Skip to content
Blog

Build Secrets with BuildKit: Keeping Credentials Out of Layers

Why ARG and ENV secrets stay permanently in image history, and how RUN --mount=type=secret plus the --secret flag keep credentials out of layers.

Published on • October 16, 2026

AI Assistant

You pass a registry token to docker build with --build-arg NPM_TOKEN=..., the build succeeds, and six months later someone pulls your image, runs docker history, and reads the token back out. Nothing crashed, no log warned you: the credential was never “leaked”, it was archived, layer by layer, exactly as designed. BuildKit’s answer is RUN --mount=type=secret - a file that exists only while one build instruction runs, never written to a layer and never part of the cache key. This post covers why ARG and ENV cannot do the job, how secret mounts actually work, and how to wire them into CI.

ARG and ENV are permanent by construction

Docker’s own best-practices docs are blunt: “Build arguments and environment variables are inappropriate for passing secrets to your build, because they persist in the final image.” Unsetting an environment variable later does not help, because each ENV line creates its own layer. The docs demonstrate it directly:

# syntax=docker/dockerfile:1
FROM alpine
ENV ADMIN_USER="mark"
RUN echo $ADMIN_USER > ./mark
RUN unset ADMIN_USER

Build that, then run:

$ docker run --rm test sh -c 'echo $ADMIN_USER'
mark

The value is still there. The fix even for non-secret config is to set, use, and unset inside a single RUN so it only ever exists in one layer - but that is a mitigation, not a model to copy for credentials. For real secrets, there is a second leak path: docker/build-push-action attaches provenance attestations to your image, and Docker documents that for public repositories those attestations “contains the values of build arguments”. If you are passing tokens as build args, they are published alongside the image. The documented remediation is exactly this post’s subject: refactor to secret mounts, then rotate anything you already exposed.

How RUN —mount=type=secret works

BuildKit supports several mount types inside a RUN instruction; the secret type is described in the Dockerfile reference as letting the build container access secret values “without baking them into the image or build cache”. Mechanics:

  • The builder takes a secret from the build client and makes it available for the duration of that one RUN.
  • By default it arrives as a file at /run/secrets/<id>, mode 0400, uid/gid 0.
  • When the instruction finishes, the mount is gone. Nothing is committed to the layer, and the secret is not part of the instruction’s cache key, so rotating a token does not invalidate your build cache.
  • Options on the mount: id, target (custom path), env (expose as an environment variable instead of, or alongside, a file, since Dockerfile v1.10.0), required (default false: with required=true the instruction errors out when the secret is unavailable), plus mode, uid, and gid.

In the Dockerfile:

# syntax=docker/dockerfile:1
FROM alpine
RUN apk add --no-cache aws-cli
RUN --mount=type=secret,id=aws,target=/root/.aws/credentials \
    aws s3 cp s3://my-private-bucket/data.tar.gz /tmp/

And on the command line, pass the secret in:

$ docker build --secret id=aws,src=$HOME/.aws/credentials .

Sources: files, environment variables, and explicit types

The --secret flag accepts a file (src=), an environment variable (env=), or an auto-detected shorthand. With Bake, the same thing is data:

target "default" {
  secret = [
    "id=aws,src=${HOME}/.aws/credentials"
  ]
}

Environment-variable examples from the docs:

$ docker build --secret id=kube,env=KUBECONFIG .
$ docker build --secret id=API_TOKEN .

The second form binds the variable named API_TOKEN to /run/secrets/API_TOKEN with no env= parameter. In the Dockerfile you choose how the step consumes it:

# as a file, referenced explicitly
RUN --mount=type=secret,id=aws \
    AWS_SHARED_CREDENTIALS_FILE=/run/secrets/aws \
    aws s3 cp ...

# as environment variables
RUN --mount=type=secret,id=aws-key-id,env=AWS_ACCESS_KEY_ID \
    --mount=type=secret,id=aws-secret-key,env=AWS_SECRET_ACCESS_KEY \
    aws s3 cp ...

For private Git dependencies there are two special mounts: --mount=type=ssh for an agent socket or key (passed with docker build --ssh default), and two predefined secrets for remote contexts, GIT_AUTH_TOKEN (Basic auth with GitHub’s x-access-token username) and GIT_AUTH_HEADER (a raw Authorization header for any provider), which can be scoped per host by appending the hostname, such as id=GIT_AUTH_TOKEN.github.com.

CI: GitHub Actions wiring

