Build and push Docker images with GitHub Actions: set up Buildx, log in to GHCR or ECR, use layer caching that works, tag sensibly and build multi-arch.
Building a container image is the most common non-trivial thing a pipeline does. The runner already has Docker installed, so a build is one command, and the parts worth getting right are caching, tagging and authentication.
Verified against docker/build-push-action@v6, docker/setup-buildx-action@v3 and docker/login-action@v3 as of September 2026.
GHCR is the path of least resistance from Actions, because authentication uses the token the workflow already has.
permissions: packages: write is not optional. Without it the push fails with a permission error even though login succeeded, because GITHUB_TOKEN defaults to read-only and login does not grant write.
secrets.GITHUB_TOKEN needs no configuration. It is injected automatically, scoped to this repository, and expires when the run ends. There is no reason to create a personal access token for GHCR pushes from the same repository.
name: Image
on:
push:
branches: [main]
jobs:
image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha,format=long
type=ref,event=branch
type=semver,pattern={{version}}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=maxA fresh runner has no image layers, so an uncached build rebuilds everything every time. type=gha uses the GitHub Actions cache backend, which is purpose-built for this:
mode=max caches intermediate layers as well as the final ones. The default, mode=min, caches only layers in the final image, which means a multi-stage build's expensive dependency-installation stage is not cached at all. For almost any multi-stage Dockerfile, mode=max is what you want.
This cache shares the repository's 10 GB allowance with every other cache, and image layers are large. A workflow caching several images across a matrix can evict its own entries, so watch the Actions caches page if you see erratic cache misses.
Do not use actions/cache with a /tmp/.buildx-cache directory, a pattern that circulates widely. It predates the gha backend, requires a manual cache-rotation dance to avoid unbounded growth, and is slower.
cache-from: type=gha
cache-to: type=gha,mode=maxdocker/metadata-action generates tags from the event so you do not hand-roll expressions. The three rules above give you:
type=sha,format=long tags every build with the full commit SHA. This is the tag deployments should reference, because it is immutable and traceable to exactly one commit.
type=ref,event=branch gives you a moving main tag, useful for "the current build" without pinning.
type=semver,pattern={{version}} produces 1.2.3 when the run was triggered by a v1.2.3 tag, and produces nothing otherwise.
What is deliberately absent is latest. A latest tag that moves on every merge means a deployment cannot be reproduced, and rolling back requires finding which image latest used to point at. If you want it, metadata-action adds it with type=raw,value=latest,enable={{is_default_branch}}, which at least confines it to the default branch.
If your images run on both x86 and ARM, for example on Graviton instances or Apple Silicon laptops:
QEMU emulation makes the non-native architecture build slowly, sometimes several times slower. For a heavy build the faster approach is one job per native runner architecture pushing by digest, then a final job combining them into a manifest list, at the cost of a more complex workflow.
Architecture mismatches are also a leading cause of a container that builds fine and then refuses to start. The exec format error page covers that specific failure.
- uses: docker/setup-qemu-action@v3
- uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}ECR needs cloud credentials, and the right way to get them is OIDC rather than stored keys:
The trust relationship that makes role-to-assume work without a stored secret is the subject of the deploying to AWS with OIDC lesson.
Unlike Docker Hub and GHCR, an ECR repository must exist before you push: ECR does not create one on first push, and the resulting error names the repository rather than explaining that.
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/github-actions-ecr
aws-region: eu-west-1
- uses: aws-actions/amazon-ecr-login@v2
id: ecr
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.ecr.outputs.registry }}/my-app:${{ github.sha }}A pull request should verify the image builds without publishing it:
This matters more than it looks, because a pull_request run from a fork has no write access and no secrets, so a workflow that always pushes will fail on every external contribution.
- uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=maxDo I need setup-buildx-action?
For cache-from and cache-to, yes. The default Docker builder does not support the cache exporters, so the cache options are silently ignored without Buildx. It is also required for platforms.
Why is my push denied when login succeeded?
For GHCR, almost always a missing permissions: packages: write. Login authenticates with a read-scoped token happily and the failure only appears at push time. For ECR, check that the IAM role has ecr:PutImage alongside the ecr:GetAuthorizationToken that login needs.
Should I build with docker build in a run step instead?
It works and you lose the cache exporters, multi-platform support and the metadata integration. A plain run: docker build is reasonable for a throwaway check and not for a publish pipeline.
How do I stop the registry filling up with commit-tagged images?
Configure a retention policy at the registry: an ECR lifecycle policy, or GHCR's package retention settings. Do not solve it by tagging less, because losing the immutable SHA tag costs you reproducible deployments.
Can I scan the image for vulnerabilities in the same workflow?
Yes, and the ordering matters: build with push: false and load: true so the image is available to the local daemon, scan it, then push in a later step conditional on the scan passing. Pushing first and scanning afterwards means the vulnerable image was publishable for the duration.
Deploy to AWS from GitHub Actions without stored keys: create the OIDC provider, write the IAM trust policy claim by claim, scope sub and fix common errors.
Learn what GitHub Actions is and how it runs your build: workflows, jobs, steps and runners, plus a first working pipeline you can commit today.
Understand the YAML behind a GitHub Actions workflow: events and filters, job dependencies with needs, if conditionals, contexts and expressions.