Passing Secrets into Docker Builds Safely

A --build-arg token lands in the image history. Mount it with RUN --mount=type=secret in GitHub Actions instead, then prove it is absent from every layer.

A build argument is a parameter, and BuildKit records the parameters an instruction ran with, so --build-arg NPM_TOKEN=... publishes that token to everyone who can pull the image. Pass the value through RUN --mount=type=secret instead: BuildKit exposes the file for the duration of one instruction, keeps it out of the layer diff, and keeps it out of the recorded command line.

This guide covers where each leaky pattern deposits the value, the Dockerfile and workflow changes that replace it, the BuildKit mount syntax for the common package managers, a verification step that fails the build when a credential reaches a layer, and the arithmetic on what the checks cost per month. For the wider picture, start at Docker builds on GitHub Actions.

Diagnosis

GitHub Actions masks a secret in the log stream, and that masking stops at the log. The same value can still be written into an image layer, into the image config, or into a provenance attestation, and all three travel to whoever pulls the tag. GitHub documents the boundary in using secrets in GitHub Actions.

Four patterns account for most leaks, and each one leaves the value somewhere different.

PatternWhere the value survivesHow to see it
ARG plus --build-argThe created_by string of the layer, and the provenance attestation at mode=maxdocker history --no-trunc
ENV TOKEN=...The image config, and the environment of every process started from the imagedocker inspect --format '{{json .Config.Env}}'
COPY creds.json then RUN rm creds.jsonThe earlier layer diff, which ships inside the imagedocker save and read the layer tarballs
A secret used in a build stage that the final image discardsThe builder cache, and any registry cache exported with mode=maxPull the cache ref and inspect its blobs

The first one is the most common because it looks contained. The argument is declared, consumed by one command, and never copied into the runtime stage, so the assumption is that it disappeared with the stage. It did not:

$ docker history --no-trunc --format '{{.CreatedBy}}' ghcr.io/acme/api:1.4.2
|1 NPM_TOKEN=npm_7Qa4KjR2vX9pLtB6 /bin/sh -c npm config set //registry.npmjs.org/:_authToken=$NPM_TOKEN && npm ci

The fourth pattern is the one that survives a correct multi-stage Dockerfile. The discarded stage keeps the secret out of the published image and still writes it into the layers the builder caches. If those layers are exported to a registry cache with mode=max, the cache ref carries the credential and is usually readable by anyone who can read the repository.

Fix

A secret mount is a bind mount that BuildKit attaches for the duration of a single RUN instruction. The file exists at /run/secrets/<id> by default, is never part of the filesystem diff that becomes the layer, and its value is never written into the recorded command line.

Before, with the token as a build argument:

FROM node:22-slim
WORKDIR /app
ARG NPM_TOKEN
COPY package.json package-lock.json ./
RUN npm config set //registry.npmjs.org/:_authToken=$NPM_TOKEN && npm ci
COPY . .
RUN npm run build

After, with the credential file mounted at the path npm already reads:

# syntax=docker/dockerfile:1.7
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc,required=true \
    npm ci
COPY . .
RUN npm run build

Three details carry the change.

Mount the credential file rather than the raw value. npm config set writes /root/.npmrc inside the container, and that file lands in the layer. Mounting the finished .npmrc at the path the tool reads removes the write step entirely. The same shape works for /root/.netrc, ~/.docker/config.json, and ~/.m2/settings.xml.

Set required=true. Without it, a missing secret produces a build that runs anyway and fails later with a confusing registry error, or worse, succeeds against the public registry and ships a different dependency tree.

Read the file inside the instruction when a tool wants an environment variable. The value stays inside the process that needs it:

RUN --mount=type=secret,id=sentry_token,required=true \
    SENTRY_TOKEN="$(cat /run/secrets/sentry_token)" \
    ./scripts/upload-sourcemaps.sh

For private Git dependencies, RUN --mount=type=ssh forwards an agent socket instead, which avoids putting a deploy key on disk at any point. Registry authentication for the base image is a separate problem with its own answer in how to authenticate to a private registry in GitHub Actions.

Configuration

The workflow, on a remote Docker builder

Warpbuilds/build-push-action@v6 is a drop-in replacement for the upstream build and push action and keeps its inputs, including secrets and secret-files, with profile-name selecting the builder profile. Every input is listed in the Docker Builders documentation.

name: docker
on:
  pull_request:
  push:
    branches: [main]

