Skip to content
compiler.dev

Docker Build GitHub Actions Workflow Template

Last updated

This is a working .github/workflows/ci.yml that builds your Docker image on every pull request and pushes it to GitHub Container Registry (GHCR) from main. It uses Buildx with the GitHub Actions layer cache so unchanged layers are not rebuilt.

The workflow

name: CI

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

concurrency:
  group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
  IMAGE: ghcr.io/${{ github.repository }}

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        if: github.event_name != 'pull_request'
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.IMAGE }}
          tags: |
            type=sha
            type=raw,value=latest,enable={{is_default_branch}}

      - 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 the non-obvious parts do

  • Permissions. The workflow default is read-only. The job adds packages: write because only it pushes images. Pull requests from forks get a read-only token anyway, so they cannot push.
  • setup-buildx-action creates a Buildx builder. The default Docker driver cannot export the GitHub cache, so this step is required for cache-to: type=gha.
  • Login only when pushing. On pull requests there is nothing to push, so the step is skipped.
  • docker/metadata-action generates tags and OCI labels. The example tags every build with the commit SHA and adds latest on the default branch.
  • push: expression. Pull requests build only, as a check that the Dockerfile still works. Pushes to main build and push.
  • cache-from and cache-to: type=gha,mode=max read and write the GitHub Actions cache. mode=max caches every layer from every stage, which matters for multi-stage builds, though it uses more cache space. See cache limits.
  • Dockerfile order matters more than any setting. Copy lockfiles and install dependencies before copying source, so code edits do not invalidate dependency layers. The full list of techniques is in Docker layer caching in CI.

Variants

Multi-platform images

Add docker/setup-qemu-action@v3 before Buildx and platforms: linux/amd64,linux/arm64 to the build step. Emulated arm64 builds are slow; use a native ARM runner when speed matters.

Scan before push

Add a vulnerability scan step on the built image before the push, and fail on high severity findings.

Run tests inside the image

Add a first build step with load: true, push: false and a test stage, then run docker run on the result. Share layers through the same cache.

Docker Hub or ECR

Change registry, the login step and images. For AWS, use OIDC with aws-actions/amazon-ecr-login@v2 instead of stored keys.

Deploy job

Add a second job with needs: build that deploys the pushed SHA tag.

macOS

Docker is not available on macOS runners. Keep container builds on Linux.

Run it on faster machines

Docker builds are CPU and disk heavy, and layer caches only help if they are close to the build. Change one line:

    runs-on: [self-hosted, Linux, compiler-ci]

The job runs on a fresh 8 vCPU machine from compiler.dev, which keeps a Docker layer cache on the machine, with automatic fallback to your original runner. For image builds specifically, dedicated builders such as Depot can be faster; compare options in faster GitHub Actions runners. Prices are on pricing.

FAQ

Why is my build not using the cache?

Check that Buildx is set up, that cache-from and cache-to match, and that an early Dockerfile layer is not changing every run (for example copying the whole repo first).

Is the GitHub cache big enough?

Repositories have a size cap shared by all caches, and old entries are evicted. Large images can fill it.

Do pull requests from forks push images?

No. The push expression is false for pull requests, and fork tokens are read-only.

What are artifacts for, then?

Use an artifact to pass a built tarball between jobs, not for layer caching.