Builds often need a credential for a minute. A private npm registry token, a pip index password, an SSH key to clone an internal Git dependency. The question is how to hand it to that one step without it ending up in the image, where everyone who can pull the image can read it.
The obvious approaches (ARG, ENV, copy the file in and delete it) all leak, each in a slightly different place. Here’s where, followed by the approach that doesn’t. Examples use BuildKit and alpine:3.20; the token is a fake value.
Where a build arg ends up
A common pattern, which even looks careful because it deletes the .npmrc in the same step:
FROM alpine:3.20
ARG NPM_TOKEN
RUN echo "//registry.example.com/:_authToken=${NPM_TOKEN}" > /root/.npmrc \
&& echo "installing..." \
&& rm /root/.npmrcdocker build --build-arg NPM_TOKEN=npm_s3cr3tV4lue -t app .BuildKit already objects during the build:
1 warning found (use docker --debug to expand):
- SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ARG "NPM_TOKEN") (line 2)And it’s right to. The file is gone, but the value isn’t:
$ docker history --no-trunc --format '{{.CreatedBy}}' app
RUN |1 NPM_TOKEN=npm_s3cr3tV4lue /bin/sh -c echo "//registry.example.com/:_authToken=${NPM_TOKEN}" > /root/.npmrc && ... # buildkit
ARG NPM_TOKEN=npm_s3cr3tV4lueBuild args that a RUN step uses are recorded with the step, because they’re part of what made that layer. Anyone with docker pull access can run docker history and read the token. Deleting the file in the same RUN did its job (no layer contains .npmrc), but that never addressed the history record.
The |1 NPM_TOKEN=... prefix is the detail worth remembering for an interview. Many people know “don’t copy secret files into images” and fewer know that build args are also exposed.
ENV and copied files leak in other places
ENV TOKEN=... is worse. It’s written to the image config, so it shows up in docker image inspect under Config.Env, is set in every container started from the image, appears in docker inspect output for those containers (and in any monitoring tool that collects it), and is inherited by every image built FROM yours.
Copying a credentials file in and deleting it in a later instruction leaks through layers instead:
COPY id_rsa /root/.ssh/id_rsa
RUN git clone git@github.com:acme/internal-lib.git
RUN rm /root/.ssh/id_rsaThe COPY layer contains the key, and the later rm only adds a whiteout. docker save app | tar -x and a look in the layer tarballs recovers it. The build context note shows the same mechanism for an accidentally copied .env.
Multi-stage builds are sometimes offered as the fix: do the authenticated work in a builder stage and copy only the output into the final stage. That keeps the secret out of the final image’s layers and history, which is a real improvement. But the builder stage’s cache still holds it on the build machine, and if anyone pushes the builder stage (CI cache exports with mode=max do exactly that), the secret goes with it. It’s defence in depth, not the main control.
Secret mounts: available for one step, recorded nowhere
BuildKit’s secret mounts expose a value to a single RUN instruction, either as a file under /run/secrets/ (or a target you pick) or as an environment variable, and they never touch a layer:
FROM alpine:3.20
RUN --mount=type=secret,id=npm_token,env=NPM_TOKEN \
echo "token length: ${#NPM_TOKEN}" && test -n "$NPM_TOKEN"
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
wc -c /root/.npmrc
RUN ls -la /root/The values come from the build command, read from your environment or a file:
NPM_TOKEN=npm_s3cr3tV4lue docker build \
--secret id=npm_token,env=NPM_TOKEN \
--secret id=npmrc,src=$HOME/.npmrc \
-t app .#5 0.249 token length: 15
#6 0.141 51 /root/.npmrc
#7 0.130 total 8
#7 0.132 drwx------ 1 root root 4096 Oct 7 17:31 .
#7 0.132 drwxr-xr-x 1 root root 4096 Oct 7 17:31 ..The steps saw the secret, the next step finds /root empty, and the history shows only the commands:
RUN /bin/sh -c ls -la /root/ # buildkit
RUN /bin/sh -c wc -c /root/.npmrc # buildkit
RUN /bin/sh -c echo "token length: ${#NPM_TOKEN}" && test -n "$NPM_TOKEN" # buildkitThree details trip people up. First, a secret that wasn’t passed is silently absent by default. Building without the --secret flags gave token length: 0, and only the test -n made the step fail. Add required=true to the mount, or check explicitly, so a misconfigured CI job fails loudly instead of installing from the public registry. Second, secrets aren’t part of the cache key, so rotating a token doesn’t rerun a cached step (which is usually what you want). Third, your command can still leak the value itself: echo $NPM_TOKEN prints it to the build log, and writing it into a file the step leaves behind puts it in the layer. The mount protects the transport, not careless commands.
For Git over SSH there’s a dedicated variant, RUN --mount=type=ssh, which forwards your SSH agent socket into the step with docker build --ssh default, so the private key never leaves your machine at all.
In Compose, build.secrets on a service passes the same mounts. CI systems typically take the value from their own secret store and pass it via env=.
Note
Older guides add
# syntax=docker/dockerfile:1to enable secret mounts. Current Docker versions ship a frontend that supports them natively; the directive is still useful when you need the newest Dockerfile features.
Runtime secrets are a separate question
Everything above is about build time. At runtime the image shouldn’t contain credentials either. A single image should run in staging and production, so the database password must arrive at start-up: through Compose or Swarm secrets: (mounted as files under /run/secrets/), Kubernetes Secrets, or a secrets manager the app queries. Environment variables work but are visible via docker inspect and to child processes; files are generally preferred for anything sensitive.
Interview practice
A colleague’s Dockerfile installs a private Python package with ARG PIP_INDEX_URL (which contains a username and password) followed by RUN pip install -r requirements.txt. They argue it’s fine because it’s a multi-stage build and the final stage only copies /install from the builder. Is the final image safe? What risks remain, and what would you change?
Answer
The final image’s history and layers come only from the final stage, so the credential won’t appear in its docker history or layers, assuming nothing in the final stage uses the arg. In that narrow sense the final image is clean.
The risks are elsewhere. The builder stage’s history and cache contain the full index URL, so it’s readable on the build machine, in any exported build cache (--cache-to ... mode=max exports intermediate stages), and in any tag where someone builds or pushes --target builder. pip may also log the URL on errors, and a pip.conf or cached wheel metadata in the builder could hold it.
The fix is to use a secret mount in the builder: RUN --mount=type=secret,id=pip_index,env=PIP_INDEX_URL pip install ... with --secret id=pip_index,env=PIP_INDEX_URL on the command line, plus required=true. Then check with docker history --no-trunc on both stages that the URL doesn’t appear, and rotate the existing credential, since it has already been in cache exports.
Keep going
- Docker interview questions
- Docker MCQ practice
- Docker build args vs environment and secrets
- Docker build context and ignore rules
- Official reference: Build secrets