My Dockerfile had a line I was proud of: RUN rm -f .env .npmrc. Cleanup, right? I ran docker run --rm myapp ls -a, saw no .env, and shipped it.
Months later I wrote a 12-line script to scan every image I had pushed to my private registry. 3 of 14 still contained a full .env file, API keys and all. The rm had worked perfectly. It just didn't do what I thought it did, because of how Docker image layers store deleted files.
TL;DR
- Every
COPY,ADDandRUNin a Dockerfile creates a layer, and a layer is a diff. Deleting a file in a later layer adds a "whiteout" marker. The original bytes stay in the earlier layer. -
docker run ... lsshows the merged view, so the file looks gone.docker saveplustarshows it's still there. - Build args are a second leak: values passed with
--build-argand used inRUNshow up indocker history --no-trunc. - Fix: a
.dockerignorethat excludes.env*and.git, plus BuildKit secret mounts (RUN --mount=type=secret) for anything a build step needs. - If a secret already shipped in a pushed image, rotate it. Deleting the tag is not enough.
Why doesn't RUN rm remove a file from a Docker image?
RUN rm can't remove a file that an earlier layer added, because layers are append-only. Each instruction records only what changed compared to the layer below it. Deleting a file is itself a change, so Docker records it as a new entry in the new layer rather than editing the old one.
Here is the Dockerfile that bit me, trimmed down:
FROM node:20-slim
WORKDIR /app
COPY . .
RUN npm ci && npm run build
RUN rm -f .env .npmrc
CMD ["node", "dist/server.js"]
That produces (among others) three layers I care about:
-
COPY . .adds the whole project directory, including.envand.npmrc. -
RUN npm ci && npm run buildaddsnode_modulesanddist. -
RUN rm -f .env .npmrcadds two tiny files namedapp/.wh..envandapp/.wh..npmrc.
Those .wh. files are whiteouts. When the container filesystem is assembled, the storage driver sees a whiteout and hides the matching file from lower layers. Your running container never sees .env. But layer 1 is shipped, pulled and cached exactly as it was built, secrets included.
The mental model that fixed it for me: a Docker image is a git history, not a folder. git rm doesn't remove a password from old commits either.
How do I check if my Docker image layers contain a .env file?
Export the image with docker save, unpack it, and list the contents of every layer tarball. Anything a later layer deleted will still appear in the layer that added it.
docker save myapp:latest -o myapp.tar
mkdir -p out && tar -xf myapp.tar -C out
# Newer Docker writes OCI layout (blobs/sha256/*),
# older versions write <id>/layer.tar. Check both.
for blob in out/blobs/sha256/* out/*/layer.tar; do
[ -f "$blob" ] || continue
hits=$(tar -tf "$blob" 2>/dev/null | grep -E '(^|/)\.(env|npmrc)(\..*)?$')
[ -n "$hits" ] && echo "== $blob" && echo "$hits"
done
Some blobs are JSON configs and manifests, not tarballs, which is why tar errors are sent to /dev/null. On my worst image the output looked like this:
== out/blobs/sha256/9f3c...
app/.env
app/.npmrc
== out/blobs/sha256/41aa...
app/.wh..env
app/.wh..npmrc
The first blob is the COPY layer with the real files. The second is the rm layer with the whiteouts. To prove it's not a false alarm, print the file straight out of the layer:
tar -xOf out/blobs/sha256/9f3c... app/.env
That printed my production database URL. Anyone who can docker pull that image can run the same three commands.
If you want something visual, dive myapp:latest shows the file tree of each layer and highlights files that were added and later removed. It's the fastest way to spot "why is this layer 40 MB" problems too, which is usually how people discover this in the first place.
Can docker history leak secrets from build args?
Yes. When a RUN instruction uses a build arg, the arg's value gets recorded in the image history, and docker history --no-trunc prints it.
This pattern looks safe because the .npmrc is created and deleted inside a single RUN, so it never lands in a layer:
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc \
&& npm ci \
&& rm .npmrc
The file is handled correctly. The token is not:
docker history --no-trunc myapp:latest | grep NPM_TOKEN
# RUN |1 NPM_TOKEN=npm_a1b2c3... /bin/sh -c echo "//registry...
The |1 NPM_TOKEN=... prefix is the build arg baked into the history metadata. Same problem with ENV: anything set with ENV API_KEY=... lives in the image config and shows up in docker inspect.
So there are three places a secret hides in an image, and rm only touches one of them:
| Where it hides | How it got there | How to see it |
|---|---|---|
| Layer tarball |
COPY/ADD, or a RUN that wrote it |
docker save + tar -tf, or dive
|
| Image history |
ARG used in a RUN
|
docker history --no-trunc |
| Image config | ENV |
docker inspect |
How do I pass secrets to a Docker build safely?
Use a BuildKit secret mount. It makes a file available to one RUN step at build time without writing it to any layer, the history, or the config.
# syntax=docker/dockerfile:1
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .
During that one RUN, /root/.npmrc exists. After it, nothing is left behind, no whiteout and no history entry with the token. BuildKit is the default builder in modern Docker, so this usually works without extra flags. The # syntax line pins the Dockerfile frontend so --mount is understood.
Runtime secrets are a separate problem with a simpler answer: they don't belong in the image at all. Pass them when the container starts, with docker run --env-file, Compose env_file, or your orchestrator's secret store.
What should a .dockerignore include to stop leaking secrets?
At minimum, exclude env files, credential files and the .git directory. COPY . . copies everything in the build context that .dockerignore doesn't exclude.
.env
.env.*
!.env.example
.npmrc
.git
*.pem
node_modules
.git matters more than people think. Even if you deleted a secret from your working tree, your git history may still hold it, and COPY . . ships that history into a layer. It's the same layering problem, one level down.
.dockerignore is also the cheapest fix because it works before anything reaches a layer. The other fixes are about controlling what goes in. This one stops the file from ever being sent to the builder.
Does a multi-stage build hide secrets?
Partly. Only the final stage's layers end up in the shipped image, so a secret used in a builder stage won't be in the image you push, as long as you only COPY --from=builder the build output.
Two catches. If you copy a whole directory from the builder (COPY --from=builder /app /app), you copy the secret along with it. And the builder stage's layers still exist in the build cache on whatever machine ran the build, including CI runners. Multi-stage is good for image size. For secrets, use secret mounts and treat multi-stage as a second line of defense.
I already pushed an image with a secret in it. Now what?
Rotate the secret first. Deleting or overwriting the tag doesn't remove the layer from anyone who already pulled it, and many registries keep unreferenced blobs until garbage collection runs.
My order of operations for the 3 bad images:
- Rotate every credential in the leaked files. That took the longest and mattered most.
- Add
.dockerignoreand switch to secret mounts. - Rebuild and push clean images.
- Delete the old tags and trigger registry garbage collection where I could.
- Add the scan loop above to CI so any
.envin any layer fails the build.
Step 5 is 12 lines of bash and it's the one I wish I had written first.
So why didn't RUN rm delete my .env?
Because Docker image layers are append-only diffs. RUN rm adds a whiteout file in a new layer that hides .env from the running container, but the earlier COPY layer still contains the original file, and anyone with the image can extract it with docker save and tar. To keep secrets out of a Docker image, stop them from entering any layer: exclude them with .dockerignore, pass build-time secrets with RUN --mount=type=secret, never put them in ARG or ENV, and rotate anything that has already been pushed.
Written by the developer behind Preterview, an interview prep platform.
Top comments (0)