MAY 27, 20265 MIN READ

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

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:

  1. Detect changes — figure out which services actually changed
  2. Build and push — rebuild only the touched images
  3. 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:

  1. Detect which service directories changed
  2. Rebuild only those Docker images and push to GHCR
  3. 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

Sign in to comment. Sign in

No comments yet.