DeployEasy
CI/CDIntermediate

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.

· 6 min read· 1,117 words
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 prune only 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