Building Docker images on your laptop and pushing them by hand doesn’t scale, and isn’t reproducible. A GitHub Actions workflow can build an image on every push, tag it properly, push it to a registry, and trigger a deployment to your VPS. Here’s a complete, production-ready setup, using GitHub Container Registry (GHCR).
The workflow
# .github/workflows/docker.yml
name: Build and push image
on:
push:
branches: [main]
tags: ["v*.*.*"]
pull_request:
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Tags and labels
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,format=short
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
What it does:
- Pull requests: build only (to check that the Dockerfile works), with no push.
- Pushes to main: push
ghcr.io/owner/repo:mainand:sha-a1b2c3d. - Tags like
v1.8.3: push:1.8.3and:1.8. GITHUB_TOKENwithpackages: writeis enough for GHCR. No personal token needed.type=ghacaching makes rebuilds much faster. See Docker build cache.
Tagging strategy: Docker image tags best practices.
Multi-architecture images
Add QEMU, and a platforms list, to build amd64 and arm64 images:
- uses: docker/setup-qemu-action@v3
# …
- uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm64
# … other settings as above
See multi-arch images with buildx.
Scan before pushing
- name: Scan image
uses: aquasecurity/[email protected]
with:
image-ref: ghcr.io/${{ github.repository }}:sha-${{ github.sha }}
severity: CRITICAL,HIGH
ignore-unfixed: true
exit-code: "1"
(Build with load: true, or scan after the push, depending on your flow.) See Trivy image scanning.
Pulling on your VPS
GHCR packages are private by default for private repositories. On the server, log in with a personal access token (classic, with read:packages), or a fine-grained token:
echo "$GHCR_TOKEN" | docker login ghcr.io -u <github-user> --password-stdin
docker pull ghcr.io/owner/repo:1.8.3
Or make the package public in its settings, for open-source projects.
Deploy after the build
Add a job that runs after build, connects to the VPS over SSH, and updates the stack:
deploy:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy over SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: deploy
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /opt/app
TAG="${{ github.ref_name }}"; TAG="${TAG#v}"
sed -i "s/^APP_IMAGE_TAG=.*/APP_IMAGE_TAG=$TAG/" .env
docker compose pull && docker compose up -d
See GitHub Actions deploy to VPS, GitHub Actions secrets, and Ansible Docker deploy.
Self-hosted runners
For faster builds with persistent caches, or builds that need access to private networks, run a self-hosted runner on your own VPS (private repositories only). See self-hosted GitHub runner.
Quick reference
| Step | Action |
|---|---|
| Builder | docker/setup-buildx-action |
| Login | docker/login-action + GITHUB_TOKEN |
| Tags | docker/metadata-action (branch, semver, sha) |
| Build/push | docker/build-push-action with cache-from/to: gha |
| Multi-arch | setup-qemu-action + platforms: |
| Permissions | packages: write |
Related: Dockerfile tutorial and CI/CD pipeline explained.