Building images by hand works until it doesn't: someone builds from an uncommitted branch, someone forgets to push, someone's laptop produces an arm64 image nobody notices until deploy. CI removes the human from the loop.
What follows is the workflow from the demo application, in pieces.
Tests first, in their own job
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: temurin
cache: maven
- name: Run the test suite
working-directory: pizza-springboot-backend
run: ./mvnw -B testThe image's Dockerfile builds with -DskipTests (lesson 4), and this is the other half
of that decision. Tests in a separate job report a failure against the commit with the report
attached, and no image is built at all. Tests inside the Dockerfile make every image build pay for
them and turn a flaky test into a failed deploy with no useful output.
cache: maven caches ~/.m2 keyed on the pom files. A runner is a fresh
machine every time, so without it every run re-downloads the whole dependency tree — the CI
equivalent of getting the COPY order wrong in a Dockerfile.
Permissions, and not storing a password
permissions:
contents: read
packages: writeThis is the part worth copying even if you use nothing else here. GitHub mints a
GITHUB_TOKEN for each run, scoped to this repository, expiring when the job ends. With
packages: write it can push to GHCR.
So there is no long-lived registry credential in the repository secrets — nothing to leak, nothing to rotate, nothing that keeps working after someone leaves. For AWS or GCP the equivalent is OIDC federation: the runner presents its identity token and the cloud hands back short-lived credentials, with no access key stored anywhere.
One job per image
strategy:
fail-fast: false
matrix:
include:
- name: pizza-api
context: ./pizza-springboot-backend
- name: pizza-web
context: ./pizza-react-frontend
- name: pizza-web-angular
context: ./pizza-angular-frontendThree parallel jobs from one definition. fail-fast: false matters — the default
cancels the siblings the moment one fails, so a broken frontend build hides whether the API would
have built.
The build steps
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- name: Log in to the registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}QEMU for cross-architecture emulation, buildx because plain docker build cannot do
multi-platform or inline cache (lesson 19).
The if: on the login is a small thing that prevents a real annoyance: a pull request
from a fork gets a read-only token, so this step would fail. Skipping it lets the build still run —
it just does not push, which is exactly what you want from an untrusted PR.
Tags from the git ref
- name: Work out the tags
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository }}/${{ matrix.name }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=sha-
type=raw,value=latest,enable={{is_default_branch}}Lesson 18's tagging scheme, derived rather than typed. A push to main produces
:main, :sha-a3f9c21 and :latest; a v1.4.2 tag
also produces :1.4.2 and :1.4.
Deploy the sha- tag. :latest is produced here for humans typing
docker run, not for a deployment to reference.
The cache line that decides your CI time
- name: Build and push
uses: docker/build-push-action@v6
with:
context: ${{ matrix.context }}
platforms: linux/amd64,linux/arm64
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=maxEvery runner is a fresh machine with an empty layer cache. So all the careful
COPY ordering from lesson 6 buys nothing in CI by default — every run reinstalls every
dependency, in the one place it hurts most.
type=gha stores the layer cache in GitHub's own Actions cache and restores it next
run.
mode=max is the important half. The default (min)
caches only layers present in the final image — and in a multi-stage build the expensive layer
(npm ci, mvn package) is in the build stage, which never appears
in the result. Without mode=max you cache the cheap layers and rebuild the expensive
ones.
Two caveats: GitHub's Actions cache has a 10 GB per-repository limit and evicts least-recently-used entries, so several large images can push each other out. And cache is scoped per branch, with fallback to the default branch — so the first build on a new branch is slow, and that is normal.
What this workflow does not do
It builds and pushes. It does not deploy — that is a separate concern, usually a separate workflow
triggered by this one, and it should reference the sha- tag this run produced.
⚠️ And one honest note about the file itself: it has never run. The demo project
has no GitHub remote yet, so this is written to be correct rather than proven. Every underlying
command was verified locally with the equivalent docker buildx invocation; the
workflow wiring has not been. Its own header says so, which is the right place for that
information to live.