Build and Push Docker Images with GitHub Actions

By Vishvesh Patel · DevOps EngineerUpdated September 28, 2026

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.

Continue Learning

Explore Related Topics

Try the Tool

When it breaks

Related Resources