Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

Dockerfile Secrets: The Three Patterns That Actually Keep Them Out of the Image

Sean

Platform Writer

Jun 20, 2026
9 min read

The naive ENV SECRET=xxx is the wrong answer for Dockerfile secrets because it bakes the value into every subsequent layer, and docker history <image> will print it back to anyone with read access to the image. The fix is one of three patterns: a BuildKit --secret mount, an ARG paired with a careful layer structure, or a runtime env var sourced from the platform’s secret store. Each has a use case. The mistake is using the wrong one.

The reason this matters: once a secret is in a layer, it is in the image forever. Every docker pull, every push to a registry, every docker save includes it. Squashing the image with docker export does not remove it. Multi-stage builds do not remove it. The only fix is rotation after the leak.

The three patterns in order of preference: BuildKit secret mounts for secrets consumed during docker build, runtime env vars for secrets consumed when the container starts, and ARG with explicit --no-cache discipline for the cases where BuildKit is not an option. The naive ENV does not appear in the list because it never should be the answer.

Dockerfile secrets: the three patterns that actually keep them out of the image

Table of contents

Why ENV is the wrong answer

The mechanics: every RUN, COPY, and ENV instruction in a Dockerfile creates a new image layer. The layer is a diff against the previous layer, stored as a tarball in the image manifest. ENV SECRET=xxx puts the literal string SECRET=xxx into that layer’s metadata. docker history --no-trunc <image> prints it back. So does docker inspect. So does anyone who can pull the image.

The “but it’s in a private registry” argument is the most common objection and does not hold. A leaked registry credential, a misconfigured IAM role, an image pushed to the wrong repo, a CI step that publishes to a public registry “just for testing” — any of these puts the image, with all its layers, in front of someone who can read it. Treat the image as a public artifact from the moment it is built.

ARG has a similar problem unless paired with care. An ARG set on the command line (docker build --build-arg TOKEN=xxx) is recorded in the image history unless you use the --secret mount or a BuildKit-only ARG that is consumed in the same layer. The default builder is the one that leaks; the BuildKit builder is the one that does not.

The three patterns that work

BuildKit secret mount — the secret is mounted as a file in a specific RUN step, never persisted to a layer, and is not in docker history. Requires DOCKER_BUILDKIT=1 (the default on modern Docker) and the docker buildx builder.

Runtime env var from the platform’s secret store — the secret is never in the Dockerfile at all. The platform injects it when the container starts, from a vault the platform manages. This is the right answer for application secrets (database URLs, API tokens) that the app needs at runtime.

ARG with BuildKit and a single RUN — works for the cases where BuildKit is available and the secret is consumed inside a single RUN step. The ARG value is still in docker history unless you also use the --secret mount, so this is a niche pattern.

There is a fourth pattern, the one you should never reach for: baking the secret into a base image and pulling the base image at build time. It is a worse version of ENV because the leak is now in the base image, every image built on top of the base is also leaked, and the base image is a piece of infrastructure that has to be maintained forever.

The BuildKit secret mount

The pattern. The secret lives in a file on the host (or is read from stdin), the Dockerfile mounts it into a single RUN step, and the mount is not persisted to the image.

# syntax=docker/dockerfile:1.7
FROM node:20-slim

WORKDIR /app
COPY package*.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]

Build it with:

DOCKER_BUILDKIT=1 docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .

The --mount=type=secret mounts the file at /root/.npmrc for the duration of the RUN npm ci step. After the step exits, the file is gone. It does not appear in any layer, in docker history, or in docker inspect. The npm install completes because it reads the token from the mounted file.

The same pattern for SSH keys (to pull private git deps), GPG keys (to verify signed packages), and any other build-time credential. The id is the mount name; the src is the path on the host. The target is the path inside the container for this RUN step.

BuildKit is the default in Docker 23+ and the only option in docker buildx. There is no reason to use the legacy builder for any new image. If you are on an older Docker, the time to upgrade was last year.

The ARG pattern and its limits

The legacy pattern: pass the secret as a build arg, consume it in a RUN step.

FROM node:20-slim
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > /root/.npmrc && \
    npm ci --omit=dev && \
    rm -f /root/.npmrc

This works to keep the secret out of the runtime image, because the .npmrc file is deleted in the same RUN step. The token itself, however, is in the image history as the value of the ARG NPM_TOKEN line. docker history --no-trunc myapp will print it.

The pattern is safe only if:

  • The token is short-lived and rotated aggressively.
  • The image is built in a CI environment where the build args are not logged.
  • The image is pushed to a private registry with no external pull access.
  • The build uses BuildKit with the ARG consumed in a RUN --mount=type=secret instead of the legacy ARG semantics.

For anything else, use the BuildKit secret mount.

Runtime env vars, the right way

For application secrets (database URLs, API keys, third-party tokens), the right answer is that the secret is never in the Dockerfile at all. The platform injects it from a vault when the container starts.

FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]
# On the host, or in the platform's deploy config
DATABASE_URL=postgresql://user:***@db.example.com/mydb
API_KEY=sk_replace_me
docker run -e DATABASE_URL -e API_KEY myapp

The app reads the env var at startup. The Dockerfile has no knowledge of the secret. The image is safe to push to any registry, share with any developer, and rebuild without invalidating any cache layer. The platform’s secret store is the source of truth, the env var is the transport, and the runtime is the consumer.

The two questions to ask about any env var approach: where does the value come from, and who can read it? If the value is in a .env file in the repo, you have not done anything. If the value is in the platform’s vault and only the running service can read it, you have done the right thing.

The deploy platform story

A managed platform that handles secrets without Swarm, without docker secret, and without a third-party vault does the work the Dockerfile would otherwise have to do. The platform reads the secret from its store, injects it as an env var at container start, and never lets the secret touch the build pipeline. The Dockerfile stays clean, the image stays shareable, and the secret rotation is a one-line change in the platform’s dashboard.

The cost model: a runtime with N secrets and a build pipeline that does not need to read them is a service that scales by adding replicas, not by adding a new secret-management tier. The RunxBuild hosting calculator is the right place to model that — pick the runtime size, the build frequency, the number of secrets in the store, and the bandwidth, and the calculator shows what the platform actually costs at the team’s actual usage.

For a deeper look at how env vars should be wired through a deploy platform, the Heroku environment variables post walks through the same pattern on a different platform. The shape is the same: vault at the platform, env var at the container, no value in the image.

The audit pattern

Every team that has been around for more than a year has a Dockerfile somewhere with a ENV or ARG for a secret that was rotated six months ago. The audit is the way to find them.

# Find Dockerfiles that reference common secret names
grep -rEn 'ENV|ARG' --include='Dockerfile*' . | \
  grep -iE 'token|key|secret|password|credential|auth'

That finds the obvious cases. The less obvious case is a base image that bakes a secret in. docker history --no-trunc <image> on every base image in the registry, looking for the same keywords. Anything that turns up is a leak that has to be rotated and a base image that has to be rebuilt without the secret.

For the deeper audit — secrets that may be in image layers but not in any text field — pull every image in the registry, docker save each one, and grep the resulting tarball for the prefix patterns of the secrets you care about. This is slow and expensive and is the only way to be sure. Most teams do it once a year as part of a security review.

How this fits the rest of the stack

Secrets that are not in the image are secrets the team can rotate, audit, and version. The platform’s secret store is the right home for them. The RunxBuild hosting calculator is the quick way to model what that platform costs at the team’s actual usage — runtime size, secret count, build frequency, bandwidth, and database tier all roll up into a single number the team can plan around.

Useful related references:

FAQ

Why is ENV the wrong answer for Dockerfile secrets?

ENV SECRET=xxx puts the literal value into the image layer’s metadata. docker history --no-trunc and docker inspect both print it back. The value is then in every image push, every docker save, every registry the image is published to. Treat the image as a public artifact from the moment it is built.

What is the BuildKit secret mount?

It is a --mount=type=secret,id=... flag on a RUN step that mounts a file from the host for the duration of that step. The file is not persisted to a layer, does not appear in docker history, and is gone when the step exits. Requires BuildKit, which is the default in Docker 23+.

Does ARG work for secrets?

Only if the ARG is consumed in a RUN step that immediately removes the file containing the value, and only if the image is built with BuildKit’s secret semantics. The legacy ARG puts the value in docker history as the build arg value, which is a leak. Use the BuildKit secret mount instead.

What about Docker Swarm secrets?

Swarm secrets are a runtime pattern — the secret is mounted into the container at runtime as a file in /run/secrets/. The Dockerfile never sees the value. This is the right shape for Swarm services, but the question of “how do I get a secret into a container that is not in Swarm” still comes up, and the answer is the platform’s secret store.

Can I keep secrets in a .env file in the repo?

You can, the way you can keep your production password in a public Google Doc. The .env file ends up in git history the first time someone commits it, and git history is forever. Use a real secret store. The platform’s vault is the right home; the local .env is the right home for local dev secrets only, and even then it should be gitignored.

How do I audit a Docker image for leaked secrets?

docker history --no-trunc <image> shows the values of ARG and ENV in every layer. For secrets that may be in a layer but not in a field (a token downloaded by a RUN step, for example), docker save <image> | tar -xO and grep the result for known prefix patterns. This is slow; the right answer is to never put the secret in the image in the first place.

Should I use a third-party vault like HashiCorp Vault?

If the platform does not have a built-in secret store, yes. The trade-off: another piece of infrastructure to run, another auth flow, another set of credentials to rotate. The platform’s built-in store is the right answer for 90% of teams; the dedicated vault is the right answer for the 10% that have audit requirements the platform cannot meet.

#Docker#Secrets#Security#Build Args#Container Security