Shipping containers manually — logging into a server, pulling images, restarting services — doesn’t scale. A single misconfigured tag or a forgotten vulnerability scan can take down production. GitHub Actions lets you automate the entire Docker lifecycle: build images on every push, scan for vulnerabilities, publish to a container registry, and deploy to production — all from a declarative YAML workflow committed alongside your code.
This guide walks you through building a production-grade Docker CI/CD pipeline. You’ll create multi-architecture images, publish to both GitHub Container Registry (GHCR) and Docker Hub, integrate security scanning, implement caching for fast builds, and set up automated deployments.
Prerequisites
Before starting, ensure you have:
- A GitHub account with a repository containing your application code
- Docker installed locally for building and testing images
- A Dockerfile in your repository (we’ll create an optimized one below)
- Basic familiarity with YAML syntax and Docker concepts (images, layers, tags)
- A container registry account — GHCR (included with GitHub) or Docker Hub
Verify your local Docker installation:
docker --version
# Docker version 27.5.1, build 9f9e405
docker buildx version
# github.com/docker/buildx v0.19.3
Writing an Optimized Dockerfile
A CI/CD pipeline is only as good as the Dockerfile it builds. An optimized multi-stage Dockerfile reduces image size, speeds up builds, and minimizes the attack surface.
Here’s a Go application example — the pattern applies to any compiled language:
# Stage 1: Build
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/server
# Stage 2: Runtime
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
Key principles:
- Multi-stage builds — The builder stage contains compilers and tooling; only the final binary is copied to the runtime image.
- Minimal base image — Distroless images contain no shell, package manager, or unnecessary binaries — reducing CVE exposure.
- Layer ordering —
go.modandgo.sumare copied before the source code so dependency layers are cached unless dependencies change. - Non-root user — The
nonroottag runs the process as a non-root user by default.
For a Node.js application, the approach is similar:
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
COPY . .
RUN npm run build
FROM node:22-alpine
RUN addgroup -g 1001 -S appgroup && adduser -u 1001 -S appuser -G appgroup
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]
Creating the GitHub Actions Workflow
Create the workflow file at .github/workflows/docker-publish.yml. This workflow triggers on pushes to main and on version tags matching v*.*.*.
Basic Structure
name: Build and Push Docker Image
on:
push:
branches: [main]
tags: ["v*.*.*"]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
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
Let’s break down each step.
QEMU and Buildx Setup
QEMU provides user-space emulation for building images on architectures different from the runner. Buildx extends Docker with BuildKit features like multi-platform builds and advanced caching.
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
Without these steps, you can only build images for linux/amd64 (the runner’s native architecture).
Registry Authentication
The docker/login-action handles authentication for both GHCR and Docker Hub. For GHCR, the built-in GITHUB_TOKEN is sufficient — no additional secrets required.
For Docker Hub, add your username and access token as repository secrets:
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
To publish to both registries simultaneously, include two login steps and list both image paths in the metadata action:
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ github.repository }}
docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
Intelligent Tag Management
The docker/metadata-action generates tags based on Git context. The configuration above produces:
| Git Event | Generated Tags |
|---|---|
Push to main | latest, sha-a1b2c3d |
Tag v1.2.3 | 1.2.3, 1.2, sha-a1b2c3d |
| Pull request | Build only (no push) |
This tagging strategy ensures latest always points to the most recent main branch build, semver tags are immutable release markers, and SHA tags enable exact traceability.
Build Caching
The cache-from and cache-to parameters use the GitHub Actions cache backend to persist Docker build layers between runs:
cache-from: type=gha
cache-to: type=gha,mode=max
The mode=max setting caches all layers including intermediate build stages — not just the final image layers. This is critical for multi-stage builds where the builder stage contains the most time-consuming operations (dependency installation, compilation).
Typical results:
- First build: ~4–8 minutes (no cache)
- Subsequent builds (no dependency changes): ~30–60 seconds
- Cache size: Uses your repository’s GitHub Actions cache quota (10 GB)
Integrating Vulnerability Scanning
Building images without scanning them is shipping unknown risk. Add Trivy to scan your built image before it reaches production.
- name: Build image for scanning
uses: docker/build-push-action@v6
with:
context: .
load: true
tags: ${{ env.IMAGE_NAME }}:scan
cache-from: type=gha
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.IMAGE_NAME }}:scan
format: "table"
exit-code: "1"
severity: "CRITICAL,HIGH"
ignore-unfixed: true
Key parameters:
exit-code: "1"— Fails the workflow if vulnerabilities are found, preventing the image from being pushed.severity: "CRITICAL,HIGH"— Only fails on critical and high severity findings; medium and low are reported but don’t block.ignore-unfixed: true— Skips vulnerabilities without available patches to reduce noise.
To upload scan results to GitHub’s Security tab, add the SARIF output format:
- name: Upload Trivy scan results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: "trivy-results.sarif"
if: always()
Complete Production Workflow
Here’s the full workflow combining build, scan, and deploy into a two-job pipeline:
name: Docker CI/CD
on:
push:
branches: [main]
tags: ["v*.*.*"]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
permissions:
contents: read
packages: write
security-events: write
jobs:
build:
runs-on: ubuntu-latest
outputs:
image-digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}
- name: Build for scanning
uses: docker/build-push-action@v6
with:
context: .
load: true
tags: ${{ env.IMAGE_NAME }}:scan
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.IMAGE_NAME }}:scan
format: "table"
exit-code: "1"
severity: "CRITICAL,HIGH"
ignore-unfixed: true
- name: Build and push
id: build
if: github.event_name != 'pull_request'
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy to production server
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
docker compose -f /opt/app/compose.yaml up -d --pull always
docker image prune -f
Deploy Job Breakdown
The deploy job:
- Waits for the build job —
needs: buildensures the image is built, scanned, and pushed before deployment starts. - Runs only on main — The
ifcondition prevents deployments from tags or pull requests (adjust to your strategy). - Uses environment protection — The
environment: productionsetting enables manual approval gates, deployment logs, and environment-scoped secrets in GitHub’s UI. - SSHs into the server — Pulls the latest image and restarts the Compose stack. The
docker image prune -fremoves dangling images to reclaim disk space.
Alternative: Deploy with Docker Compose Remotely
For teams using Docker Compose, you can deploy by copying the compose file and running it remotely:
- name: Copy compose file
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "compose.yaml"
target: "/opt/app/"
- name: Deploy stack
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
cd /opt/app
docker compose pull
docker compose up -d --remove-orphans
Adding Secrets to Your Repository
The workflow references several secrets. Add them in Settings → Secrets and variables → Actions in your GitHub repository:
| Secret | Purpose |
|---|---|
GITHUB_TOKEN | Auto-provided by GitHub — authenticates with GHCR |
DOCKERHUB_USERNAME | Docker Hub username (only if publishing there) |
DOCKERHUB_TOKEN | Docker Hub access token (not your password) |
DEPLOY_HOST | Production server IP or hostname |
DEPLOY_USER | SSH username on the server |
DEPLOY_SSH_KEY | Private SSH key for deployment |
Generate a Docker Hub access token at hub.docker.com/settings/security. Never use your Docker Hub password in CI — access tokens can be scoped and revoked independently.
Build Arguments and Dynamic Configuration
Pass build-time variables to control image behavior without modifying the Dockerfile:
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
build-args: |
APP_VERSION=${{ github.ref_name }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
COMMIT_SHA=${{ github.sha }}
Reference these in the Dockerfile:
ARG APP_VERSION=dev
ARG BUILD_DATE
ARG COMMIT_SHA
LABEL org.opencontainers.image.version=$APP_VERSION
LABEL org.opencontainers.image.created=$BUILD_DATE
LABEL org.opencontainers.image.revision=$COMMIT_SHA
OCI labels make images self-documenting — you can inspect any running container to determine exactly which commit built it.
Troubleshooting
Build Fails with “No Space Left on Device”
GitHub Actions runners have limited disk space (~14 GB free). Large multi-arch builds can exhaust this. Free space before the build:
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf /usr/local/share/boost
df -h
Authentication Error with GHCR
Ensure the permissions block includes packages: write:
permissions:
contents: read
packages: write
Without this, the GITHUB_TOKEN lacks permission to push packages, and the login step succeeds but the push fails with a 403 error.
Cache Not Being Used
Verify you’re using the same cache-from and cache-to keys across runs. If you changed the Buildx builder name or driver, the cache key may have changed. Reset by removing and recreating the builder:
- uses: docker/setup-buildx-action@v3
with:
driver-opts: |
image=moby/buildkit:latest
Multi-arch Build Takes Too Long
ARM64 builds run under QEMU emulation on amd64 runners, which is significantly slower (3–10x). Options to speed this up:
- Use native ARM runners — GitHub now offers
ubuntu-24.04-armrunners. - Split architectures — Build each architecture in a separate job and merge with
docker buildx imagetools create. - Cache aggressively — Ensure
mode=maxis set incache-toto cache all intermediate layers.
Image Pushed but Not Visible in GHCR
New packages default to private visibility. Go to your profile → Packages → select the package → Package settings and change visibility to public, or link the package to a repository for inherited visibility.
Summary
A Docker CI/CD pipeline with GitHub Actions eliminates manual image management and enforces consistency from commit to production. The workflow you’ve built in this guide handles the full lifecycle:
- Multi-stage Dockerfiles produce minimal, secure runtime images
- Multi-arch builds with Buildx and QEMU support both amd64 and arm64 targets
- Layer caching with the GitHub Actions cache backend keeps builds fast
- Automated tagging via metadata-action produces semver, SHA, and latest tags from Git context
- Vulnerability scanning with Trivy blocks images with critical findings before they’re published
- Automated deployment via SSH pulls and restarts containers on your production server
Commit the workflow, push a change, and watch it build. Every subsequent push to main will automatically scan, publish, and deploy your Docker image — without you touching a terminal.