Automating Docker Deployments with GitHub Actions
Manually building Docker images, pushing them, and SSHing into servers to restart containers gets old fast. This post shows a production-ready CI/CD pipeline that rebuilds only changed services and deploys automatically on every push to main.

Martin Binder
ID @martin
The Stack
The setup consists of several independent services — a Go API backend, a couple of Nuxt frontend apps, a worker service, and an admin utility — each with its own Dockerfile. All images are stored in the GitHub Container Registry (GHCR) and orchestrated on a VPS with Docker Compose.
The pipeline has three stages:
- Detect changes — figure out which services actually changed
- Build and push — rebuild only the touched images
- Deploy — pull new images and restart containers on the VPS
Detecting Changes
The first job uses dorny/paths-filter to check which directories were modified in the push:
changes:
runs-on: ubuntu-latest
outputs:
server: ${{ steps.filter.outputs.server }}
client: ${{ steps.filter.outputs.client }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
server:
- 'server/**'
client:
- 'client/**'
Each service maps to a directory. If nothing in server/ changed, the server image won’t be rebuilt. On a multi-service repo this saves significant CI minutes.
Building Only What Changed
The build job uses a matrix strategy — one job per service — and passes the changed flag from the previous job into each matrix entry:
build-and-push:
needs: changes
strategy:
matrix:
include:
- name: server
context: ./server
changed: ${{ needs.changes.outputs.server }}
- name: client
context: ./client
build_args: "NODE_ENV=production"
changed: ${{ needs.changes.outputs.client }}
steps:
- name: Build and push ${{ matrix.name }}
if: matrix.changed == 'true'
uses: docker/build-push-action@v5
with:
context: ${{ matrix.context }}
push: true
tags: ghcr.io/your-org/your-repo/${{ matrix.name }}:latest
build-args: ${{ matrix.build_args }}
The if: matrix.changed == 'true' condition means the build and push steps are skipped for unchanged services. The jobs still appear in the Actions UI but complete instantly.
Images are pushed to GHCR using GITHUB_TOKEN — no separate registry credentials needed.
The Deploy User
Rather than deploying as root or your personal user, a dedicated deploy user handles all deployment operations on the VPS:
sudo adduser --disabled-password --gecos "" deploy
sudo usermod -aG docker deploy
Adding deploy to the docker group means it can run docker compose without sudo. The user has no password — it can only be accessed via SSH key.
A dedicated SSH key pair is generated for GitHub Actions:
ssh-keygen -t ed25519 -C "github-deploy" -f ~/.ssh/github_deploy
The private key goes into GitHub Secrets as VPS_SSH_KEY. The public key is added to /home/deploy/.ssh/authorized_keys on the VPS.
Deploying
The deploy job waits for all matrix build jobs to finish, then SSHes into the VPS and restarts the stack:
deploy:
needs: build-and-push
steps:
- name: Get short SHA
id: short-sha
run: echo "sha=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
- name: Deploy to VPS
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /srv/myapp
git pull
docker compose pull
APP_ENV=prod APP_VERSION=${{ steps.short-sha.outputs.sha }} docker compose up -d
docker image prune -a -f
A few things worth noting here:
git pull before everything. The docker-compose.yml, Caddyfile, and other config files live in the repo. Pulling first ensures the VPS always runs the latest compose config, not a stale version.
Short SHA as version. The 7-character git SHA is passed as APP_VERSION to the running containers. This lets the backend expose it via a /version endpoint, which the frontend can fetch to display the running version — no Nuxt build-time config needed.
Image pruning. docker image prune -a -f removes old images after every deploy, keeping disk usage in check.
Passing Version at Runtime (and Why Build Args Aren’t Enough for Nuxt)
An interesting rabbit hole: passing a git SHA to a Nuxt app as a build arg doesn’t work the way you’d expect.
Nuxt’s runtimeConfig evaluates process.env.* at build time and bakes the value into the output. If you write:
runtimeConfig: {
public: {
version: process.env.MY_VERSION || 'dev'
}
}
…the value of process.env.MY_VERSION is hardcoded into the bundle during nuxt build. Setting the env var on the running container does nothing.
Nuxt does support runtime overrides, but only for variables prefixed with NUXT_PUBLIC_ — and only if you don’t use process.env in the config at all:
runtimeConfig: {
public: {
version: 'dev' // default only, no process.env
}
}
With this, setting NUXT_PUBLIC_VERSION=abc1234 at runtime will override the value. But managing per-deploy env vars across containers adds friction.
The cleaner solution: expose the version from the backend API instead.
var Version = "dev"
router.GET("/version", func(c *gin.Context) {
c.JSON(200, gin.H{"version": Version})
})
The frontend fetches /version on load. The backend gets APP_VERSION injected via Docker Compose:
server:
environment:
APP_VERSION: ${APP_VERSION:-dev}
And the deploy script passes the SHA inline:
APP_VERSION=abc1234 docker compose up -d
Single source of truth, no build-time gymnastics.
Branch Protection
With the pipeline in place, it makes sense to protect main. Under Settings → Branches → Add ruleset, requiring status checks to pass before merging ensures nothing broken lands on the main branch. For a solo project, skipping the pull request requirement but keeping the status check is a reasonable middle ground.
Summary
The full flow on every push to main:
- Detect which service directories changed
- Rebuild only those Docker images and push to GHCR
- SSH into the VPS, pull the latest compose config, pull new images, restart containers, prune old images
The result is a pipeline that’s fast (skips unchanged services), clean (dedicated deploy user, no root), and self-updating (git pull keeps config in sync).
Comments
No comments yet.