Skip to content
FreeToolNet
Go back

Deploy a Docker site to a VPS with GitHub Actions, GHCR and SSH

I wanted git push to be the whole deploy for sites on my VPS: build the image on GitHub, push it to GitHub Container Registry (GHCR), then pull and restart on the server. I set this up first for a Next.js portfolio and then reused it for this blog.

The pipeline is two jobs and about a hundred lines. Two things broke the early runs: SSH on my server is not on port 22, and the health check ran before the app had started. Both fixes are in the workflow below.

The code in this post is from this blog’s repository, which serves a static build from an nginx image. The portfolio workflow has the same build, push and SSH steps with a Next.js image.

The layout on the server

  • The container listens on a loopback port only, for example 127.0.0.1:3011.
  • The host nginx owns ports 80 and 443, terminates TLS with a certbot certificate and proxies to that loopback port.
  • A compose file lives in /opt/<app> on the server. CI copies it there on every deploy.

The compose file is short:

# Runs on the VPS in /opt/ai-blog. The image is built and pushed by CI.
services:
  blog:
    image: ${BLOG_IMAGE:?set BLOG_IMAGE, e.g. ghcr.io/owner/repo:latest}
    container_name: ai-blog
    restart: unless-stopped
    ports:
      - "127.0.0.1:3011:80"
    mem_limit: 64m
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

Three details matter here.

127.0.0.1:3011:80 publishes the port on loopback only. Without the 127.0.0.1 prefix Docker publishes on all interfaces, and the container is reachable from the internet without TLS.

${BLOG_IMAGE:?...} makes compose stop with a message if the variable is missing. The workflow sets it to the image tagged with the commit SHA, so each deploy runs exactly the image that was just built.

mem_limit is small because this container only serves static files. The Next.js portfolio uses the same file shape with a 256 MB limit and a 127.0.0.1:3010 to 3000 mapping.

Secrets and permissions

The workflow needs four repository secrets:

SecretWhat it holds
VPS_HOSTThe server address
VPS_PORTThe SSH port
VPS_USERA deploy user that is in the docker group
VPS_SSH_KEYThe private key for that user

It does not need a registry password. The job’s own GITHUB_TOKEN can push to GHCR once the workflow asks for the packages: write permission:

permissions:
  contents: read
  packages: write

env:
  IMAGE: ghcr.io/${{ github.repository }}

GitHub documents this in Working with the Container registry.

Job 1: build and push

on:
  push:
    branches: [main]
  workflow_dispatch:

concurrency:
  group: deploy-vps
  cancel-in-progress: false

