GitHub Actions #6 Docker Build and GHCR Push: Tag Strategy and Build Cache
This part kicks off three hands-on pipeline builds. The first pipeline produces CI’s flagship artifact: a container image. We build the flow where code merged to main gets built into an image and pushed to GHCR (GitHub Container Registry), integrated with GitHub. Images live next to the code that builds them, and as we will see, authentication comes for free. Writing the Dockerfile itself is assumed knowledge from the Docker basics series. Here we focus on the GitHub Actions side of the pipeline.
The full workflow first #
Let’s look at the finished form first, then walk through the pieces.
# .github/workflows/image.yml
name: build image
on:
push:
branches: [main]
tags: ["v*"]
permissions:
contents: read
packages: write # needed for GHCR push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- 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 }}
- 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=maxEverything we learned in #2 and #3 goes into live service here: step outputs (steps.meta.outputs), GITHUB_TOKEN, and the permissions block making its first appearance.
Login: no extra secret required #
GHCR’s practical advantage shows up right in the login step. With Docker Hub you would create an account token and register it in secrets, but GHCR logs in with the auto-issued GITHUB_TOKEN we saw in #3. Not a single new secret to manage. In exchange, the workflow must declare permissions: packages: write to grant the token package write access — and this declaration style is itself a working example of the least-privilege principle we cover in #9, because it states only the permissions you need, only in the workflows that need them.
Tag strategy: delegate to metadata-action #
Deciding image tags by hand is an area that collapses quickly. docker/metadata-action decides tags automatically based on the event that woke the run. Its default behavior alone produces the following.
| Event | Tags produced |
|---|---|
| Push to main | main (+ optional commit SHA tag) |
| Push of tag v1.4.0 | 1.4.0, 1.4, latest |
| PR | pr-123 |
In other words, a single workflow automatically enforces the policy “latest development gets the main tag, releases get version tags.” If you want different rules, you declare them through metadata-action’s tags input, so the tag policy lives as code in YAML. The operational habit that matters: deploy with a pinned tag (version or SHA), not latest. latest can be a different image today than it was yesterday, leaving you unable to answer which code is actually running.
Build cache: a pair of gha-type lines #
Docker builds are only fast with a layer cache, but runners are clean machines every time, so out of the box there is no cache (the problem from #4 reproduced in Docker). Two lines on build-push-action are the answer.
cache-from: type=gha
cache-to: type=gha,mode=maxThis pushes the build layer cache into GitHub Actions’ cache storage and pulls it back out on the next run. mode=max stores all the intermediate layers as well, raising the hit rate. The more cache-friendly your Dockerfile (separating the dependency-install layer from the code-copy layer), the bigger the effect — that writing style is the subject of the Docker intermediate series, so here we just make the connection. If you need multi-architecture builds (amd64 and arm64 together), the extension path — adding docker/setup-qemu-action and the platforms input — sits on the same action.
Where the image goes #
The pushed image appears in the repository’s Packages tab, at the address ghcr.io/<owner>/<repo>. Deploying this image to real infrastructure is the next part’s topic — and the earlier stage that Terraform in Practice #8 deferred with “assume the image is ready” gets filled in as of today.
Recap #
What we covered in this post:
- GHCR login takes only GITHUB_TOKEN, paired with a packages write declaration in permissions
- Tags are not chosen by hand but delegated to metadata-action; the per-event tag policy lives as YAML code
- Deploy with pinned version or SHA tags, not latest
- The build cache is the cache-from/to gha-type pair, and its effect scales with the Dockerfile’s cache friendliness
- The artifact lands at ghcr.io/owner/repo, and deploying it to infrastructure is the next part
In the next post (#7 AWS Deploys and OIDC), we complete the OIDC integration that lets a workflow obtain AWS permissions without access keys, and actually deploy what we built.