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.
| Pattern | Where the value survives | How to see it |
|---|---|---|
ARG plus --build-arg | The created_by string of the layer, and the provenance attestation at mode=max | docker history --no-trunc |
ENV TOKEN=... | The image config, and the environment of every process started from the image | docker inspect --format '{{json .Config.Env}}' |
COPY creds.json then RUN rm creds.json | The earlier layer diff, which ships inside the image | docker save and read the layer tarballs |
| A secret used in a build stage that the final image discards | The builder cache, and any registry cache exported with mode=max | Pull 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 ciThe 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 buildAfter, 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 buildThree 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.shFor 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.
| Check | Where it runs | Time per run | Runs per month | Runner minutes | Cost |
|---|---|---|---|---|---|
| History and config inspect | Every build | 0:08 | 600 | 80.0 | $0.32 |
Full layer scan with docker save | Nightly and release builds | 0:50 | 40 | 33.3 | $0.13 |
| Provenance inspect on the pushed tag | Pushes to the default branch | 0:05 | 120 | 10.0 | $0.04 |
| Total | 123.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.