skip to content

During a Docker image build you need a private credential, for example a package registry token or an SSH key for a private Git dependency. How do you supply it so it never ends up in the published image?

level: middleimportance: must knowfreq 55%

answer

  1. layers are additive: rm in a later layer hides, never deletes
  2. --build-arg and ENV land in image config and history
  3. --mount=type=secret -> tmpfs at /run/secrets/<id>, that RUN only
  4. --mount=type=ssh forwards the agent, key never enters the build
  5. leaked once = rotate; scrubbing the image is not a fix

basics

~20 s

Use BuildKit mounts. RUN --mount=type=secret,id=tok exposes the value as a file under /run/secrets for that one instruction only, and it is never written to a layer or to image history. For private Git over SSH use --mount=type=ssh to forward the agent. Never pass credentials via ARG, ENV or COPY.

solid answer

~50 s

Credentials must never enter a layer, and "delete it in a later RUN" does not help, because the earlier layer still contains the file and anyone can extract it. The correct mechanism is a BuildKit secret mount: ``` RUN --mount=type=secret,id=npmtok \ NPM_TOKEN=$(cat /run/secrets/npmtok) npm ci ``` built with `docker build --secret id=npmtok,src=./token` or `--secret id=npmtok,env=NPM_TOKEN`. BuildKit mounts it on a tmpfs visible only to that RUN; it is not committed, not in `docker history`, and not in the exported cache. For private Git dependencies, `RUN --mount=type=ssh` forwards the host's SSH agent socket, with `docker build --ssh default`, so the key itself never enters the build. What not to do: `--build-arg TOKEN=...` (recorded in image config and history), `ENV TOKEN=...` (persists in the image), or `COPY key .` followed by `RUN rm key` (still in the earlier layer). If a credential ever did land in a pushed image, rotate it; scrubbing the image is not a fix.

code