jobs:
  build:
    runs-on: warp-ubuntu-latest-x64-2x
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Write the registry credential file
        run: |
          printf '//registry.npmjs.org/:_authToken=%s\n' \
            "${{ secrets.NPM_TOKEN }}" > "$RUNNER_TEMP/npmrc"

      - uses: Warpbuilds/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/acme/api:${{ github.sha }}
          profile-name: api-amd64
          secret-files: |
            npmrc=${{ runner.temp }}/npmrc
          secrets: |
            sentry_token=${{ secrets.SENTRY_TOKEN }}

The credential file goes under RUNNER_TEMP so it stays outside the build context and outside anything COPY . . can reach. Workflows triggered by pull_request from a fork receive no repository secrets, so a build that declares required=true fails on fork pull requests by design; run those through a separate job that builds without the private registry.

The local equivalent takes the same two secrets from a file and from the environment:

docker buildx build \
  --secret id=npmrc,src="$HOME/.npmrc" \
  --secret id=sentry_token,env=SENTRY_TOKEN \
  -t ghcr.io/acme/api:local .

Proving the value is absent

Run these against a throwaway credential the first time, because the last check greps for the literal value.

docker buildx build --load -t api:verify \
  --secret id=npmrc,src="$RUNNER_TEMP/npmrc" .

docker history --no-trunc --format '{{.CreatedBy}}' api:verify \
  | grep -qi -e '_authToken' -e 'NPM_TOKEN=' && exit 1

docker inspect --format '{{json .Config.Env}}' api:verify \
  | grep -qi 'token' && exit 1

docker save api:verify -o "$RUNNER_TEMP/api.tar"
mkdir -p "$RUNNER_TEMP/api" && tar -xf "$RUNNER_TEMP/api.tar" -C "$RUNNER_TEMP/api"
grep -rql --binary-files=text \
  "$(cut -d= -f2 "$RUNNER_TEMP/npmrc")" "$RUNNER_TEMP/api" && exit 1

echo "credential absent from history, config, and every layer"

The history check catches build arguments, the config check catches ENV, and the layer scan catches a file that was copied in and deleted later. On a build that pushed rather than loaded, replace the first command with docker buildx imagetools inspect ghcr.io/acme/api:$GITHUB_SHA --format '{{json .Provenance}}' and read the recorded invocation parameters.

Docker builds run from the Linux labels. Remote Docker builders sit alongside snapshot runners, CI observability, an MCP server, and the Action Debugger. On the platform side, WarpBuild does not access or store build secrets, and each runner runs in its own virtual machine that is created on demand and destroyed after the build, both documented in the security documentation. WarpBuild is SOC 2 Type 2, with the report and control documentation available in the trust center.

Cost or Time Model

The checks are cheap enough that the argument for running them on every build is arithmetic rather than judgement. Rates below are the job runner warp-ubuntu-latest-x64-2x (2 vCPU, 8 GB) at $0.004 per minute and a 16 vCPU, 32 GB, 100 GB disk builder profile at $0.06 per minute, both from the pricing page and checked on 2026-08-13, for a roughly 900 MB Node service image built 600 times per month.

CheckWhere it runsTime per runRuns per monthRunner minutesCost
History and config inspectEvery build0:0860080.0$0.32
Full layer scan with docker saveNightly and release builds0:504033.3$0.13
Provenance inspect on the pushed tagPushes to the default branch0:0512010.0$0.04
Total123.3$0.49

Set that against the rebuild after a credential reaches a published tag. Rotating the token invalidates every image that embedded it, so each affected tag has to be rebuilt and republished: 60 tags at 6:00 of builder session each is 360 minutes at $0.06 per minute, or $21.60, plus 60 jobs at 2:00 on the runner at $0.004 per minute, or $0.48. Compute comes to $22.08. The rebuild is the small part of that day. The rotation itself, the audit of who pulled the affected tags, and the wait for downstream teams to move off them are what the incident actually costs.

Builder sizes and disks are listed on remote Docker builders, and the repository-level side of credential handling is covered in managing GitHub Actions secrets across repositories.

FAQ

Does a build arg really end up in the published image?

Yes. BuildKit records the arguments a RUN instruction was executed with in that layer's created_by string, so docker history --no-trunc on any pulled tag prints the value. With provenance set to mode=max the build arguments are also recorded in the attestation attached to the pushed image.

Does rotating a token invalidate the layers built with it?

No. BuildKit keeps the secret value out of the layer cache key, so a rotated credential on its own does not force the RUN instruction to execute again. Change something the cache key does cover, such as the lockfile or a cache-busting build argument, when a rebuild with the new credential matters.

Where does the secret live when the build runs on a remote Docker builder?

The value is sent with the build request and mounted for the duration of the one RUN instruction that declares it, and it is never written into a layer. WarpBuild does not access or store build secrets, and each runner VM is created on demand and destroyed after the job.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.