Short answer: use BuildKit's RUN --mount=type=secret instead of ARG or ENV. Declare the secret once with RUN --mount=type=secret,id=mytoken, read it from /run/secrets/mytoken inside that RUN step only, and pass the value at build time with docker build --secret id=mytoken,src=./mytoken.txt. The file is mounted only for the duration of that instruction and is never written to a layer or the final image config.
Most Dockerfiles that need a build-time credential start like one of these:
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc && npm ci
ENV NPM_TOKEN=abc123
RUN npm ci
Both leave the token recoverable after the build finishes: BuildKit records build-arg values for the build, and an ENV value is written straight into the image's config layer, visible to anyone with access to the image via docker history or docker inspect -- Docker's own "Build secrets" documentation says so directly.
In the Dockerfile, add --mount=type=secret to the one RUN instruction that actually needs the value, and read it from the mount path BuildKit provides (/run/secrets/<id> by default):
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) && \
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc && \
npm ci && \
rm .npmrc
The mount exists only for this RUN instruction. No other layer, no other stage, and no docker history line ever sees it -- there is nothing to redact because nothing was ever written down.
Supply the secret from a file (never a shell variable expanded straight into the Dockerfile) when you build:
docker build --secret id=npm_token,src=./npm_token.txt .
Add the source file itself to .dockerignore and .gitignore so it is never sent to the build context or committed by accident -- the mount reads it directly from the host, not from the context.
A secret-looking ENV or ARG default, or a COPY/ADD of a file like .env or .npmrc with a token already in it, is the pattern to replace with the mount above. Paste your Dockerfile into Docker Build Secrets Check to find every instance at once, each with its own remediation, or run the CLI in your own repository:
python3 check.py --fail-on-risk
That exits with a nonzero status if any high-risk finding remains -- wire it into your own CI to block a newly-added secret-looking ENV or copied credential file before it merges.
ARG declaration is still worth fixingAn ARG SIGNING_TOKEN line with no default commits nothing to the Dockerfile itself -- there is no secret text in the file for anyone reading it to see. The risk is what it invites: a caller building the image with docker build --build-arg SIGNING_TOKEN=... hands BuildKit a value it still records for that build, the same exposure as a literal default, just deferred to whoever runs the build rather than whoever wrote the Dockerfile. If an argument is genuinely meant to carry a secret, mount it instead of declaring it as a plain ARG.