Command Palette

Search for a command to run...

Hectal
PHASE 2Intermediate ~15 min· topic 5 of 5

Topic 2.5

Building & Pushing Container Images in GitHub Actions

In one line

Most pipelines end by producing a container image. In GitHub Actions, docker/setup-buildx-action, docker/login-action, docker/metadata-action, and docker/build-push-action build multi-platform images with BuildKit, reuse layer caches between runs (type=gha), tag them consistently (commit SHA, branch, version), add labels, provenance, and an SBOM, and push to GHCR or ECR. Build the image once and promote the same digest through environments.

0/5 · 0%

Think of it like this

A bakery's packaging line. Every loaf (build) goes into a labelled bag (tags and labels) with a batch number (commit SHA), a list of ingredients (SBOM), and a seal showing where it was made (provenance), then goes to the shop's storeroom (registry).

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

buildx
Docker's build command using BuildKit, supporting multi-platform builds and advanced caching.
GHCR
GitHub Container Registry, at ghcr.io.
Image digest
The sha256 identifier of an image's exact content, which never changes.
Multi-platform image
One image tag containing builds for several CPU architectures.
Provenance attestation
Signed metadata describing how, where, and from which source an image was built.
SBOM
Software Bill of Materials: a list of the components inside an image.

Step by step

01The image job

After tests pass on main, Tiffin builds the web image for both architectures, tags it from Git, pushes it to GHCR, and records the digest as a job output for deployment.

.github/workflows/ci.yml (image job)whole fileyaml
image:
  needs: [unit-tests, integration-tests]
  if: github.event_name != 'pull_request'
  runs-on: ubuntu-latest
  permissions: { contents: read, packages: write, id-token: write, attestations: write }
  outputs:
    digest: ${{ steps.build.outputs.digest }}
  steps:
    - uses: actions/checkout@v4
    - uses: docker/setup-qemu-action@v3
    - uses: docker/setup-buildx-action@v3
    - uses: docker/login-action@v3
      with:
        registry: ghcr.io
        username: ${{ github.actor }}
        password: ${{ secrets.GITHUB_TOKEN }}
    - id: meta
      uses: docker/metadata-action@v5
      with:
        images: ghcr.io/tiffin-team/web
        tags: |
          type=sha,format=short
          type=ref,event=branch
          type=semver,pattern={{version}}
    - id: build
      uses: docker/build-push-action@v6
      with:
        context: .
        platforms: linux/amd64,linux/arm64
        push: true
        tags: ${{ steps.meta.outputs.tags }}
        labels: ${{ steps.meta.outputs.labels }}
        cache-from: type=gha
        cache-to: type=gha,mode=max
        provenance: mode=max
        sbom: true
The image jobdiagram
Rendering diagram…

02What landed in the registry

The pushed image has one tag per Git reference, a manifest list for both architectures, and attestations attached.

terminal
$ docker buildx imagetools inspect ghcr.io/tiffin-team/web:sha-8f3a1c2 | head -9
── expected output ──
Name: ghcr.io/tiffin-team/web:sha-8f3a1c2
MediaType: application/vnd.oci.image.index.v1+json
Digest: sha256:4f1a9c2e7b3d5f6a8c1e2d3b4a5f6e7d8c9b0a1f2e3d4c5b6a7f8e9d0c1b2a3f
 
Manifests:
Name: ghcr.io/tiffin-team/web:sha-8f3a1c2@sha256:9c2e...
Platform: linux/amd64
Name: ghcr.io/tiffin-team/web:sha-8f3a1c2@sha256:1b7d...
Platform: linux/arm64

03The cache at work

On a code-only change, the dependency layers come from the GitHub Actions cache. The image job drops from 9 minutes (cold, two architectures) to under 3.

terminal
$ gh run view 1180150020 --log | grep -E 'CACHED|exporting to image' | head -4
── expected output ──
image #12 [linux/amd64 builder 3/6] COPY pom.xml .mvn mvnw ./
image #12 CACHED
image #14 [linux/amd64 builder 4/6] RUN --mount=type=cache,target=/root/.m2 ./mvnw -B dependency:go-offline
image #14 CACHED

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Permission denied pushing to GHCR

The workflow sets permissions: { contents: read } at the top, and the image job doesn't override it.

terminal
$ # image job log
── what you'll see ──
ERROR: failed to solve: failed to push ghcr.io/tiffin-team/web:sha-8f3a1c2: denied: installation not allowed to Write organization package

Myth vs fact

Myth

Building the image again for production is fine, it's the same Dockerfile.

Fact

A rebuild can pull different base images or dependencies and produce different content. Deploy the digest that was tested.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Building ARM images under QEMU emulation is slow. For large builds, use native ARM runners (hosted ubuntu-24.04-arm or self-hosted) per platform, push by digest, and merge them into one multi-platform manifest.

Remember this

  1. 1

    buildx and BuildKit: the modern Docker builder with parallel stages, cache mounts, secrets, and multi-platform builds (linux/amd64,linux/arm64) (Docker course, Topic 10.1).

  2. 2

    Login: to GHCR with GITHUB_TOKEN (needs packages: write), or to ECR with AWS credentials from OIDC (Topic 7.2), not stored keys.

  3. 3

    Tags: docker/metadata-action creates tags from Git: sha-8f3a1c2, branch names, PR numbers, and SemVer from tags (2.4.0, 2.4). Avoid relying on latest.

  4. 4

    Layer cache: cache-from: type=gha and cache-to: type=gha,mode=max store layers in the Actions cache, so unchanged layers (dependencies) aren't rebuilt.

  5. 5

    Provenance and SBOM: provenance: mode=max and sbom: true attach build attestations, describing how and from what the image was built (Topic 7.3).

  6. 6

    Build once, promote by digest: deploy the exact image digest (@sha256:...) built and tested in CI, not a rebuild per environment (Phase 5).

Explain it without notes

01

Why tag images with the commit SHA?

02

What does cache-to: type=gha,mode=max do?

Practice

01

Build and push an image to GHCR from a workflow with SHA and branch tags.

02

Make the image build reuse its cache and measure the difference.

Trade-offs

  • ↔

    Multi-platform builds serve ARM and x86 from one tag but take longer, especially under emulation. Attestations and SBOMs add build time and storage, in exchange for supply-chain transparency.

Done when you can

  • I build, tag, and push images with the Docker actions.

  • My image builds use layer caching.

  • I deploy images by digest and attach provenance and SBOMs.