jobs:
  build:
    name: Build and push image
    # Skipped until the domain is set, so pushes before launch don't fail.
    if: vars.SITE_URL != ''
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v7

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/build-push-action@v6
        with:
          context: .
          file: deploy/Dockerfile
          push: true
          tags: |
            ${{ env.IMAGE }}:latest
            ${{ env.IMAGE }}:${{ github.sha }}
          build-args: |
            SITE_URL=${{ vars.SITE_URL }}
            SITE_TITLE=${{ vars.SITE_TITLE }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Notes on the choices:

  • concurrency with cancel-in-progress: false queues a second push behind the first. Two deploys never run docker compose up on the server at the same time, and a running deploy is not killed halfway.
  • The image gets two tags. latest is for humans. The SHA tag is what the deploy job uses.
  • Site settings go in as build-args. Both of my sites fix their public settings at build time, so the values must be present when the image is built. Setting them on the running container does nothing.
  • The if: on the job is specific to this blog. The build needs the site URL, so the job is skipped until that repository variable exists.

Building on GitHub’s runner also keeps the build off the server. My VPS has little free memory, and a Next.js build there needed extra swap.

Job 2: copy the compose file and restart

deploy:
  name: Deploy on VPS
  needs: build
  runs-on: ubuntu-latest
  timeout-minutes: 10
  steps:
    - uses: actions/checkout@v7

    - name: Copy compose file
      uses: appleboy/scp-action@v1
      with:
        host: ${{ secrets.VPS_HOST }}
        port: ${{ secrets.VPS_PORT }}
        username: ${{ secrets.VPS_USER }}
        key: ${{ secrets.VPS_SSH_KEY }}
        source: deploy/docker-compose.yml
        target: /opt/ai-blog
        strip_components: 1

    - name: Pull and restart
      uses: appleboy/ssh-action@v1
      env:
        BLOG_IMAGE: ${{ env.IMAGE }}:${{ github.sha }}
        GHCR_USER: ${{ github.actor }}
        GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      with:
        host: ${{ secrets.VPS_HOST }}
        port: ${{ secrets.VPS_PORT }}
        username: ${{ secrets.VPS_USER }}
        key: ${{ secrets.VPS_SSH_KEY }}
        envs: BLOG_IMAGE,GHCR_USER,GHCR_TOKEN
        script: |
          set -eu
          cd /opt/ai-blog
          echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin
          export BLOG_IMAGE
          docker compose pull
          docker compose up -d
          docker logout ghcr.io
          docker image prune -f
          # The container needs a moment to start; retry before failing.
          for i in $(seq 1 20); do
            if curl -fsS -o /dev/null http://127.0.0.1:3011/; then
              echo "healthy"; exit 0
            fi
            sleep 3
          done
          echo "blog did not become healthy" >&2
          docker compose logs --tail 50
          exit 1

strip_components: 1 drops the deploy/ prefix, so the file lands at /opt/ai-blog/docker-compose.yml.

envs: is the part that is easy to miss. Variables in the step’s env: block exist on the runner. They reach the remote shell only if they are named in envs:.

The server logs in to GHCR with the job’s token, pulls, and logs out again. No long-lived registry credential is stored on the VPS. The token is passed on stdin with --password-stdin, not on the command line.

set -eu stops the script at the first failed command. Without it a failed docker compose pull is followed by an up -d that restarts the old image, and the job goes green.

Gotcha 1: SSH is not on port 22

Port 22 is closed on my server and SSH listens on a non-default port. The SSH and SCP actions default to 22, so the deploy job could not connect until I added the VPS_PORT secret and passed it as port: to both steps.

If you move SSH later, this secret is one more place to update.

Gotcha 2: the health check raced the app

My first health check was one curl straight after docker compose up -d. It failed on deploys that were fine. up -d returns when the container has been started, not when the app inside is ready to answer. The Next.js server needs a few seconds to boot, and the probe got there first.

The fix is the retry loop above: up to 20 attempts, 3 seconds apart. The job exits 0 on the first success. If all 20 fail, it prints the last 50 log lines and exits 1, so the failed run shows why the container did not come up.

The check hits the loopback port directly. It tests the container, not nginx or DNS, so a DNS problem cannot fail a good deploy.

Gotcha 3: read the right workflow

The portfolio repository also has a lint and test workflow named CI. It was failing while the deploy workflow was green. The red mark on the commit came from CI, not from the deploy. Check which workflow failed before debugging the server.

Result

After the four secrets were set, the portfolio workflow ran green end to end: build, push to GHCR, SSH deploy and health check. The container on the server now runs the CI-built image, and a push to main is the deploy.

How this post was made

Written with AI assistance and reviewed by the author before publishing. See the editorial policy.

Commands and output were captured on 2026-11-07 on GitHub Actions ubuntu-latest runner; single VPS with Docker and Docker Compose; host nginx terminates TLS and proxies to a container bound to 127.0.0.1 (Docker 29, Docker Compose v5, Next.js 16.4.5, node:24-alpine, nginx:1.29-alpine).



Previous Post
Running a GPU-first Gradio app CPU-only on a 2 vCPU VPS
Next Post
Building a Next.js Docker image on a low-memory VPS with temporary swap