Secrets in Docker images: build args, layers and checks
ARG and ENV values stay in a Docker image, and deleting a file in a later layer doesn't remove it. How to build with secret mounts and check an image.
Crunu teamPublished 7 min read
On this page
Secrets passed to a Docker build with ARG or ENV, or copied in as files, stay in the image. Docker's documentation says build arguments and environment variables "are inappropriate for passing secrets to your build" because "they persist in the final image." Deleting a file in a later step doesn't help either: each instruction adds a layer, and the earlier layer with the file is still part of the image that anyone who pulls it can unpack. Use BuildKit secret mounts for values the build needs, keep .env files out of the build context, and give the running container its secrets at start-up, not at build time.
This post shows how secrets get into images, how to check an image you already have, how to build without leaving anything behind, and how to deliver runtime values. For Compose-specific setup, see environment variables in Docker Compose.
How secrets end up in an image
There are three common routes, and all of them survive into the final image.
ARG used for a secret. A build argument such as --build-arg NPM_TOKEN=… is recorded with the build. Docker's build check for this case puts it directly: "setting secrets in a Dockerfile using ENV or ARG is insecure because they persist in the final image" (Docker: SecretsUsedInArgOrEnv).
ENV with a real value. An ENV DATABASE_URL=… line becomes part of the image's configuration. Every container started from the image has it, and anyone can read it without starting one.
A file copied in. COPY . . with a .env in the directory, or a COPY .npmrc for a private registry, writes the file into a layer.
The layer trap catches people who know about the last one:
# Doesn't work: the token is still in the layer created by COPY
COPY .npmrc /app/.npmrc
RUN npm ci
RUN rm /app/.npmrcLayers stack. The rm adds a new layer that hides the file from the container's filesystem, but the layer that added it is still in the image. Anyone who pulls the image can export it and read each layer separately.
How to check an image you already have
Three tools, from quick to thorough.
docker history. It shows the instruction behind each layer. The documentation describes the CREATED BY column as the "command that was used to create the image", and --no-trunc stops it from cutting the text off (Docker: image history). Build arguments used in RUN steps and ENV values show up here.
docker history --no-trunc payments-api:latest
docker image inspect payments-api:latest --format '{{json .Config.Env}}'The second command prints the environment baked into the image's configuration.
dive. "A tool for exploring each layer in a docker image" (dive on GitHub). Run dive payments-api:latest and step through the layers to see which files each one added or removed. A file that appears in one layer and disappears in the next is exactly the case above.
trivy. Trivy "scans any container image, filesystem, and git repository to detect exposed secrets", and secret scanning is on by default (Trivy: secret scanning).
trivy image payments-api:latestTwo caveats from its documentation: it "tries to detect a base image and skip those layers", and it skips some paths by default. It matches known patterns, so a random password without a recognizable shape may not be flagged. Treat a clean scan as "nothing obvious", not "nothing there".
If you find a secret, the image is the leak. Everyone who could pull it from the registry may have it, and so may every host and cache that stored it. Reissue the value at its provider first, then rebuild and delete the old tags. The key rotation guides cover the provider side.
Build without leaving secrets behind
Keep env files out of the build context. A .dockerignore file excludes files "from the build context", so they are never sent to the builder (Docker: build context). It goes in the root of the context:
.env
.env.*
!.env.example
.npmrc
*.pemUse secret mounts for values the build needs. BuildKit can mount a secret for a single RUN step. It's available to that command and isn't written to a layer. Docker's build check documentation says secret mounts "do not persist in the final image or its metadata."
# syntax=docker/dockerfile:1
FROM node:24-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
COPY . .docker build --secret id=npmrc,src=$HOME/.npmrc -t payments-api .By default a secret is mounted as a file at /run/secrets/<id>; the target option changes the path, and the env option exposes it as an environment variable for that step instead (Docker: build secrets). For private Git dependencies, Docker recommends SSH mounts with --ssh default.
Multi-stage builds help, with a caveat. With multiple FROM stages, Docker's documentation says, "you can selectively copy artifacts from one stage to another, leaving behind everything you don't want in the final image" (Docker: multi-stage builds). A secret used only in a builder stage doesn't reach the image you push, as long as you copy only the build output across. The builder stage still exists in the build cache on the machine that built it, and in any cache you export to a registry. Prefer a secret mount even inside a builder stage, and don't push build caches from stages that handled secrets to places with wider access than the secret itself.
Watch the build output. A secret mount keeps the value out of the image, but a command that prints it still writes it to the build log. Don't echo secrets, and check what verbose flags print.
Give the container its secrets when it starts
Runtime secrets, such as the database password, the payment key or the signing secret, don't need to be in the image. The image should be the same in staging and production, with the values supplied when the container starts.
The simplest runtime delivery is environment variables on docker run. Docker's documentation notes that --env VAR without a value takes the value from the caller's environment: "the Docker CLI client checks the value the variable has in your local environment and passes it to the container."
That combines well with an agent that holds the values. On a host enrolled in Crunu, start the container through crunu run, which puts the environment's variables into the Docker CLI's process environment, and name the variables to pass:
crunu run --env payments-api/production -- \
docker run --rm --env DATABASE_URL --env STRIPE_SECRET_KEY payments-api:latestThe values never touch a file on the host or a layer in the image. Be aware of what this doesn't hide: a container's environment is part of its configuration, and anyone who can run docker inspect on the host can read it. Docker's own documentation warns that "the docker group grants root-level privileges to the user" (Docker: Linux post-installation steps), so limit who has it.
In short-lived containers, such as CI jobs, the crunu agent can run inside the container instead. Its install script has a no-root mode tested in node:24, Ubuntu 24.04 and Alpine 3.22 images, and with a service token in CRUNU_TOKEN it signs in and decrypts inside its own process, with no state files. The service token then becomes the one secret the container receives, so give it only the environments that job needs, and bind it to the pipeline with OIDC. See Docker for the agent in containers and the glossary entry on runtime secret injection for the general pattern.
The NEXT_PUBLIC_ trap in Docker
Frameworks that inline public variables at build time break the "same image everywhere" rule. Next.js documents that if you "build and deploy a single Docker image to multiple environments, all NEXT_PUBLIC_ variables will be frozen with the value evaluated at build time" (Next.js: environment variables). That has two consequences. You need the public values during docker build, and anything with that prefix is in the image and in the JavaScript sent to browsers. Never put a secret under a public prefix to make it available at build time; the post on NEXT_PUBLIC_ leaks covers the details. The Next.js stack page shows which variables load when.
Checklist
Sources
- Build secrets, Docker documentation, checked October 30, 2026.
- SecretsUsedInArgOrEnv build check, Docker documentation, checked October 30, 2026.
- docker image history, Docker documentation, checked October 30, 2026.
- Build context, Docker documentation, checked October 30, 2026.
- docker container run, Docker documentation, checked October 30, 2026.
- Multi-stage builds, Docker documentation, checked October 30, 2026.
- Linux post-installation steps for Docker Engine, Docker documentation, checked October 30, 2026.
- dive on GitHub, checked October 30, 2026.
- Secret scanning, Trivy documentation, checked October 30, 2026.
- How to use environment variables in Next.js, Next.js documentation, checked October 30, 2026.