Build and Push Docker Images with GitHub Actions
Build a container image in CI and push it to GHCR or ECR. Buildx setup, registry login, layer caching that actually works, and sensible tagging.
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.
Pushing to GitHub Container Registry
GHCR is the path of least resistance from Actions, because authentication uses the token the workflow already has.
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=max
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.
Layer Caching
A 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:
cache-from: type=gha
cache-to: type=gha,mode=max
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.
Tagging
docker/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.
Multi-Architecture Builds
If your images run on both x86 and ARM, for example on Graviton instances or Apple Silicon laptops:
- 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 }}
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.
Pushing to Amazon ECR
ECR needs cloud credentials, and the right way to get them is OIDC rather than stored keys:
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 }}
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.
Build Only on Pull Requests
A pull request should verify the image builds without publishing it:
- 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=max
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.
Frequently Asked Questions
Do 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.