Docker Build Security Best Practices
Docker build security is not only about the Dockerfile. It spans the image contents, the CI job that assembles the image, and the registry that stores the result. A pipeline that runs tests but pulls an unpinned base image, bakes a credential into a layer, or pushes a mutable tag that anyone can overwrite has still shipped a supply chain problem, even though every test passed. This lesson hardens each stage of a Docker build inside GitHub Actions: what goes into the image, how BuildKit handles secrets, how to scope workflow permissions and pin actions, and how to verify an image before anything deploys from it.
Overview / How it works
A container image is a chain of trust. Your workflow trusts the actions it calls, the base image trusts its publisher, and every layer inherits whatever was true of the layer before it, including files that were only supposed to exist transiently. Four points in that chain need explicit hardening.
First, image contents: prefer minimal, actively maintained base images, pin them by digest instead of a mutable tag, and run the final process as a non-root user. Second, the build mechanism: Docker BuildKit supports secret mounts that never persist in the image history or any intermediate layer, which is the correct way to hand a build step a private registry token or an npm credential. Third, the pipeline itself: the workflow’s token needs the minimum permissions required to push an image and nothing more, and every action you call should be pinned to a version or commit SHA you trust, because a compromised action has the same access as your workflow does. Fourth, verification: scan the built image for known vulnerabilities and sign it before anything downstream references it, and have deploy jobs reference the image by digest rather than by tag, since a tag can be repointed to a different image after the fact.
Syntax or workflow structure
A hardened build job generally has this shape: checkout, set up Buildx, authenticate to the registry with a short-lived token, build and push with any required secrets passed through a secret mount, scan the resulting digest, sign it, and expose the digest as a job output that later deploy jobs consume. Scope permissions: per job rather than relying on the repository default — typically contents: read and packages: write to push to GitHub Container Registry, plus id-token: write only if you use keyless signing with cosign and Sigstore, since that flow exchanges a short-lived OpenID Connect token for a signing certificate instead of a stored private key.
Examples
Example 1: a Dockerfile with two real defects. It pulls the mutable node:latest tag, so the base image can silently change between builds, and it passes a registry token through a build argument that is then exported as an environment variable. That token becomes readable inside the built image’s layer history, which anyone who can pull the image can inspect with docker history.
FROM node:latest
ARG NPM_TOKEN
ENV NPM_TOKEN=$NPM_TOKEN
WORKDIR /app
COPY . .
RUN npm config set //registry.npmjs.org/:_authToken=$NPM_TOKEN \
&& npm install
CMD ["npm", "start"]
Expected behavior: the build succeeds and looks correct, but the image contains a working copy of NPM_TOKEN in a cached layer, and it runs as root at container start, so a compromised dependency during npm install has root inside the container.
Example 2: the corrected Dockerfile. The base image is pinned by digest, the token is consumed through a BuildKit secret mount that only exists for the duration of the single RUN instruction, and the final stage copies only build output into a fresh image that runs as an unprivileged user.
FROM node:20.11-bookworm-slim@sha256:REPLACE_WITH_VERIFIED_DIGEST AS build
WORKDIR /app
COPY package*.json ./
RUN --mount=type=secret,id=npm_token \
npm config set //registry.npmjs.org/:_authToken="$(cat /run/secrets/npm_token)" \
&& npm ci --omit=dev
COPY . .
RUN npm run build
FROM node:20.11-bookworm-slim@sha256:REPLACE_WITH_VERIFIED_DIGEST
RUN useradd --system --uid 10001 appuser
WORKDIR /app
COPY --from=build --chown=appuser:appuser /app/dist ./dist
COPY --from=build --chown=appuser:appuser /app/node_modules ./node_modules
USER appuser
HEALTHCHECK --interval=30s --timeout=3s CMD node dist/healthcheck.js || exit 1
CMD ["node", "dist/server.js"]
Expected behavior: docker history on the published image no longer shows the token anywhere, the runtime layer is smaller because dev dependencies and build tools stay in the discarded build stage, and the container process runs as appuser instead of root. The digest placeholder must be replaced with a value you have independently verified against the vendor’s published digest before you rely on it.
Example 3: a workflow that builds, scans, and signs. Actions are pinned to commit SHAs with the version noted in a comment, the job requests only the permissions it uses, the npm token is passed as a build secret rather than a build argument, and the image is scanned before it is signed. Both the scan and the sign step operate on the immutable digest that the build step produced, not on the tag.
name: Docker Build and Publish
on:
push:
branches: [main]
permissions:
contents: read
packages: write
id-token: write # required for keyless cosign signing
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfa1b1 # v3.7.1
- name: Log in to GHCR
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: build
uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0
with:
context: .
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
secrets: |
npm_token=${{ secrets.NPM_TOKEN }}
- name: Scan image for vulnerabilities
uses: aquasecurity/trivy-action@0.24.0
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
severity: CRITICAL,HIGH
exit-code: '1'
- name: Install cosign
uses: sigstore/cosign-installer@4959ce089c160fddf62f7b42464195ba1a56d382 # v3.6.0
- name: Sign image by digest
run: cosign sign --yes ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
Expected behavior: if Trivy finds a critical or high severity vulnerability with a known fix, the job fails before the signing step runs, so no signature is ever attached to an image with an unresolved critical finding. Downstream deploy jobs should reference steps.build.outputs.digest, never the mutable github.sha tag, so a later force-push or tag reuse cannot silently change what gets deployed.
Step by step
- Write the Dockerfile with a pinned base image digest, a non-root user, and a
.dockerignorefile that excludes.git, local.envfiles, and any credentials directory from the build context. - Identify any secret the build needs, such as a private registry token, and plan to pass it with
--mount=type=secretrather thanARGorENV. - In the workflow, set an explicit
permissions:block scoped to what the job actually does. - Pin every third-party action to a commit SHA, with the human-readable version in a trailing comment so upgrades stay reviewable.
- Build and push with
docker/build-push-action, supplying secrets through itssecrets:input. - Capture the build’s digest output and scan that exact digest, not a tag, for known vulnerabilities with a severity gate that fails the job on unresolved critical findings.
- Sign the scanned digest so consumers can verify provenance before pulling it.
- Have any deploy job consume the digest, not the branch-based tag, and gate deployment behind a protected environment with required reviewers or checks.
Common Mistakes
Mistake 1: passing secrets through --build-arg. Build arguments are recorded in the image’s build history and are visible to anyone who can inspect the image or, in some configurations, anyone with access to the build cache. Even removing the file that used the secret in a later layer does not remove it, because earlier layers are still part of the image. Correction: use BuildKit’s --mount=type=secret, which mounts the secret only for the duration of the single RUN instruction that needs it and never writes it into a layer.
Mistake 2: granting the workflow token broad permissions by habit. A job that only needs to push an image to a registry and read repository contents does not need permissions: write-all or the classic default of broad read/write access. If an action in that job is later compromised, or a step is manipulated through an untrusted input, the blast radius is whatever the token can do. Correction: declare the minimal permissions: block the job actually needs, for example contents: read and packages: write, and add id-token: write only when a step genuinely uses OIDC, such as keyless cosign signing.
Best Practices
- Pin base images by digest, not by a floating tag like
latestor even a minor version tag, and update the digest deliberately through a reviewed pull request. - Use multi-stage builds so build-time tools, source archives, and intermediate artifacts never reach the final runtime image.
- Run the container process as a non-root user with a fixed UID, and avoid installing a shell or package manager in the final stage if the application does not need one.
- Never pass secrets through
ARG,ENV, orCOPY; use BuildKit secret mounts, and confirm withdocker history --no-truncthat nothing sensitive leaked into a layer. - Maintain a
.dockerignorefile so local credentials,.githistory, and editor files never enter the build context in the first place. - Scan every image for known vulnerabilities before it is signed or promoted, and fail the pipeline on unresolved critical findings rather than only warning.
- Sign images with cosign, preferably using keyless signing backed by your CI provider’s OIDC identity, so signatures do not depend on a long-lived private key stored as a secret.
- Reference images by digest at deploy time, and treat pull requests from forks as untrusted: never run a build that consumes registry credentials or deploy secrets in response to a fork’s pull request without explicit review, and avoid
pull_request_targetfor anything that checks out and executes fork-controlled code on a privileged runner.
Practice Exercises
- Take an existing Dockerfile in one of your projects and rewrite it as a multi-stage build with a digest-pinned base image and a non-root final user. Confirm with
docker history --no-truncthat no credential appears in any layer. - Modify a build workflow so any private package registry token is passed through a BuildKit secret mount instead of a build argument, and verify the build still succeeds with the token unreadable inside the final image.
- Add a vulnerability scan step that runs against the build’s digest output and fails the job on critical severity findings, then intentionally introduce a known-vulnerable dependency to confirm the gate actually stops the pipeline.
Summary
Docker build security means treating the image as a supply chain artifact, not just a packaging format. Pin base images by digest, keep secrets out of layers with BuildKit mounts, scope the workflow token to the minimum permissions the job needs, pin the actions you call, scan and sign the resulting digest, and have deployments consume that digest rather than a mutable tag. Every path, credential, and registry name in these examples is a placeholder; adapt them to your own infrastructure without hardcoding real hosts or tokens into the workflow.
