git push trigger Build Image multi-arch Scan vulnerabilities Push GHCR / Hub Deploy production Docker CI/CD Pipeline with GitHub Actions push → build → scan → publish → deploy

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 orderinggo.mod and go.sum are copied before the source code so dependency layers are cached unless dependencies change.
  • Non-root user — The nonroot tag 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 EventGenerated Tags
Push to mainlatest, sha-a1b2c3d
Tag v1.2.31.2.3, 1.2, sha-a1b2c3d
Pull requestBuild 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:

  1. Waits for the build jobneeds: build ensures the image is built, scanned, and pushed before deployment starts.
  2. Runs only on main — The if condition prevents deployments from tags or pull requests (adjust to your strategy).
  3. Uses environment protection — The environment: production setting enables manual approval gates, deployment logs, and environment-scoped secrets in GitHub’s UI.
  4. SSHs into the server — Pulls the latest image and restarts the Compose stack. The docker image prune -f removes 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:

SecretPurpose
GITHUB_TOKENAuto-provided by GitHub — authenticates with GHCR
DOCKERHUB_USERNAMEDocker Hub username (only if publishing there)
DOCKERHUB_TOKENDocker Hub access token (not your password)
DEPLOY_HOSTProduction server IP or hostname
DEPLOY_USERSSH username on the server
DEPLOY_SSH_KEYPrivate 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-arm runners.
  • Split architectures — Build each architecture in a separate job and merge with docker buildx imagetools create.
  • Cache aggressively — Ensure mode=max is set in cache-to to 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.