Docker’s official docker/build-push-action takes three inputs for secret mounts: secrets (inline value), secret-envs (runner environment variable), and secret-files (file path on the runner). A private npm registry build looks like this:

name: ci
on: [push]

jobs:
  docker:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: docker/setup-buildx-action@v4

      - name: Create .npmrc file
        run: |
          echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > .npmrc

      - name: Build
        uses: docker/build-push-action@v7
        with:
          context: .
          secret-files: |
            npmrc=./.npmrc
          tags: user/app:latest
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci

Two details worth copying: if the RUN uses a non-root user, set uid/gid/mode on the mount so that user can read it (uid=1000,gid=1000), and quote multi-line GitHub secrets in the secrets: block so embedded newlines survive. Keep provenance on while you are at it - provenance: mode=max is recommended - because with secret mounts instead of build args, provenance describes your build without carrying your tokens.

Combining secret mounts with cache mounts

Secret mounts pair well with --mount=type=cache, which gives package managers and compilers a directory that persists between builder invocations “without invalidating the instruction cache”:

# syntax=docker/dockerfile:1
FROM ubuntu
RUN rm -f /etc/apt/apt.conf.d/docker-clean; \
    echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    apt-get update && apt-get --no-install-recommends install -y build-essential

sharing=locked is there because apt needs exclusive access to its data. One rule to keep straight: cache mounts live in the builder’s local storage, are not exported by --cache-to and not restored by --cache-from, and start empty on every fresh CI runner. That makes them a performance feature, never a place to park a credential - anything you write into a cache directory outlives the instruction and travels with the builder.

Caveats before you declare victory

  • The same RUN can still see the secret. The mount is a real file to the command you execute. Keep that command surface small: no set -x, no echoing the value into build logs, no piping a token into a verbose tool. Anything the process prints can end up in build output.
  • Use required=true when a missing secret must fail the build. Default behavior is a silent skip of the mount, which turns into a confusing downstream auth error instead of a clean failure.
  • Build output and attestations describe the build. Attestations record build arguments; with secret mounts that record stays clean, but it also means you should never “fall back” to a build arg in a hurry.
  • Multi-stage discipline. Mount secrets only in the stage that consumes them (fetch, private install), and build your final stage from a slim base that never had the credential in scope. Bind mounts for build inputs (--mount=type=bind,source=requirements.txt,target=/tmp/requirements.txt) are the same trick for non-secret files: temporarily present, never committed.
  • Windows containers are different. There is no /run/secrets default, so an explicit target is required; the value is written to a clear-text file with an ACL and removed after the step, and an empty placeholder directory may remain at the target path in the resulting layer.

Verifying your own image

Trust nothing, check it:

$ docker build --secret id=aws,src=$HOME/.aws/credentials -t myapp:scan .
$ docker history --no-trunc myapp:scan | grep -i -E 'token|secret|aws' || echo "clean history"
$ docker image inspect myapp:scan --format '{{json .Config}}' | grep -i -E 'token|secret' || echo "clean config"

A hardened Dockerfile

Putting it together - secrets in the fetch stage only, cache mounts for speed, non-root runtime, no credential-bearing ENV:

# syntax=docker/dockerfile:1

FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
# private registry auth exists only for this instruction
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc,required=true,uid=1000,gid=1000 \
    --mount=type=cache,target=/root/.npm \
    npm ci

FROM deps AS build
COPY . .
RUN --mount=type=cache,target=/root/.npm \
    npm run build

FROM node:20-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
$ docker buildx build --secret id=npmrc,src=$HOME/.npmrc -t myapp:1.0.0 .

The token authenticates npm ci, never touches an image layer, never enters the cache key, and never appears in the runtime stage.

Key Takeaways

  • ARG and ENV values persist in the final image; unsetting them later does not remove the layer, and build args even show up in default provenance attestations for public repos.
  • RUN --mount=type=secret injects a file (default /run/secrets/<id>, mode 0400) only for that instruction: not in the layer, not in the cache key.
  • Pass secrets with --secret id=...,src=... for files, env=... for variables, or the secrets / secret-envs / secret-files inputs of docker/build-push-action; use Bake’s secret block for declarative builds.
  • Combine with cache mounts for package managers, but never write credentials into a cache directory - cache mounts persist in builder storage.
  • Set required=true for mandatory secrets, mount them only in the stage that needs them, keep the consuming command quiet, and run non-root in the final stage.
  • Verify with docker history and docker image inspect after every change, and rotate any secret that ever rode along as a build arg.

References: