# Build and Push Docker Images in GitHub Actions

URL: https://www.devopsness.com/blog/github-actions-docker-build-push
Author: Kiril Urbonas
Published: 2026-07-23
Updated: 2026-10-12
Categories: DevOps
Tags: Docker, CI/CD, GitHub Actions, Containers, DevOps

Summary: A practical guide to building, tagging, caching, and pushing Docker images from GitHub Actions using buildx, GHCR, and multi-arch builds.

---
To build and push a Docker image in GitHub Actions, run `docker/setup-buildx-action`, log in with `docker/login-action`, generate tags with `docker/metadata-action`, then build and push with `docker/build-push-action@v7`. For GHCR, the built-in `GITHUB_TOKEN` plus `permissions: packages: write` is all the auth you need.

Building an image in CI is easy. Building it *fast*, tagging it sanely, and pushing it with the right permissions is where teams trip. Below is the standard stack the Docker org maintains, plus the choices that change your build times and your blast radius.

## The standard stack

Almost every production pipeline uses the same four official actions, in this order (current majors as of October 2026):

- **docker/setup-buildx-action@v4:** boots a BuildKit builder. This is what unlocks caching, multi-arch, and the modern `--mount` build features. Without it you get the classic Docker builder, which is slower and can't do most of what follows.
- **docker/login-action@v4:** authenticates to your registry so the push succeeds.
- **docker/metadata-action@v6:** generates tags and OCI labels from Git context (branch, tag, SHA) so you don't hand-write tag logic.
- **docker/build-push-action@v7:** runs the actual `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.

## Authenticating to a registry

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:

```yaml
- uses: docker/login-action@v4
  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:

```yaml
- uses: docker/login-action@v4
  with:
    username: ${{ secrets.DOCKERHUB_USERNAME }}
    password: ${{ secrets.DOCKERHUB_TOKEN }}
```

For **Amazon ECR**, skip login-action and use `aws-actions/amazon-ecr-login@v2` after `aws-actions/configure-aws-credentials` assumes an OIDC role, so there are no long-lived AWS keys in the repo. The job then needs `id-token: write`.

## Tagging strategy

Hand-maintained tag logic rots. Let metadata-action derive tags from context:

```yaml
- id: meta
  uses: docker/metadata-action@v6
  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.

## Layer caching, and why it matters

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, 10 GB per repository by default (admins can raise it, and overage is billed). Entries unused for 7 days are evicted. Best default for most teams. If one repo builds several images, give each a distinct `scope=` so they don't overwrite each other's cache.
- **Registry cache (`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.

## A full build-push job with gha cache

```yaml
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@v7

      - uses: docker/setup-buildx-action@v4

      - id: meta
        uses: docker/metadata-action@v6
        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@v4
        if: github.event_name != 'pull_request'
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/build-push-action@v7
        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.

## Multi-arch builds

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:

```yaml
      - uses: docker/setup-qemu-action@v4

      - uses: docker/setup-buildx-action@v4

      - uses: docker/build-push-action@v7
        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.

## Gotchas worth knowing

- **`push` must be conditional.** Gate it on `github.event_name != 'pull_request'` (or on branch/tag refs) so PRs validate without publishing.
- **First cached build is a wash.** The cache pays off from the second run on. Don't judge the setup by the cold build.
- **GHCR visibility.** A freshly pushed package is private. Link it to the repo and set visibility once in the package settings.
- **Pin action versions.** Major tags like `@v7` 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](/blog/github-actions-cache-dependencies).

## Questions people ask

### Do I need a personal access token to push to GHCR from GitHub Actions?

No. The workflow's `GITHUB_TOKEN` can push to `ghcr.io` as long as the job declares `permissions: packages: write`. Log in with `docker/login-action`, using `github.actor` as the username and `secrets.GITHUB_TOKEN` as the password. If a push to an existing package is denied, grant the repository write access in the package's settings rather than reaching for a PAT.

### How do I push to Amazon ECR instead of GHCR?

Swap the login step. Use `aws-actions/configure-aws-credentials` with `role-to-assume` and `id-token: write` permission, then `aws-actions/amazon-ecr-login@v2`, and point metadata-action at your ECR repository URI. `docker/build-push-action` itself does not change.

### Why is my build-push-action build not using the cache?

The first run always builds cold. After that, check that you set both `cache-from` and `cache-to`, that `cache-to` uses `mode=max`, and that multiple images in one repo use separate `scope` values. Caches are also scoped by branch: a feature branch can read the default branch's cache but not another feature branch's.

## The call we'd make

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](/blog/github-actions-recipes).

## Sources

- [docker/build-push-action on GitHub](https://github.com/docker/build-push-action)
- [Docker Docs: GitHub Actions cache backend (type=gha)](https://docs.docker.com/build/cache/backends/gha/)
- [GitHub Docs: Publishing a package with GitHub Actions](https://docs.github.com/en/packages/managing-github-packages-using-github-actions-workflows/publishing-and-installing-a-package-with-github-actions)
- [GitHub Docs: Dependency caching limits and eviction](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching)

