---
title: "GitHub Actions Docker build: the fast, cached workflow"
description: "A complete, copyable GitHub Actions workflow to build and push Docker images: BuildKit, type=gha layer caching, SHA-pinned actions, healthchecks not sleep."
url: https://starsling.dev/ci/docker
canonicalUrl: https://starsling.dev/ci/docker
---

# Docker builds in GitHub Actions, done right

Workflow pattern - Docker. Last updated: 2026-07-10

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 workflow

Copy this into `.github/workflows/docker.yml`, or download it from https://starsling.dev/templates/docker-build-push.yml

```yaml
# 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=max
```

## What each line buys you

| Config | What it does | Impact |
|---|---|---|
| cache-from / cache-to: type=gha, mode=max | Stores build layers in the GitHub Actions cache and restores them next run; mode=max also caches intermediate build stages. | Turns 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. |
| docker/setup-buildx-action | Creates a docker-container builder. The Engine's default docker driver cannot export to the type=gha cache backend; this one can. | Enables the cache above; no direct speedup on its own. |
| concurrency: cancel-in-progress | Cancels an in-flight build when a newer commit lands on the same ref. | Frees a runner instead of paying for a superseded build - cost, not wall-clock. |
| permissions: contents: read, packages: write | Grants the workflow only the checkout read and the GHCR write it needs. | Security, not speed: limits the blast radius if a step is compromised. |
| docker/metadata-action | Derives tags and OCI labels from the event (branch, tag, sha). | unmeasured |
| uses: ...@<40-char-sha> | Pins every action to an immutable commit instead of a mutable @v tag. | Security: blocks a mutable-tag supply-chain swap. No speed impact. |

## Adapting it

### 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.

```yaml
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max
```

### Keep 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.

```dockerfile
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.

```yaml
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 --wait
```

## Avoid these

### 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.

```yaml
- 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 run
```

### sleep-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.

```yaml
- run: |
    docker compose up -d
    sleep 30          # hope the DB is ready by now
    ./run-integration-tests.sh
```

### Floating :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
# 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:latest
```

## Seen 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.

### coollabsio/coolify - .github/workflows/coolify-staging-build.yml

```yaml
      - 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 }}
```

Key lines: `uses: docker/build-push-action@v6`, `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 }}`.

A per-arch matrix build that reads cache from both the gha backend and a registry buildcache tag while writing gha cache scoped to the architecture.

Source: [coollabsio/coolify `.github/workflows/coolify-staging-build.yml` lines 71-84](https://github.com/coollabsio/coolify/blob/e7dff30b7c998c301fd91bd169727b90c59ec291/.github/workflows/coolify-staging-build.yml#L71-L84) at commit `e7dff30`.

### usememos/memos - .github/workflows/release.yml

```yaml
        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 }}
```

Key lines: `uses: docker/build-push-action@v6`, `cache-from: type=gha,scope=release-${{ matrix.platform }}`, `cache-to: type=gha,mode=max,scope=release-${{ matrix.platform }}`.

A buildx multi-platform release build that scopes its gha cache per platform so parallel matrix legs do not overwrite one another's layers.

Source: [usememos/memos `.github/workflows/release.yml` lines 298-307](https://github.com/usememos/memos/blob/a9ac008a689cf45b977ae47d904ac5392f055851/.github/workflows/release.yml#L298-L307) at commit `a9ac008`.

### redpanda-data/redpanda - .github/workflows/type-check-python.yaml

```yaml
      - 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=max
```

Key lines: `- uses: docker/setup-buildx-action@v4`, `- uses: docker/build-push-action@v7`, `cache-from: type=gha`, `cache-to: type=gha,mode=max`.

A compact, complete pattern: set up buildx, then build a CI helper image while caching every layer through the gha backend.

Source: [redpanda-data/redpanda `.github/workflows/type-check-python.yaml` lines 31-38](https://github.com/redpanda-data/redpanda/blob/6f43b2a20730a845313233cb0a01b7c4249c979e/.github/workflows/type-check-python.yaml#L31-L38) at commit `6f43b2a`.

## Verify on your repo

Hand this prompt to your coding agent 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-action` runs before the build, and the build step has both `cache-from: type=gha` and `cache-to: type=gha,mode=max`.
- Search for `sleep`: `grep -rnE 'sleep [0-9]' .github/workflows/` (add your `docker-compose.yml` if you have one) - replace each with a healthcheck and `docker compose up --wait`.
- Search for floating tags: `grep -rnE ':latest' .github/workflows/ Dockerfile` (add your `docker-compose.yml` if you have one) and pin each image to a version.
- Read a warm run's log: BuildKit prints `CACHED` on restored layers. If nothing is cached, the builder or the cache-to line is missing.

## 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

- [Configure docker/build-push-action](https://starsling.dev/github-actions/docker-build-push-action)
- [Shard tests across parallel jobs](https://starsling.dev/best-practices/github-actions/shard-tests)
- [Cancel superseded workflow runs](https://starsling.dev/best-practices/github-actions/cancel-superseded-runs)

## Sources

1. [docker/build-push-action](https://github.com/docker/build-push-action)
2. [Docker docs - GitHub Actions cache backend (type=gha)](https://docs.docker.com/build/cache/backends/gha/)
3. [Docker docs - Cache management with GitHub Actions](https://docs.docker.com/build/ci/github-actions/cache/)
4. [docker/metadata-action](https://github.com/docker/metadata-action)
5. [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action)
6. [GitHub docs - Publishing Docker images to GHCR](https://docs.github.com/en/actions/publishing-packages/publishing-docker-images)
7. [GitHub docs - Security hardening: pin third-party actions to a full SHA](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions)
