Keeping Secrets Out of Images
Keeping secrets out of Docker images means never putting passwords, API tokens, private keys, certificates, or production configuration values into a Dockerfile, copied file, build argument, image label, or committed layer. It matters because images are designed to be cached, shared, inspected, pushed to registries, and reused. Once a secret is baked into an image layer, deleting it later is usually too late.
Overview: How secret leaks happen
A Docker image is a read-only template made from stacked filesystem layers plus metadata. Every Dockerfile instruction creates or contributes to image history. A container is a running or stopped instance of that image with a thin writable layer, mounts, runtime settings, and a process. If a secret is added while building the image, it can survive in the layer store, build cache, image history, exported archives, registry blobs, or derived images even when the final filesystem no longer appears to contain the file.
This is the part that surprises many beginners: RUN rm secret.txt does not rewrite the previous layer that copied secret.txt. Docker layers are immutable. A later layer can hide or delete a path in the final view, but the earlier layer may still exist as a blob addressable by digest. Anyone with the image, registry access, build cache access, or enough Docker daemon access may be able to inspect image history or recover layer contents.
Secrets also leak through metadata. ENV API_TOKEN=... stores a value in image configuration. ARG TOKEN=... may appear in build history or provenance depending on how it is used. LABEL values are metadata. Even command examples can leak through shell history, CI logs, and build output. The practical rule is simple: if the value must remain secret, it should not be part of the build context, Dockerfile text, image metadata, or a normal build layer.
Use runtime delivery for runtime secrets and BuildKit secret mounts for build-time secrets. Runtime delivery means the application receives a secret when the container starts, commonly as a file mounted at /run/secrets/name through Docker Compose, Swarm, Kubernetes, or a cloud platform. Build-time delivery means BuildKit temporarily mounts a secret for one RUN instruction so the build can download private dependencies without committing the credential to an image layer.
Syntax
docker build --secret id=SECRET_ID,src=FILE -t IMAGE:TAG .
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=SECRET_ID COMMAND
services:
app:
image: IMAGE:TAG
secrets:
- SECRET_NAME
secrets:
SECRET_NAME:
file: ./secret-file.txt
| Form | Use it for | Why it helps |
|---|---|---|
docker build --secret id=name,src=file |
Build-time credentials | Passes a local file to BuildKit without placing it in the build context or image layer. |
RUN --mount=type=secret,id=name |
One Dockerfile build step | Makes the secret available only during that RUN; the mount disappears before the layer is committed. |
secrets: in Compose |
Runtime configuration | Mounts secret files into selected services, usually under /run/secrets/. |
.dockerignore |
Build context control | Prevents local secret files from being sent to the builder accidentally. |
--build-arg |
Non-secret build settings | Useful for versions or feature toggles, but not appropriate for credentials. |
Examples
Example 1: Block local secret files from the build context
mkdir -p secrets
printf 'changeme\n' > secrets/api_token.txt
printf 'secrets/\n*.pem\n.env\n' > .dockerignore
Output:
Created secrets/api_token.txt and .dockerignore entries that keep common secret files out of the build context.
The build context is the directory tree Docker sends to the builder when you run docker build. If secrets/api_token.txt is inside the context and not ignored, it can be copied accidentally by a broad COPY . .. A tight .dockerignore is the first defense because files that never enter the context cannot be added to an image by mistake.
Example 2: Use a BuildKit secret for a private download step
# syntax=docker/dockerfile:1
FROM alpine:3.20
RUN --mount=type=secret,id=api_token \
sh -c 'test -s /run/secrets/api_token && echo "private dependency step completed" > /build-result.txt'
CMD ["sh", "-c", "cat /build-result.txt"]
Build and run the image:
docker build --secret id=api_token,src=secrets/api_token.txt -t build-secret-demo:1.0 .
docker run --rm build-secret-demo:1.0
Output:
private dependency step completed
The secret is visible only as /run/secrets/api_token during that single RUN instruction. The final image contains /build-result.txt, but it does not contain the secret file. In a real build, this pattern is useful for downloading private packages, authenticating to a private source repository, or reading a license key needed during compilation.
Example 3: Pass a runtime secret with Docker Compose
services:
app:
image: alpine:3.20
command: sh -c 'test -s /run/secrets/api_token && echo token-mounted'
secrets:
- api_token
secrets:
api_token:
file: ./secrets/api_token.txt
docker compose up --abort-on-container-exit
Output:
app-1 | token-mounted
app-1 exited with code 0
This example delivers the token at container start, not image build time. The image remains generic and shareable. Compose mounts the secret into the service container, and the command verifies that the file exists without printing the value. Many production platforms follow the same idea, even when the exact implementation differs.
How it works step by step
- The client prepares the build context. Docker reads
.dockerignoreand excludes matching files before sending the context to the builder. - The Dockerfile is evaluated layer by layer. Instructions such as
COPY,RUN, andENVcontribute filesystem changes or metadata. Changing one layer can invalidate that layer and all layers after it. - BuildKit handles secret mounts specially. A secret passed with
docker build --secretis not treated as a normal context file. It is mounted into the build container only for theRUN --mount=type=secretstep that asks for it. - The layer is committed after the command exits. Files written by the command can become part of the layer, but the secret mount itself is not committed. Your command must still avoid copying the secret into another path or printing it.
- The registry stores image artifacts. When pushed, a registry stores the manifest, config object, and compressed layer blobs. Anything baked into those artifacts can travel with the image.
- Runtime secrets are mounted separately. Compose, Swarm, or an orchestrator attaches a secret file to the container at start time. Removing the container removes that runtime mount; the image itself does not change.
Common Mistakes
Copying a secret and deleting it later
FROM alpine:3.20
COPY secrets/api_token.txt /tmp/api_token.txt
RUN rm /tmp/api_token.txt
CMD ["sh"]
This is wrong because the COPY instruction creates a layer that contains the file. The later RUN rm creates another layer that hides it in the final filesystem view, but it does not erase the previous layer blob. Fix it by keeping secrets/ out of the build context and using BuildKit --secret when the build needs a credential.
Using ENV or ARG for credentials
FROM alpine:3.20
ARG API_TOKEN=<YOUR_API_TOKEN>
ENV API_TOKEN=<YOUR_API_TOKEN>
RUN echo "configured" > /status.txt
This stores sensitive-looking data in places that can be inspected or preserved in build metadata. ARG is acceptable for non-secret settings such as APP_VERSION; ENV is useful for ordinary runtime defaults. Neither is a safe place for real credentials.
Printing secrets during a build
RUN --mount=type=secret,id=api_token sh -c 'cat /run/secrets/api_token'
Even with BuildKit, printing the secret can leak it into terminal output, CI logs, build logs, or support transcripts. Test presence with test -s, authenticate directly with the tool that needs the secret, and keep command output quiet.
Relying on latest while investigating a leak
docker build -t registry.example.com/team/app:latest .
latest is only a tag, not an immutable version. During a secret incident, moving tags make it harder to identify exactly which image bytes were built, pushed, or deployed. Use specific release tags such as registry.example.com/team/app:1.8.2 and record digests for production deployments.
Best Practices
- Add secret-like paths to both
.gitignoreand.dockerignore, including.env,secrets/,*.pem, and local credential files. - Use BuildKit secret mounts for build-time credentials; do not use
ARG,ENV, or copied files for secrets. - Use Docker Compose secrets, Swarm secrets, Kubernetes Secrets with appropriate controls, or a cloud secret manager for runtime credentials.
- Grant each service only the secrets it needs. Avoid one shared file containing every production credential.
- Keep Dockerfile output quiet. Never
echo,cat, or log a secret for debugging. - Pin base image tags such as
alpine:3.20ornode:20-alpine; avoidlatestin reproducible builds and production release processes. - Use multi-stage builds so compilers, package-manager credentials, caches, and source checkout details stay out of the final runtime image.
- Scan images and review
docker historywhen you suspect a leak, then rotate the credential. Do not assume deleting a file from a later layer fixed the exposure. - Treat Docker daemon access as highly privileged. A user who can build, run, inspect, or export images may be able to access sensitive artifacts.
Practice Exercises
- You find a Dockerfile that runs
COPY . .and the project has a local.envfile. Update the ignore files so the value cannot enter Git or the Docker build context. Hint: protect both source control and the build context. - Write a Dockerfile step that uses a BuildKit secret named
npm_tokento prove a token file exists without printing it. Expected end state: the final image contains a harmless marker file, not the token. - A teammate wants to use
ENV DB_PASSWORD=changemein a production image. Explain what metadata and layer risks this creates, then choose a runtime secret delivery method for a Compose deployment.
Summary
- Docker images are layered, cached, inspected, and shared, so secrets do not belong in Dockerfiles or build contexts.
- Deleting a secret in a later layer does not remove it from an earlier layer.
ARG,ENV, labels, build logs, and shell history are unsafe places for real credentials.- Use BuildKit
--secretandRUN --mount=type=secretfor build-time secrets. - Use runtime secret mounts from Compose, Swarm, Kubernetes, or your deployment platform for application credentials.
- When a secret enters an image or log, rotate it and rebuild from a clean source, then push a new immutable release.
