How to keep secrets out of Docker images (.env, ARG, ENV and docker history)

Three common ways a secret ends up inside an image, how to check yours, and the fixes: a .dockerignore file and BuildKit secret mounts.

Anyone who can pull a Docker image can read every file in every layer and every build argument recorded in its history. Secrets usually get in one of three ways: a .env file copied by COPY . ., a key passed with ARG or ENV, or a credentials file written during a RUN step. Here is how to check an image you already built, then how to build it without the secret.

1. If the image was already pushed to a registry

Treat the secret as leaked. Anyone who pulled the image, and every cache and mirror it passed through, has a copy, and deleting the tag does not reach those. Revoke the key at the provider first (see what to do when you leak an API key), then rebuild with the fixes below, push the clean image, and delete the old tags and digests from the registry.

2. Check an image for secrets

Build arguments and environment variables are recorded in the image history. --no-trunc shows each step in full:

docker history --no-trunc my-image

Environment variables set with ENV are also in the image config, and every container started from the image gets them:

docker image inspect --format '{{json .Config.Env}}' my-image

For files, listing the final filesystem is not enough. A file deleted in a later step still exists in the earlier layer. To list what each layer contains, save the image to a tar archive and look inside. On current Docker Desktop the archive is in OCI layout, with each layer stored under blobs/sha256/:

mkdir image
docker save my-image -o image.tar
tar -xf image.tar -C image
for f in image/blobs/sha256/*; do tar -tf "$f" 2>/dev/null | grep -E '(^|/)\.env' | sed "s|^|$f: |"; done

Any line that prints is a .env file stored in that layer. Delete image and image.tar when you are done, because they now contain the secret too. Tools such as dive show the same thing layer by layer, interactively.

3. The .env file copied by COPY . .

COPY . . copies the whole build context, including .env, .env.local and .git. Deleting the file afterwards does not help:

FROM node:22
WORKDIR /app
COPY . .
RUN rm -f .env

The RUN rm step only adds a new layer that marks .env as deleted. The COPY layer below it still contains the file with its contents, and anyone can extract it with the commands in step 2. In a test build of exactly this pattern, the original .env was readable from the saved image.

The fix is to keep the file out of the build context with a .dockerignore file in the root of the context, next to the Dockerfile in most projects:

.git
**/.env
**/.env.*
!**/.env.example

Patterns are matched from the root of the build context, so a plain .env line only excludes the file at the top level. ** matches any number of directories, including none, so **/.env also covers api/.env. The ! line keeps the example file with placeholder values. Excluding .git keeps your commit history, and any secrets in it, out of the image as well.

If you use several Dockerfiles, Docker also reads an ignore file named after the Dockerfile, such as build.Dockerfile.dockerignore, which takes precedence over .dockerignore.

A tighter habit is to copy only what the image needs (COPY package.json package-lock.json ./ and COPY src ./src) instead of the whole directory.

4. ARG and ENV secrets show up in docker history

Passing a token as a build argument looks private, because it is not in the Dockerfile:

FROM busybox
ARG API_TOKEN
ENV API_TOKEN=$API_TOKEN
RUN echo "token length ${#API_TOKEN}" > /tmp/x
docker build --build-arg API_TOKEN=sk_test_ARGSECRET -t my-image .

But docker history --no-trunc my-image prints the value in plain text:

RUN |1 API_TOKEN=sk_test_ARGSECRET /bin/sh -c echo "token length ${#API_TOKEN}" > /tmp/x # buildkit
ENV API_TOKEN=sk_test_ARGSECRET
ARG API_TOKEN=sk_test_ARGSECRET

An ARG on its own, without ENV, still leaks: every RUN step that uses it is recorded with the value, as in the RUN |1 API_TOKEN=... line above. ENV is worse because the value also stays in the image config and in every running container's environment. Recent versions of Docker warn about this during the build:

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ARG "API_TOKEN") (line 2)

Docker's own build documentation says the same thing: build arguments and environment variables are inappropriate for secrets because they persist in the final image.

5. The fix: BuildKit secret mounts

A secret mount makes the value available to a single RUN step and does not write it into a layer or the history. BuildKit is the default builder in Docker Desktop and Docker Engine, so this works with a plain docker build. Mount the secret as an environment variable for that one command:

FROM node:22
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=api_token,env=API_TOKEN \
    npm run fetch-assets

Pass the value from an environment variable in your shell, or from a file:

docker build --secret id=api_token,env=API_TOKEN -t my-image .
docker build --secret id=api_token,src=$HOME/.config/my-app/token -t my-image .

Without the env= option in the Dockerfile, the secret is mounted as a file at /run/secrets/<id>, here /run/secrets/api_token. Use target= to put the file where a tool expects it, for example a private npm registry token:

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci
docker build --secret id=npmrc,src=$HOME/.npmrc -t my-image .

In a test build, the secret was readable inside the RUN step, while docker history, docker image inspect and the running container showed neither the value nor /run/secrets.

Things to watch:

6. Runtime secrets belong outside the image

Many secrets in images are not needed at build time at all: the app reads them when it starts. Leave them out of the image and pass them when the container runs:

docker run --env-file .env my-image

In Compose, the env_file: key does the same. Values passed this way are still visible to anyone who can run docker inspect on the container, so on shared hosts and in production use your orchestrator's secrets support or a secrets manager. And keep that .env out of git as well: see how to prevent committing .env files to git.