Container CI has one question that outranks every technical detail: when is it allowed to push? Get that wrong and the pipeline becomes a mechanism for turning an unreviewed pull request into a published artifact that something, somewhere, will pull.
The complete workflow is at examples/github-actions/docker-ci/build.yml, validated by
npm run check:workflows.
The push condition is the security boundary
Section titled “The push condition is the security boundary”A pull request build must build and must not push. The reason is not caution for its own
sake: the contents of the pull request decide what gets built. A contributor who can change the
Dockerfile can make the image do anything, and if the pipeline publishes it, they have published to
your registry without review.
- name: Build and conditionally push id: build uses: docker/build-push-action@v7 with: context: . push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }}What it doesBuilds the image on every trigger, but only pushes it when the event is not a pull request.
Why we run itOn a pull request the image contents are proposed, not accepted. Publishing it would let an unreviewed change become a real artifact under your organisation's name.
Expected resultOn a pull request: a build with no push and no registry credentials in the job at all. On a push to main: build and push.
Pair it with a login step under the same condition, so a pull request run never even holds registry credentials:
- name: Log in to the registry if: github.event_name != 'pull_request' uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }}Note also that on a pull request from a fork, secrets.GITHUB_TOKEN is read-only and organisation
secrets are not passed at all. So even a workflow that tries to push from a fork run fails — the
platform is enforcing the same boundary. The condition above makes the intent explicit rather than
relying on a failure.
Buildx and the build cache
Section titled “Buildx and the build cache”docker/setup-buildx-action swaps the runner’s default builder for BuildKit, which is what makes
layer caching and multi-platform builds possible:
- uses: docker/setup-buildx-action@v4Then cache to the Actions cache backend:
cache-from: type=ghacache-to: type=gha,mode=maxtype=gha uses the same storage as actions/cache, so it shares the repository’s cache quota and
the same scope rules: a cache written on a branch is readable from that branch and from the default
branch, but not from a sibling branch. That is a deliberate isolation boundary — a fork must not be
able to poison the cache that a main build restores.
mode=max exports every intermediate layer rather than only the final ones. It writes more, but a
build that changes a middle layer still reuses everything before it. With the default mode=min, a
change near the top of the Dockerfile invalidates far more than it needs to.
Deriving tags instead of writing them
Section titled “Deriving tags instead of writing them”Hand-written tags drift. docker/metadata-action derives them from the event:
- name: Derive tags and labels id: meta uses: docker/metadata-action@v6 with: images: ghcr.io/${{ github.repository }} tags: | type=ref,event=branch type=ref,event=pr type=semver,pattern={{version}} type=sha,format=long| Rule | Produces | On |
|---|---|---|
type=ref,event=branch | main | A push to a branch |
type=ref,event=pr | pr-42 | A pull request |
type=semver,pattern={{version}} | 1.4.2 | A push of tag v1.4.2 |
type=sha,format=long | sha-a1b2c3… | Every event |
The action also emits OCI labels — source repository, revision, creation time — which is how a registry UI and later a vulnerability scanner can trace an image back to the commit that produced it.
The SHA tag is the one that matters operationally. Branch tags are mutable: main means something
different every merge. A deployment that references sha-a1b2c3… refers to exactly one image
forever, which is what makes a rollback a matter of changing one string.
Multi-platform builds
Section titled “Multi-platform builds”- uses: docker/setup-buildx-action@v4- uses: docker/build-push-action@v7 with: context: . platforms: linux/amd64,linux/arm64Building a foreign architecture on an x86 runner requires binfmt emulation, which the Docker organisation publishes a setup action for. It works, and it is slow — an emulated compile can take several times longer than a native one, and for a large application that turns a four-minute build into half an hour.
The faster answer is to stop emulating. GitHub now offers native
ARM runners, so a matrix that builds linux/amd64 on an x86
runner and linux/arm64 on an ARM runner, then merges the two into one manifest list, produces the
same multi-platform image at native speed. It is a more complex workflow in exchange for a build that
finishes.
Scanning, and where it belongs
Section titled “Scanning, and where it belongs”Scanning the built image for known vulnerabilities belongs on the pull request, because that is where the finding is still cheap to act on:
- name: Build for scanning uses: docker/build-push-action@v7 with: context: . push: false load: true tags: local/app:scanload: true makes the image available to the runner’s Docker daemon so a scanner can read it. It is
incompatible with multi-platform builds — the daemon can only load a single-platform image — so a
scan job typically builds one platform.
What to do with findings is a policy question. Failing the build on any HIGH severity finding is common and produces a lot of noise from base-image CVEs with no available fix; failing only on findings that have a fixed version available is usually the more sustainable rule.
Dockerfile choices that CI makes visible
Section titled “Dockerfile choices that CI makes visible”CI exposes two Dockerfile problems quickly:
Layer ordering. Copying the whole source tree before installing dependencies means every source change invalidates the dependency layer. Copy the manifest first, install, then copy the source:
COPY package.json package-lock.json ./RUN npm ciCOPY . .Build secrets. A secret passed as --build-arg is recorded in the image history and readable by
anyone who pulls it. BuildKit’s secret mount exists for this:
- uses: docker/build-push-action@v7 with: secrets: | npm_token=${{ secrets.NPM_TOKEN }}RUN --mount=type=secret,id=npm_token \ npm config set //registry.npmjs.org/:_authToken="$(cat /run/secrets/npm_token)" && npm ciThe secret is mounted into the filesystem for that one RUN and is never committed to a layer.
.dockerignore decides what your cache can do
Section titled “.dockerignore decides what your cache can do”The build context is everything sent to the builder before the first instruction runs. Without a
.dockerignore, that includes .git, node_modules, build output, local .env files and any
credential a previous step wrote into the workspace.
Three separate consequences, and only one of them is about speed:
Cache invalidation. COPY . . copies the whole context, so any file changing invalidates that
layer and everything after it. With .git in the context, every commit changes the context — so the
layer cache is invalidated on every single build, and the caching configured earlier on this page does
nothing.
Build time. A large context is transferred to the builder before anything starts.
Disclosure. Anything in the context can be COPY-ed into the image, deliberately or by a
COPY . . that was not thought through. A .env or .aws/credentials file in the workspace becomes
part of a published image.
.git.githubnode_modulesdist*.env.env***/*.mdDockerfile*.dockerignoreLinting the Dockerfile
Section titled “Linting the Dockerfile”- name: Lint the Dockerfile uses: hadolint/hadolint-action@v3 with: dockerfile: Dockerfile failure-threshold: warninghadolint catches the recurring problems: an unpinned base image tag, apt-get install without
--no-install-recommends or a cleaned package list, ADD where COPY is meant, a missing USER
instruction leaving the container running as root.
The USER finding is the one worth acting on first. A container running as root that is compromised
has root inside the container, and combined with a permissive runtime that is a much shorter path to
the host than it should be.
RUN useradd --system --uid 10001 appUSER 10001Use a numeric UID rather than a name. Kubernetes’ runAsNonRoot check inspects the UID, and it cannot
resolve a username — a container declaring USER app fails to start under that policy with an error
that does not mention the cause.
Multi-stage builds and what CI should build
Section titled “Multi-stage builds and what CI should build”FROM golang:1.25 AS buildWORKDIR /srcCOPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 go build -trimpath -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12 AS runtimeCOPY --from=build /out/app /appUSER 10001ENTRYPOINT ["/app"]The dependency download is its own layer, before the source is copied, so a source change reuses it. That ordering is the single most effective Dockerfile change for build time.
CI can target a specific stage, which is useful for running tests inside the build environment without shipping the toolchain:
- name: Run tests inside the build stage uses: docker/build-push-action@v7 with: context: . target: build push: false load: true tags: local/app:test
- run: docker run --rm local/app:test go test ./...The final image contains only the binary. The compiler, the module cache and the source are in a stage that is never published — which is both a smaller image and a much smaller attack surface.
Testing the image, not just building it
Section titled “Testing the image, not just building it”A build that succeeds proves the Dockerfile is valid. It proves nothing about whether the container starts, listens, or serves anything.
- name: Build for testing uses: docker/build-push-action@v7 with: context: . push: false load: true tags: local/app:test
- name: Smoke test the container run: | docker run -d --name app -p 8080:8080 local/app:test for _ in $(seq 1 30); do if curl -fsS http://localhost:8080/healthz; then docker rm -f app exit 0 fi sleep 2 done echo "::error::container did not become healthy" docker logs app docker rm -f app exit 1docker logs app on failure is what makes this diagnosable — without it you learn only that the
container did not respond, which is where most people then start guessing.
load: true makes the image available to the runner’s Docker daemon. It cannot be combined with a
multi-platform build, so a test job builds one platform.
A few cheap assertions worth adding once the container runs:
- name: Assert the image is sane run: | user="$(docker inspect --format '{{.Config.User}}' local/app:test)" [ -n "$user" ] && [ "$user" != "root" ] && [ "$user" != "0" ] \ || { echo "::error::image runs as root"; exit 1; }That check would have caught a USER instruction accidentally removed in a refactor, which is exactly
the kind of regression nobody notices until a security review.
Choosing a cache backend
Section titled “Choosing a cache backend”type=gha is the default recommendation, and it is not the only option:
| Backend | Stored in | Scoped by | Notes |
|---|---|---|---|
type=gha | The Actions cache | Branch, per repository | Shares the repository’s cache quota |
type=registry | Your container registry | Whatever the registry allows | No quota pressure; needs push access |
type=inline | The image itself | Follows the image | Only mode=min; simplest |
type=local | The runner’s disk | Nothing, on a hosted runner | Useful only on self-hosted |
type=registry is worth knowing about specifically because of the quota interaction described in
caching: a mode=max layer cache for a large image can consume
most of the repository’s Actions cache and evict the dependency caches that were doing the real work.
Moving image layers to a registry cache leaves the Actions cache for everything else.
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=maxNote that this needs registry write access — so on a pull request build, which deliberately has no
registry credentials, cache-to must be conditional or the step fails.
Keeping base images current
Section titled “Keeping base images current”A pinned base image is reproducible and, three months later, missing every security update published since. Both facts are true, and the resolution is not to unpin it:
{/* .github/dependabot.yml */}version: 2updates: - package-ecosystem: docker directory: / schedule: interval: weeklyDependabot raises a pull request updating the pinned tag or digest, which runs the full pipeline — including the tests and the smoke test above — before anyone merges it. That is the difference between an update you verified and one that happened.
Pair it with a scheduled rebuild, because a base image can publish a patch under the same tag:
on: schedule: - cron: '0 4 * * 1' workflow_dispatch:A weekly rebuild of the current release picks up base-image patches without a code change. Scheduled workflows run in UTC on a best-effort basis and are disabled automatically after 60 days of repository inactivity, so do not build anything time-critical on the cron alone.
Scanning, and a failure policy people can live with
Section titled “Scanning, and a failure policy people can live with”Scanning the built image belongs on the pull request, where a finding is still cheap to act on. The hard part is not running the scanner — it is deciding what fails the build.
- name: Scan the image uses: aquasecurity/trivy-action@v0.36.0 with: image-ref: local/app:scan format: sarif output: trivy.sarif severity: HIGH,CRITICAL ignore-unfixed: true
- name: Upload results to code scanning if: always() uses: github/codeql-action/upload-sarif@v4 with: sarif_file: trivy.sarifTwo choices in there do most of the work.
ignore-unfixed: true suppresses findings with no available fixed version. Without it, a typical
image reports dozens of base-distribution CVEs that nobody can remediate, the build is permanently
red, and a red build that is normal is not a signal. With it, every remaining finding has an action:
update the package.
SARIF upload rather than a hard failure. Findings land in the repository’s code scanning view, with history, dismissal and tracking, instead of being a wall of text in a log that disappears. It also means the pull request is annotated rather than blocked, which is usually the right default for a finding in a transitive base-image package.
Uploading SARIF needs security-events: write, and the feature’s availability depends on the
repository — public repositories have it, private ones may require GitHub Advanced Security. Check
Settings → Code security rather than assuming; see
least-privilege permissions for the permission
itself.
Note that the version above is pinned to an exact release rather than a major. Third-party scanners change their default severity handling and output format between releases more often than most actions, and an unpinned scanner is a build that can fail overnight for reasons unrelated to your code — the same argument made for linters in Go CI.
Watching image size
Section titled “Watching image size”Image size is a cost that accumulates invisibly: slower pulls on every deploy, slower autoscaling, more registry storage.
- name: Report image size run: | bytes="$(docker image inspect local/app:test --format '{{.Size}}')" mb=$(( bytes / 1024 / 1024 )) echo "Image size: ${mb} MB" >> "$GITHUB_STEP_SUMMARY" if [ "$mb" -gt 250 ]; then echo "::warning::image is ${mb} MB, above the 250 MB budget" fiA warning rather than a failure is deliberate. A legitimate change can push an image over a budget, and a hard gate on a number somebody picked a year ago mostly teaches people to raise the number. The warning appears on the pull request, someone asks about it, and that conversation is the control.
When the number does jump, the usual causes are a build artifact copied into the runtime stage, a
package manager cache not cleaned in the same RUN layer, or a base image switched from a slim variant
to a full one.
Reproducible image builds
Section titled “Reproducible image builds”Two builds of the same commit should produce the same digest. By default they do not, because file timestamps and image creation time vary:
- uses: docker/build-push-action@v7 with: context: . push: ${{ github.event_name != 'pull_request' }} provenance: true sbom: true build-args: | SOURCE_DATE_EPOCH=${{ github.event.repository.updated_at }}BuildKit honours SOURCE_DATE_EPOCH by normalising timestamps in the layers it creates. Full
reproducibility additionally requires the build itself to be deterministic — which is why the
language pages in this cluster care about -trimpath, ContinuousIntegrationBuild and
project.build.outputTimestamp.
provenance: true and sbom: true attach attestations to the image at build time. That is the
foundation the deployment side builds on: a digest that can be verified rather than merely trusted.
See software supply-chain security.
Cleaning up what CI publishes
Section titled “Cleaning up what CI publishes”A pipeline pushing an image per commit fills a registry quickly, and the naive cleanup policy — “delete untagged versions” — deletes exactly the images a digest-based deployment is running.
A workable policy keeps:
- Anything tagged with a release version, indefinitely or on a long horizon.
- A
deployed-productionanddeployed-stagingtag pointing at what is currently live, so those digests are never untagged. - The last N commit-tagged images from the default branch, which bounds how far a rollback can reach.
And deletes pull request images once the pull request closes, which is usually the bulk of the volume.
The complete pipeline
Section titled “The complete pipeline”-
Every event. Checkout, set up Buildx, derive tags and labels.
-
Push events only. Log in to the registry.
-
Every event. Build with
push: ${{ github.event_name != 'pull_request' }}and GHA layer caching. -
Push events only. Report the digest into the job summary so the deploy job — and a human reading the run — can see exactly what was published.
The job declares packages: write at the workflow level. That permission is required to push to
ghcr.io and is harmless on a pull request run, where the token is read-only regardless. Being
explicit is still better than relying on the default: see
least-privilege permissions.
Exercise
Section titled “Exercise”-
Copy
examples/github-actions/docker-ci/build.ymlinto a repository with aDockerfileand push a branch. -
Open a pull request. Confirm the build runs, the login step is skipped, and nothing appears in the registry.
-
Merge. Confirm the push event publishes the image and that the digest appears in the run summary.
-
Push a second commit that changes only the application source, not the dependency manifest. Compare build times and confirm the dependency layer was reused from cache.
-
Add a
v1.0.0tag and push it. Confirmmetadata-actionproduced a1.0.0tag alongside the SHA tag.
Then what?
Section titled “Then what?”Skip the boilerplate — the Professional Toolkit has five production-ready workflow templates.