Secrets Deleted in a Later Docker Layer Still Live in History
You deleted the .env from your Docker image. It's still in there.
A Docker image isn't a single filesystem — it's a stack of read-only layers, one per build instruction. Every RUN, COPY, and ADD adds a new layer on top of the last. When a later layer "deletes" a file, it only writes a whiteout marker that hides the file in the merged view. The bytes from the earlier layer are never touched. The secret is still sitting there, one command away from anyone who has the image.
Why "delete" doesn't erase
Layers are immutable and content-addressed. This Dockerfile looks safe — it copies a credentials file, uses it, then removes it:
COPY .env /app/.env
RUN some-tool --config /app/.env && rm /app/.env
The rm runs in the same layer, so the merged image shows no .env. But if the secret was ever written to its own layer (a COPY .env, an ARG/ENV value, or a file created earlier and deleted later), that layer still contains it. A whiteout in a newer layer hides it; it does not delete it.
ENV and build ARG are worse: their values are stored in the image's metadata, readable straight from docker history or docker inspect — no unpacking required.
Extracting it takes one command
You don't need special tooling. Save the image to a tarball and look inside:
docker save myimage:latest -o image.tar
tar -xf image.tar # each blob is a layer's filesystem
docker export (a running container) shows the merged view, so deleted files look gone. docker save exports every layer separately, so whiteout-hidden files reappear. Tools like dive surface the same thing interactively. Treat any image that ever touched a secret as already leaked.
The fix: never bake secrets into a layer
| Anti-pattern | Why it leaks | Use instead |
|---|---|---|
COPY .env then rm | earlier layer keeps the file | RUN --mount=type=secret |
ARG TOKEN=... / ENV TOKEN=... | stored in image metadata | build secret mount |
download key, delete in later RUN | earlier layer keeps the key | build secret or runtime injection |
BuildKit build secrets mount the value into the build container only for the duration of that one instruction — it never lands in a layer or in metadata:
docker build --secret id=aws,src=$HOME/.aws/credentials .
RUN --mount=type=secret,id=aws \
AWS_SHARED_CREDENTIALS_FILE=/run/secrets/aws \
aws s3 cp ...
For secrets your app needs at runtime (not build time), don't put them in the image at all — inject them at docker run via a secrets manager or orchestrator secret, so the image stays clean.
Power moves
- Mount a secret as an env var, scoped to one step:
RUN --mount=type=secret,id=aws-key-id,env=AWS_ACCESS_KEY_ID ...gives you the ergonomics ofENVwithout persisting the value. - Audit before you ship:
docker history --no-trunc <image>reveals every instruction and any secret baked intoENV/ARG. Run it in CI as a gate. - Already shipped a baked secret? Rebuilding won't help — the old image is out there. Rotate the credential. A leaked secret is only fixed by revoking it.
- Squashing is not a fix. Flattening layers hides the history but the secret still sits in the squashed filesystem if it was ever written to disk. Only build-time secret mounts keep it off disk entirely.
Resources
- Build secrets — Docker docs (
RUN --mount=type=secret) docker historyreferencedocker savereference- How attackers use
docker historyto recover deleted credentials — AquilaX
Shipping AI or cloud systems? Yeda AI audits and hardens production LLM and container pipelines. Talk to us · Read the blog