Skip to main content
RunBook Academy

Docker & ContainersV Β· Dockerfiles & BuildKitBuild secrets

Build secrets and SSH mounts

Advanced⏱ ~26 mindocker

What you'll learn

  • Use BuildKit secrets for build-time credentials, with the right target, mode and uid
  • Use BuildKit SSH mounts for private source access
  • Explain why an ARG-passed secret cannot be removed from an image after the fact
  • Verify that secrets do not leak into layers, history or attestations
  • Respond correctly when a credential has already been baked in

Prerequisites

Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-12

Not yet marked complete on this device.

The default docker build has no way to pass secrets without exposing them in layers. BuildKit fixes this with RUN --mount and RUN --ssh.

The fix matters because the alternative is not β€œslightly less tidy”. A credential that reaches an image layer or an image config is in a content-addressed blob that you cannot edit, that has already been replicated to every node that pulled it, and that no amount of retagging or deleting will recall.

Build secrets (--mount=type=secret)

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
  npm ci
docker buildx build --secret id=npmrc,src=$HOME/.npmrc .

The secret file is mounted at /root/.npmrc (or wherever specified) during the RUN step. After the step, the secret file is gone. The layer produced by the step does not contain the secret.

Multiple secrets:

RUN --mount=type=secret,id=aws_key \
    --mount=type=secret,id=aws_secret \
  ./deploy.sh

Every option, and the defaults that catch people

The Dockerfile reference documents these fields on --mount=type=secret:

FieldDefaultNotes
idbasename of targetThe name the CLI must supply
target / dst / destination/run/secrets/ + idWhere the file appears
envβ€”Mount as an environment variable instead of a file (Dockerfile frontend 1.10.0+)
requiredfalseIf false, a missing secret is silently empty
mode0400Octal file mode
uid0Owning user
gid0Owning group

On the CLI side, --secret takes id=<name>,src=<file> for a file, id=<name>,env=<variable> to read from the environment, or just id=<name> when the variable already has the same name.

Two of those defaults cause most of the support traffic.

Why an ARG-passed secret is in the image forever

Docker’s build-variables documentation states it plainly: β€œBuild arguments and environment variables are inappropriate for passing secrets to your build, because they’re exposed in the final image.” The mechanism behind that sentence is what makes it non-negotiable.

Verifying secrets do not leak

Three checks, each of which can actually fail. Run them in CI, not by hand after an incident.

Read-only / Safescan history
IMAGE=myorg/myapp:1.0.0
docker history --no-trunc --format '{{.CreatedBy}}' "$IMAGE" \
| grep -Ei 'token|secret|passwd|password|api[-_]?key|BEGIN [A-Z ]*PRIVATE KEY' \
&& echo 'FAIL: credential-shaped string in image history' && exit 1
echo 'history clean'
Read-only / Safeenv
$ docker inspect --format '{{json .Config.Env}}' myorg/myapp:1.0.0
["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","NODE_VERSION=22.14.0"]

Illustrative output

Then inspect the layers themselves:

docker save myorg/myapp:1.0.0 | tar -tvf -

Look for any file that might contain the secret. Common mistakes:

  • echo $SECRET > /some/file β€” the file ends up in the layer.
  • Logging the secret β€” the log ends up in the layer’s metadata.
  • Sending the secret in an HTTP request that gets logged by a sidecar β€” the request body may be cached in some logs.

The protection is β€œthe secret file itself is not in any layer.” The protection is not β€œthe secret cannot appear in a layer.”

SSH mounts (--mount=type=ssh)

For builds that need SSH access (private Go modules, private git repositories):

RUN --mount=type=ssh \
  git clone git@github.com:myorg/private-repo.git
eval $(ssh-agent -s)
ssh-add ~/.ssh/id_ed25519
docker buildx build --ssh default .

The host’s SSH agent is forwarded to the build container for the duration of the step. After the step, the agent socket is gone.

The documented defaults: id is default, the socket appears at /run/buildkit/ssh_agent.${N}, mode is 0600, and required is false β€” so the same silent-failure warning applies. The N is the index of the mount within the instruction, which is why you rarely reference the path directly; BuildKit sets SSH_AUTH_SOCK for you.

Common pitfalls

  • Putting secrets in ENV or ARG. Visible in docker history --no-trunc and in the image metadata. Do not do this.
  • Putting secrets in a COPY from the build context. The secret file ends up in the layer. Do not do this.
  • Using a non-BuildKit builder. Legacy builders do not support secret mounts; secrets must be passed via ARG (visible) or via mount at runtime (does not help build).
  • Multi-stage with secrets. Secrets are scoped to the stage that uses them. They are not visible to subsequent stages by default. This is the correct behaviour; pass the secret explicitly to each stage.
  • Assuming rotation invalidates the build. It does not β€” see below.

Knowledge check

Knowledge check Β· 6 questions

  1. Q1. To use a private Git repository at build time, the recommended BuildKit pattern is:

  2. Q2. A `--mount=type=secret` value is persisted in the image's filesystem layers.

  3. Q3. A token was passed with `--build-arg` six months ago. The tag has since been overwritten. What is the correct first action?

  4. Q4. A stage runs `USER node`, then `RUN --mount=type=secret,id=npmrc,target=/home/node/.npmrc npm ci`, and the step fails with EACCES on that path. Why?

  5. Q5. Which of these can expose a build-time credential to anyone who can pull the image? Select all that apply.

  6. Q6. By default a forgotten `--secret` flag produces an empty mount rather than a build failure, so `required=true` is what makes the build fail.

Passing score: 75%. Answers are checked in this browser.