How to Deploy Docker to a VPS with GitHub Actions
Build a Docker image with GitHub Actions, publish it to GHCR, and deploy a tested release to an Ubuntu VPS with a safe rollback path.
Article by Phạm Minh Thiện
Front-end developer working directly with Next.js, NestJS, Docker, Nginx, and Ubuntu VPS deployments.
Reviewed on 7/27/2026.
Table of contents
How to Deploy Docker to a VPS with GitHub Actions
Building a Docker image directly on a small VPS is convenient until the build competes with PostgreSQL, Redis, Nginx, and the application already running there. This guide moves the expensive build to GitHub Actions, publishes the image to GitHub Container Registry (GHCR), and leaves the VPS responsible for pulling and starting a tested release.
The deployment flow is:
Push to main
↓
GitHub Actions builds and pushes an image to GHCR
↓
GitHub Actions connects to the VPS over SSH
↓
The VPS pulls the image for the commit SHA
↓
Docker Compose starts the new release and checks its health
Prerequisites
You need:
- A GitHub repository containing a working
Dockerfile. - An Ubuntu VPS with Docker Engine and the Docker Compose plugin.
- An SSH key that GitHub Actions can use for deployment.
- A non-root deployment user with permission to run Docker.
- A health endpoint such as
/health.
Check the server before changing anything:
docker --version
docker compose version
curl -fsS http://127.0.0.1/health
Create a dedicated user when the server is new:
sudo adduser deploy
sudo usermod -aG docker deploy
Log in again after changing the group so the Docker permission is refreshed. Do not put a private key, password, or registry token inside the repository.
Deployment architecture
The VPS should not need the complete source tree or a Node.js build toolchain. It only needs a Compose file that references an immutable image tag:
services:
web:
image: ghcr.io/OWNER/REPOSITORY:${IMAGE_TAG}
restart: unless-stopped
env_file: .env
ports:
- "3000:3000"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 30s
timeout: 5s
retries: 3
Using the commit SHA instead of latest makes every deployment traceable and makes rollback predictable.
Step 1: Create a production Dockerfile
Use a multi-stage build so the runtime image does not contain development dependencies or the source control metadata:
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server/entry.mjs"]
Adjust the final command for the framework used by your application. Run the same image locally before pushing it:
docker build -t deployeasy-demo:local .
docker run --rm -p 3000:3000 deployeasy-demo:local
curl -fsS http://127.0.0.1:3000/health
Step 2: Push the image to GHCR
GitHub Actions can authenticate to GHCR with the built-in GITHUB_TOKEN. The workflow needs packages: write permission:
name: Deploy production
on:
push:
branches: [main]
permissions:
contents: read
packages: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: type=sha
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
The registry package may be private by default. That is usually the right choice for production applications.
Step 3: Configure GitHub secrets
Add these repository secrets:
VPS_HOST public hostname or IP address
VPS_USER deploy
VPS_SSH_KEY private key used only by this workflow
If SSH listens on a non-standard port, add VPS_PORT as a variable or secret. The public key belongs in /home/deploy/.ssh/authorized_keys on the VPS. Restrict the key and user to the smallest access level that still supports the deployment.
Step 4: Deploy through SSH
After the image job succeeds, run a separate deployment job. This example writes the image tag to the server and starts only that version:
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Copy Compose file
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
source: compose.prod.yml
target: /srv/deployeasy
- name: Pull and start the release
uses: appleboy/ssh-action@v1.2.0
env:
IMAGE_TAG: sha-${{ github.sha }}
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
envs: IMAGE_TAG
script: |
set -eu
cd /srv/deployeasy
export IMAGE_TAG
docker login ghcr.io -u "${{ github.actor }}" -p "${{ secrets.GITHUB_TOKEN }}"
docker compose -f compose.prod.yml pull web
docker compose -f compose.prod.yml up -d web
sleep 5
docker compose -f compose.prod.yml ps
curl --fail --silent http://127.0.0.1:3000/health
For a private GHCR package, use a short-lived or narrowly scoped read-only token stored as a VPS secret. Do not print it in workflow logs.
Step 5: Verify the release
Check the container, logs, and public endpoint in that order:
docker compose -f /srv/deployeasy/compose.prod.yml ps
docker compose -f /srv/deployeasy/compose.prod.yml logs --tail=100 web
curl -I https://app.example.com
If the health check fails, inspect the application log before restarting repeatedly. Common causes include a missing environment variable, a wrong listening address, a database migration failure, or a port mismatch between Compose and the application.
Rollback strategy
Keep the previous image tag until the new release has passed its checks. To roll back, set IMAGE_TAG to the last known-good commit and start the service again:
cd /srv/deployeasy
export IMAGE_TAG=sha-PREVIOUS_COMMIT
docker compose -f compose.prod.yml pull web
docker compose -f compose.prod.yml up -d web
curl --fail http://127.0.0.1:3000/health
Do not delete old images until you have confirmed that the release is stable and that your database backup is usable. Application rollback does not automatically roll back a destructive database migration.
Security checklist
- Keep the SSH private key and registry credentials in GitHub Secrets.
- Use a dedicated non-root user and firewall the server with UFW.
- Pin action versions and review third-party actions before using them.
- Prefer immutable commit tags over
latest. - Keep the database private; expose only the application or reverse proxy port.
- Run
docker image pruneonly with an intentional retention policy.
Frequently asked questions
Does the VPS need Node.js installed?
No. The build and runtime dependencies are inside the image. The VPS needs Docker and the Compose plugin.
Why use GHCR instead of copying a tar file?
GHCR gives the workflow a registry with authentication, caching, and a clear image history. It also avoids moving a large build artifact through an ad-hoc SSH command.
Should I deploy on every push?
Only deploy from a protected branch after tests pass. For higher-risk applications, add a staging environment and require approval before deploying to production.
Conclusion
Moving the build to GitHub Actions keeps a small VPS focused on running the application. Immutable image tags, a health check, SSH secrets, and a tested rollback path turn a manual git pull deployment into a repeatable production workflow.
Continue reading
Related articles
GitHub Actions CI/CD: Automatically Deploy Node.js to a VPS
Create a secure GitHub Actions pipeline that builds, tests, and deploys Node.js to a VPS over SSH with secrets, health checks, concurrency, and rollback.
Read article →How to Fix Docker Exit Code 137 on a VPS
Diagnose OOM kills, RAM pressure, swap, and heavy Docker builds before changing a small VPS or restarting production services.
Read article →PostgreSQL Backups on a VPS with pg_dump and cron: 18 Steps with Restore Testing
Learn how to automate PostgreSQL backups on a VPS using pg_dump, cron, checksums, retention, offsite copies, and scheduled restore tests.
Read article →