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:
| Secret | What it holds |
|---|---|
VPS_HOST | The server address |
VPS_PORT | The SSH port |
VPS_USER | A deploy user that is in the docker group |
VPS_SSH_KEY | The 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:
concurrencywithcancel-in-progress: falsequeues a second push behind the first. Two deploys never rundocker compose upon the server at the same time, and a running deploy is not killed halfway.- The image gets two tags.
latestis 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.