dockerfile · 10 lines
dockerfile
# syntax=docker/dockerfile:1
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=ssh \
    mkdir -p -m 0700 ~/.ssh && ssh-keyscan github.com >> ~/.ssh/known_hosts && \
    GOPRIVATE=github.com/acme/* go mod download
COPY . .
RUN --mount=type=secret,id=apitoken,required=true \
    API_TOKEN=$(cat /run/secrets/apitoken) ./fetch-assets.sh

go deeper

for a junior

Know that credentials must never be copied in or passed as build args, and that BuildKit secret mounts are the supported way.

for a middle

Show the exact syntax for secret and ssh mounts, explain why layers and history leak, and consume the secret inside the mounting RUN.

for a senior

Add verification and incident response: history and layer inspection, CI secret scanning, cache-export implications, and rotation on disclosure.

for a principal

Push toward short-lived, workload-identity-issued build credentials so leaks have small blast radius, plus organisation-wide build policy and scanning gates.

## Why the naive approaches leak An image is an ordered set of immutable layers plus a config blob. Three properties follow. First, **layers are additive and permanent**. COPY id_rsa . creates a layer containing that file. A later RUN rm id_rsa creates another layer with a whiteout marker so the file is invisible in the merged view, but the earlier layer still ships with the image. Anyone who pulls it can docker save the image and read the key out of the tarball. Second, **build args and ENV are recorded in metadata**. Values passed with --build-arg appear in the image configuration and are visible with docker history. ENV values are part of the image config by design and are also injected into the environment of every process in every container from that image, so they leak both at rest and at runtime. Third, **caches and logs are exposed too**. Exported build caches, CI logs and build summaries can all carry a credential passed by a mechanism that treats it as ordinary build data. ## The secret mount BuildKit adds RUN --mount=type=secret. The build is invoked with docker build --secret id=<name>,src=<path> (or env=<VAR> to take it from the environment of the CLI, or with no src to default to an env var of the same name). During that single RUN instruction, the value is mounted as a file, by default at /run/secrets/<id>, on a tmpfs. When the instruction finishes, the mount is gone. Nothing about it is committed to a layer, recorded in history, or written into the exported cache. You can control target, mode, uid and gid, and mark it required so the build fails loudly rather than silently proceeding without the credential. The key discipline is that the secret must be consumed inside the same RUN that mounts it. Reading it into an ENV, or writing it to a file under the working directory, defeats the whole mechanism, because now it is in the layer. ``` # syntax=docker/dockerfile:1 FROM node:22 RUN --mount=type=secret,id=npmrc,target=/root/.npmrc,required=true npm ci ``` Mounting straight to the config path the tool expects is often cleaner than catting the file into a variable, because the value never appears in a shell command line where it might be echoed by set -x. ## The ssh mount Private Git dependencies are the other common case. RUN --mount=type=ssh forwards the host SSH agent socket into the build, invoked as docker build --ssh default. The agent performs the signing; the private key never enters the build environment at all, so there is nothing to leak even in principle. You still need the host key in known_hosts, typically via ssh-keyscan in the same RUN, and the mount must be present on every RUN that reaches out to the private remote. ``` RUN --mount=type=ssh mkdir -p -m 0700 ~/.ssh \ && ssh-keyscan github.com >> ~/.ssh/known_hosts \ && go mod download ``` ## Verification and incident handling To prove a build does not leak, inspect what shipped rather than trusting the Dockerfile: docker history --no-trunc shows build args and instructions; docker save piped into a tar listing lets you grep layer contents; dive or a scanner with secret detection automates it. Many organisations run a secret-scanning gate over built images in CI for exactly this. If a credential did reach a pushed image, treat it as disclosed. Registry layers may be cached by consumers, mirrored, or already pulled. Deleting the tag does not recall the bytes. Rotate the credential, then fix the Dockerfile. ## Alternatives worth naming Multi-stage builds are sometimes offered as the answer: fetch dependencies in a build stage using a credential, then copy only the artefacts into the final stage. That does keep the credential out of the final image, and it is a legitimate pattern, but the intermediate stage still contains the secret in its layers, which matters if that stage is cached, exported, or pushed as a cache target. Secret mounts are the cleaner answer and compose fine with multi-stage builds. On platforms with a build service (CI runners, cloud builders), the strongest option is often not to hand the build a long-lived credential at all: use short-lived, workload-identity-issued tokens so a leak has a small blast radius. That is the principal-level framing to add on top of the mechanism.

  • A colleague argues that COPY key . followed by RUN rm key is safe because the final image does not contain the file. Why are they wrong?
    Layers are immutable and additive: the COPY created a layer that still holds the key, and the rm only adds a whiteout entry so the file is hidden in the merged filesystem view. Anyone who pulls the image can run docker save and read the key straight out of the earlier layer tarball. The same reasoning applies to unsetting an ENV or overwriting a file in a later instruction.
  • Can a secret mounted with --mount=type=secret end up in an exported build cache pushed with --cache-to?
    No. BuildKit deliberately excludes secret mount contents from the layer and from cache export; the mount exists on a tmpfs only for the duration of that RUN. The risk is what your command does with the value: if the RUN writes it to a file in the working directory, or bakes it into an artefact, that output is normal build data and will be cached and exported like anything else.
  • How would you detect that a pushed image contains a credential, and what do you do about it?
    Inspect the artefact rather than the source: docker history --no-trunc for build args and instructions, docker save plus a grep over the layer tarballs, or a scanner with secret detection wired into CI as a gate. If a credential is found in something already pushed, rotate it immediately and assume disclosure, because layers may have been pulled, mirrored or cached by others; deleting the tag does not recall the bytes.

A secret mount is a visitor badge handed over at the door and taken back on the way out; a build arg is writing the door code in the visitor log that ships with the building.

saying these in an interview costs you the question

  • Claiming a later RUN rm removes a copied credential from the image
  • Passing tokens via --build-arg because they are 'not in the Dockerfile'
  • Setting a credential as ENV so subsequent instructions can use it
  • Believing multi-stage builds alone make an intermediate stage's secret unreachable when caches are exported
  • Treating a leaked credential as fixed by deleting the image tag instead of rotating

context