Docker builds in GitHub Actions, done right
A fast Docker CI workflow on GitHub Actions needs three things: a docker-container builder from docker/setup-buildx-action, layer caching with cache-from/cache-to: type=gha, and pinned image and action references - here is the complete workflow, ready to paste.
The complete Docker workflow
Drop this in .github/workflows/docker.yml, adjust the registry and image name, and it builds and pushes a cached image on every push. [sources]
# Build and push a Docker image with GitHub Actions: tuned template.
#
# What makes this fast and correct, and where each choice is documented:
# - A docker-container builder via docker/setup-buildx-action. The Engine's
# default "docker" driver cannot export to the type=gha cache backend;
# this driver can.
# https://docs.docker.com/build/cache/backends/gha/
# - Layer caching with cache-from/cache-to: type=gha, mode=max. mode=max
# caches intermediate stages too, so multi-stage builds actually hit.
# https://docs.docker.com/build/cache/backends/
# - Tags and OCI labels derived by docker/metadata-action instead of a
# hand-rolled ${{ github.sha }}.
# https://github.com/docker/metadata-action
# - Least-privilege permissions: read the repo, write packages (GHCR).
# https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#permissions
# - A concurrency block so a new push cancels the in-flight build for the
# same ref instead of paying for both.
# https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#concurrency
# - Every action pinned to a full commit SHA (a mutable @v7 tag is a supply-
# chain risk); the trailing comment keeps the human-readable version.
# https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions
#
# Adapt it: swap the type=gha cache for a registry cache (commented below) when
# your layers outgrow the repository's Actions-cache budget or you want a cache
# that survives Actions-cache eviction, and keep your Dockerfile multi-stage so
# mode=max caching has stages to restore.
name: Build and push Docker image
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
branches: [main]
# Cancel an in-flight build when a newer commit lands on the same ref. Pushes
# to main and PR updates both benefit; nothing waits behind a stale build.
concurrency:
group: docker-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Least privilege: read the checkout, write the image to GHCR. No id-token,
# no broad write. Widen only if a step genuinely needs more.
permissions:
contents: read
packages: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# Creates a docker-container builder. The default "docker" driver cannot
# export to the type=gha cache backend used below; this one can.
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
# Skip the push (and the login) on every pull_request build: fork PRs have
# no registry write, and an unmerged PR has nothing to publish. The PR
# still builds, and it still restores the base branch's cache.
- name: Log in to the Container registry
if: github.event_name != 'pull_request'
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Derive tags + OCI labels from the event (branch, tag, sha) so you never
# hand-maintain a tag expression.
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=sha,format=long
- name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
# Build on PRs (restoring the base branch's cache), push only on
# branch/tag events. Note the cache a PR writes is scoped to that PR:
# main cannot restore it, so this speeds up the PR, not the merge.
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# The layer cache. type=gha stores layers in the GitHub Actions cache;
# mode=max also caches intermediate build stages, so a multi-stage
# Dockerfile restores its builder layers instead of rebuilding them.
cache-from: type=gha
cache-to: type=gha,mode=max
# Registry-cache alternative: use this instead of the two lines above
# when your layers outgrow the repository's Actions-cache budget (10 GB
# by default) or you want a cache that outlives Actions-cache eviction
# (unused entries are removed after 7 days). It stores the cache as a
# manifest in your registry:
#
# cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
# cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=maxWhat each line of the Docker workflow buys you
- Config
cache-from / cache-to: type=gha, mode=maxWhat it doesStores build layers in the GitHub Actions cache and restores them next run; mode=max also caches intermediate build stages.ImpactTurns a full image rebuild into a layer restore on a cache hit - the build's dominant cost. Speedup is workload-dependent; unmeasured on your repo. - Config
docker/setup-buildx-actionWhat it doesCreates a docker-container builder. The Engine's default docker driver cannot export to the type=gha cache backend; this one can.ImpactEnables the cache above; no direct speedup on its own. - Config
concurrency: cancel-in-progressWhat it doesCancels an in-flight build when a newer commit lands on the same ref.ImpactFrees a runner instead of paying for a superseded build - cost, not wall-clock. - Config
permissions: contents: read, packages: writeWhat it doesGrants the workflow only the checkout read and the GHCR write it needs.ImpactSecurity, not speed: limits the blast radius if a step is compromised. - Config
docker/metadata-actionWhat it doesDerives tags and OCI labels from the event (branch, tag, sha).Impactunmeasured - Config
uses: …@<40-char-sha>What it doesPins every action to an immutable commit instead of a mutable @v tag.ImpactSecurity: blocks a mutable-tag supply-chain swap. No speed impact.
Adapting the Docker workflow
Registry cache instead of type=gha
The GitHub Actions cache is 10 GB per repository by default, evicts least-recently-used entries when it fills, and drops any entry unused for 7 days. When your layer cache outgrows that budget - or the repo builds rarely enough that the 7-day expiry keeps cold-starting it - store the cache as a manifest in your registry instead, where it persists independently of the Actions cache.
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=maxKeep the Dockerfile multi-stage
mode=max caches intermediate stages, so a multi-stage Dockerfile - a heavy builder stage plus a slim runtime stage - restores its build layers on a cache hit. Pin the base image to a real version so the first layer is stable and cacheable.
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:22-bookworm-slim
COPY --from=build /app/dist /app/dist
CMD ["node", "/app/dist/server.js"]Healthchecks, not sleep, for service readiness
If your job starts a database or other service before integration tests, don't guess at startup time with sleep. Add a healthcheck and let docker compose up --wait block exactly until the service is ready - faster on a fast start, reliable on a slow one.
services:
db:
image: postgres:16.4 # pinned, not :latest
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres"]
interval: 3s
timeout: 3s
retries: 20
# then in the workflow:
# - run: docker compose up --waitDocker CI anti-patterns to avoid
Building without a layer cache
Every run rebuilds every layer from scratch, so the base-image install and dependency layers pay full cost on each push - even when nothing about them changed.
- uses: docker/build-push-action@v7
with:
context: .
push: true
tags: ghcr.io/acme/app:latest
# no cache-from / cache-to: every layer rebuilds from scratch, every runsleep-based container readiness
A fixed sleep is either too short (flaky failures when the container is slow) or too long (wasted minutes on every run). Use a healthcheck and docker compose up --wait so the step blocks exactly until the service is ready.
- run: |
docker compose up -d
sleep 30 # hope the DB is ready by now
./run-integration-tests.shFloating :latest and unpinned images
A floating :latest base image resolves to a different image over time, so builds stop being reproducible and every layer below the FROM misses the cache once the base moves. An unpinned service image in Compose (postgres:latest) is not a build input, so it costs you reproducibility rather than cache hits - your tests silently run against a different Postgres. Pin both to a real version (node:22-bookworm-slim, postgres:16.4).
# Dockerfile
FROM node:latest # moves under you: a different Node per build
# The same problem in docker-compose.yml, for your test services:
# image: postgres:latestSeen in the wild
Real workflow files from public projects.
Each shows the type=gha layer cache in a real pipeline. Some still reference their Docker actions by version tag rather than a pinned SHA; the tuned template above pins them.
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/production/Dockerfile
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-${{ matrix.arch }}
cache-from: |
type=gha,scope=build-${{ matrix.arch }}
type=registry,ref=${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.arch }} uses: docker/build-push-action@v6
with:
context: .
file: ./scripts/Dockerfile
platforms: ${{ matrix.platform }}
build-args: |
VERSION=${{ needs.prepare.outputs.version }}
COMMIT=${{ github.sha }}
cache-from: type=gha,scope=release-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=release-${{ matrix.platform }} - uses: docker/setup-buildx-action@v4
- uses: docker/build-push-action@v7
with:
context: "tests"
file: tools/type-checking/Dockerfile
tags: redpanda-data/python-type-check
cache-from: type=gha
cache-to: type=gha,mode=maxVerify on your repo
Hand this prompt to your coding agent (Claude Code, Cursor, and the like) to audit and fix your Docker CI in your own repo.
Set up or audit Docker image builds in this repo's .github/workflows. The target is the standard fast pattern: docker/setup-buildx-action for BuildKit, docker/build-push-action with cache-from: type=gha and cache-to: type=gha,mode=max, docker/metadata-action for tags/labels, a least-privilege permissions block, a concurrency block that cancels superseded runs, and every action pinned to a full commit SHA. Also flag any `sleep`-based waits for container readiness (replace with a healthcheck and `docker compose up --wait`) and any :latest image tags (pin them). Make the changes, then show me the diff and open a PR rather than applying it blindly.
Ground these changes in the upstream docs before you edit: https://docs.docker.com/build/cache/backends/gha/, https://github.com/docker/metadata-action. If you cannot fetch them, say so rather than guessing, and cite what you used in the PR description.Prefer to check by hand?
Find your Docker build:
grep -rnE 'docker/build-push-action|docker build|docker compose build' .github/workflows/.Confirm
docker/setup-buildx-actionruns before the build, and the build step has bothcache-from: type=ghaandcache-to: type=gha,mode=max.Search for
sleep:grep -rnE 'sleep [0-9]' .github/workflows/(add yourdocker-compose.ymlif you have one) - replace each with a healthcheck anddocker compose up --wait.Search for floating tags:
grep -rnE ':latest' .github/workflows/ Dockerfile(add yourdocker-compose.ymlif you have one) and pin each image to a version.Read a warm run's log: BuildKit prints
CACHEDon restored layers. If nothing is cached, the builder or the cache-to line is missing.
Automate this with StarSling.
Rather than audit this by hand, install the StarSling GitHub App: it opens a pull request to move your workflows onto StarSling runners, then its agents keep reading your jobs, run logs, and machine telemetry and open pull requests that fix what this page covers, along with caching, parallelization, and test sharding across the rest of your pipeline. Every change arrives as a PR you review and merge.
FAQ
How do I set up a Docker build in GitHub Actions?
Use the complete workflow above: check out the repo, set up BuildKit with docker/setup-buildx-action, log in to your registry, derive tags with docker/metadata-action, then build and push with docker/build-push-action - with cache-from: type=gha and cache-to: type=gha,mode=max so layers are cached between runs. Copy it, adjust the registry and image name, and it works.
How do I cache Docker layers between GitHub Actions runs?
Add a docker-container builder (docker/setup-buildx-action), then pass cache-from: type=gha and cache-to: type=gha,mode=max to docker/build-push-action. type=gha uses the GitHub Actions cache as the layer store; mode=max caches intermediate stages too, which is what a multi-stage Dockerfile needs to actually hit.
Should I wait for a container with sleep before running tests?
No - a fixed sleep is flaky when the container starts slowly and wasteful when it starts fast. Add a healthcheck to the service in docker-compose.yml and run docker compose up --wait, which blocks precisely until the service reports healthy. That is both faster on average and reliable.
Do I need docker/login-action if I only build on pull requests?
No. Guard the login and the push with if: github.event_name != 'pull_request'. Fork PRs have a read-only token and can't push to the registry, but they should still build (with push: false) to catch Dockerfile breakage, and that build restores the base branch's cache so it stays fast. A cache written by a PR is scoped to that PR - main cannot restore it - so the win is on the PR, not on the merge build.
Related
Where to go next.
Sources
1docker/build-push-action (opens in new tab)
2Docker docs · GitHub Actions cache backend (type=gha) (opens in new tab)
3Docker docs · Cache management with GitHub Actions (opens in new tab)
4docker/metadata-action (opens in new tab)
5docker/setup-buildx-action (opens in new tab)
6GitHub docs · Publishing Docker images to GHCR (opens in new tab)
7GitHub docs · Security hardening: pin third-party actions to a full SHA (opens in new tab)
Last updated 2026-07-10
A fast Docker pipeline, without the tuning tax.
One line to install. Faster runs on day one, and agents that open reviewable PRs to keep your pipeline tuned.