Skip to content

Docker CI with GitHub Actions: Build, Scan, Publish

Lesson 6 of 8Intermediate13 min readGitHub Actions & CI/CD · Continuous IntegrationVerified: docker/build-push-action v7, docker/setup-buildx-action v4, docker/metadata-action v6, docker/login-action v4, hadolint-action v3, trivy-action v0.36.0, August 2026

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.

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@v4

Then cache to the Actions cache backend:

cache-from: type=gha
cache-to: type=gha,mode=max

type=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.

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
RuleProducesOn
type=ref,event=branchmainA push to a branch
type=ref,event=prpr-42A pull request
type=semver,pattern={{version}}1.4.2A push of tag v1.4.2
type=sha,format=longsha-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.

- uses: docker/setup-buildx-action@v4
- uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64

Building 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 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:scan

load: 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.

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 ci
COPY . .

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 ci

The 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
.github
node_modules
dist
*.env
.env*
**/*.md
Dockerfile*
.dockerignore
- name: Lint the Dockerfile
uses: hadolint/hadolint-action@v3
with:
dockerfile: Dockerfile
failure-threshold: warning

hadolint 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 app
USER 10001

Use 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 build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12 AS runtime
COPY --from=build /out/app /app
USER 10001
ENTRYPOINT ["/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.

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 1

docker 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.

type=gha is the default recommendation, and it is not the only option:

BackendStored inScoped byNotes
type=ghaThe Actions cacheBranch, per repositoryShares the repository’s cache quota
type=registryYour container registryWhatever the registry allowsNo quota pressure; needs push access
type=inlineThe image itselfFollows the imageOnly mode=min; simplest
type=localThe runner’s diskNothing, on a hosted runnerUseful 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=max

Note 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.

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: 2
updates:
- package-ecosystem: docker
directory: /
schedule:
interval: weekly

Dependabot 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.sarif

Two 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.

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"
fi

A 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.

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.

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-production and deployed-staging tag 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.

  1. Every event. Checkout, set up Buildx, derive tags and labels.

  2. Push events only. Log in to the registry.

  3. Every event. Build with push: ${{ github.event_name != 'pull_request' }} and GHA layer caching.

  4. 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.

  1. Copy examples/github-actions/docker-ci/build.yml into a repository with a Dockerfile and push a branch.

  2. Open a pull request. Confirm the build runs, the login step is skipped, and nothing appears in the registry.

  3. Merge. Confirm the push event publishes the image and that the digest appears in the run summary.

  4. 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.

  5. Add a v1.0.0 tag and push it. Confirm metadata-action produced a 1.0.0 tag alongside the SHA tag.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

Skip the boilerplate — the Professional Toolkit has five production-ready workflow templates.