A practical guide to building, tagging, caching, and pushing Docker images from GitHub Actions using buildx, GHCR, and multi-arch builds.
Building a Docker image in CI is easy. Building it fast, tagging it sanely, and pushing it to the right registry with the right permissions is where teams trip. This walks through the standard stack the Docker org publishes and maintains, plus the choices that actually change your build times and your blast radius.
Almost every production pipeline uses the same four official actions, in this order:
--mount build features. Without it you get the classic Docker builder, which is slower and can't do most of what follows.buildx build and pushes.You can shell out to docker build yourself, but you lose the metadata wiring and the cache exporters that make these actions worth using.
For GHCR (GitHub Container Registry), you don't need a personal access token or any stored secret. The workflow's built-in GITHUB_TOKEN works, as long as you grant it package write access:
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
For Docker Hub, store a Hub access token (not your password) as a secret and log in against the default registry:
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
For Amazon ECR, skip login-action and use aws-actions/amazon-ecr-login (ideally with an OIDC role so there are no long-lived AWS keys in the repo).
Hand-maintained tag logic rots. Let metadata-action derive tags from context:
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}}
That produces a sha-<shortsha> tag on every build (immutable, great for rollbacks and deploy pinning), a branch tag, clean semver tags when you push a v1.4.2 Git tag, and latest only on your default branch. Deploy from the SHA tag, not latest, so you always know exactly what's running.
A cold Docker build reinstalls every dependency layer from scratch. On a Node or Python image that's often the bulk of your build time. GitHub Actions runners are ephemeral, so the local layer cache is gone on every run unless you export it somewhere.
Two common backends:
type=gha: stores layers in the GitHub Actions cache. Zero extra infrastructure, scoped per repo, subject to the 10 GB cache eviction limit. Best default for most teams.type=registry): pushes cache layers to a registry tag (for example ghcr.io/org/app:buildcache). No size cap beyond your registry, shared across runners and even across repos, but you manage the extra tag.Use mode=max to cache intermediate layers, not just the final stage. The difference on a warm cache is frequently the gap between a 6-minute build and a 45-second one.
name: build
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
permissions:
contents: read
packages: write
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha
type=semver,pattern={{version}}
type=raw,value=latest,enable={{is_default_branch}}
- uses: docker/login-action@v3
if: github.event_name != 'pull_request'
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- 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
Two things worth noticing. First, permissions is scoped to exactly what this job needs: read the repo, write packages. No write-all, no admin. If a dependency or a compromised action tries to do anything else, it can't. Second, PRs still build (so you catch broken Dockerfiles) but never log in and never push. Building a fork PR with push access would leak your registry credentials, so this pattern is a security property, not just a convenience.
If your image runs on both x86 servers and ARM (Graviton, Apple Silicon dev machines, Raspberry Pi), build a single multi-arch manifest. You need QEMU for cross-architecture emulation plus buildx to assemble the manifest list:
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
buildx builds both platforms and pushes one tag that resolves to the right architecture at pull time. Be aware that emulated builds are slow: compiling native extensions under QEMU can take several times longer than native. If ARM build time hurts, move to native ARM runners and use a matrix that builds each arch on its own runner, then merge with docker buildx imagetools create. For most images, QEMU is fine to start.
push must be conditional. Gate it on github.event_name != 'pull_request' (or on branch/tag refs) so PRs validate without publishing.@v6 are convenient; commit SHAs are safer for supply-chain hardening.For dependency-level caching inside the runner (npm, pip, Go modules) rather than Docker layers, see caching dependencies in GitHub Actions.
Start with the four official actions, GHCR via GITHUB_TOKEN, metadata-action for tags, and type=gha cache with mode=max. Scope permissions to packages: write and nothing more. Add multi-arch only when you actually ship to ARM, and reach for native runners or registry cache only when type=gha becomes the bottleneck. That covers the vast majority of services without over-engineering the pipeline. For more patterns like this, see our GitHub Actions recipes.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
A production-focused Argo CD and GitOps guide: declarative Applications and ApplicationSets, app-of-apps, sync waves and hooks, automated self-heal and prune, progressive delivery with Argo Rollouts, projects/RBAC, and secure secrets β with copy-paste examples.
Most GitHub Actions pain comes from the same handful of jobs done wrong. This is the map: the recipes that make pipelines fast, secure, and cheap.
Explore more articles in this category
Run only the CI jobs a change actually affects using if conditionals, trigger path filters, and per-job path detection in a monorepo.
A prioritized toolkit for cutting CI time: measure the critical path first, then cache, parallelize, run only what changed, and shrink the work itself.
Stop stashing long-lived AWS access keys in GitHub secrets and let OIDC hand your workflows short-lived, scoped credentials instead.
Evergreen posts worth revisiting.