# 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.

URL: https://crunu.com/blog/secrets-in-docker-images
Language: en
Updated: 2026-10-30

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](https://crunu.com/environment-variables/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](https://docs.docker.com/reference/build-checks/secrets-used-in-arg-or-env/)).

**`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:

```dockerfile
# Doesn't work: the token is still in the layer created by COPY
COPY .npmrc /app/.npmrc
RUN npm ci
RUN rm /app/.npmrc
```

Layers 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.

*Figure: Diagram of where a secret goes once it is in a Docker image. Image layers: a later rm leaves the earlier layer intact; it stays after you delete it. Every registry it’s pushed to: and every mirror of it; it stays after you delete it. Image metadata: docker history shows each build step. Every host that pulls it: servers, CI runners and laptops; it stays after you delete it. Deleting the copy doesn’t undo the leak, so the value has to be reissued at its provider.*

## 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](https://docs.docker.com/reference/cli/docker/image/history/)). Build arguments used in `RUN` steps and `ENV` values show up here.

```sh
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](https://github.com/wagoodman/dive)). 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](https://trivy.dev/docs/latest/guide/scanner/secret/)).

```sh
trivy image payments-api:latest
```

Two 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](https://crunu.com/rotate) 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](https://docs.docker.com/build/concepts/context/)). It goes in the root of the context:

```text
.env
.env.*
!.env.example
.npmrc
*.pem
```

**Use 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."

```dockerfile
# 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 . .
```

```sh
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](https://docs.docker.com/build/building/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](https://docs.docker.com/build/building/multi-stage/)). 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:

```sh
crunu run --env payments-api/production -- \
  docker run --rm --env DATABASE_URL --env STRIPE_SECRET_KEY payments-api:latest
```

The 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](https://docs.docker.com/engine/install/linux-postinstall/)), so limit who has it.

*Figure: Example: on the server api-prod-1, crunu status shows a healthy machine holding version 15 of payments-api production with a lease until tomorrow. crunu run starts node server.js with that environment’s variables, which exist only in process memory; the web card shows the machine Active with the latest version.*

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](https://crunu.com/integrations/docker) for the agent in containers and the glossary entry on [runtime secret injection](https://crunu.com/learn/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](https://nextjs.org/docs/app/guides/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](https://crunu.com/blog/next-public-env-leaks) covers the details. The [Next.js stack page](https://crunu.com/environment-variables/nextjs) shows which variables load when.

## Checklist

- [ ] `.dockerignore` excludes `.env*`, `.npmrc` and key files
- [ ] No `ARG` or `ENV` with a secret value; the `SecretsUsedInArgOrEnv` build check passes
- [ ] Build-time credentials come from `RUN --mount=type=secret`
- [ ] `docker history --no-trunc` and `trivy image` are clean for every pushed tag
- [ ] Runtime secrets arrive when the container starts, not in the image
- [ ] Docker access on hosts is limited to people who should see container environments
- [ ] Any secret found in an image is reissued, and the old tags are deleted

## Sources

- [Build secrets, Docker documentation](https://docs.docker.com/build/building/secrets/), checked October 30, 2026.
- [SecretsUsedInArgOrEnv build check, Docker documentation](https://docs.docker.com/reference/build-checks/secrets-used-in-arg-or-env/), checked October 30, 2026.
- [docker image history, Docker documentation](https://docs.docker.com/reference/cli/docker/image/history/), checked October 30, 2026.
- [Build context, Docker documentation](https://docs.docker.com/build/concepts/context/), checked October 30, 2026.
- [docker container run, Docker documentation](https://docs.docker.com/reference/cli/docker/container/run/), checked October 30, 2026.
- [Multi-stage builds, Docker documentation](https://docs.docker.com/build/building/multi-stage/), checked October 30, 2026.
- [Linux post-installation steps for Docker Engine, Docker documentation](https://docs.docker.com/engine/install/linux-postinstall/), checked October 30, 2026.
- [dive on GitHub](https://github.com/wagoodman/dive), checked October 30, 2026.
- [Secret scanning, Trivy documentation](https://trivy.dev/docs/latest/guide/scanner/secret/), checked October 30, 2026.
- [How to use environment variables in Next.js, Next.js documentation](https://nextjs.org/docs/app/guides/environment-variables), checked October 30, 2026.
