How Docker layers actually work at the filesystem level

A Docker image is not a flat filesystem. It is a stack of read-only layers, where each layer represents a filesystem diff produced by one Dockerfile instruction. When you run docker build, every RUN, COPY, and ADD instruction creates a new layer containing only the files that changed.

These layers are stored as tarballs and addressed by content hash (SHA-256). The image manifest is a JSON file that lists layer hashes in order. The Docker daemon combines them using a union filesystem (OverlayFS on most Linux hosts) to present a single coherent view. A file that is "deleted" in layer N is hidden from the union view β€” but the original content still exists, verbatim, in the layer tarball for layer N-1.

Dockerfile β€” the pattern that leaks secrets dockerfile
FROM python:3.12-slim

# Layer 1: base packages
RUN apt-get update && apt-get install -y git

# Layer 2: secrets copied in  ← THE PROBLEM
COPY .env /app/.env
COPY credentials.json /root/.aws/credentials

# Layer 3: use the secrets to install private packages
RUN pip install -r requirements-private.txt

# Layer 4: "clean up" β€” this does absolutely nothing to remove secrets
RUN rm /app/.env /root/.aws/credentials

COPY . /app/
CMD ["python", "-m", "app"]

Critical misunderstanding: The RUN rm in Layer 4 creates a whiteout file in OverlayFS that hides the path from the union view. The bytes of .env are still intact and readable inside the Layer 2 tarball. This is not an edge case β€” it is documented, intended behavior.

Why "delete in a later layer" is the most dangerous Docker misconception

Stack Overflow answers from 2016-2020 frequently suggested this cleanup pattern as a solution for keeping images small. The advice was wrong about security even then. The confusion persists because it works visually: running docker exec into the container, you genuinely cannot see the deleted files. The union filesystem hides them. But the image tarball still contains every layer unchanged.

Consider the operational reality: you build the image, push it to ECR, and your CI pipeline scans the running container for secrets. The scanner finds nothing β€” because the union view hides the deleted files. The image gets a green tick and ships to production. Meanwhile, the ECR repository contains every layer. Any engineer with ecr:GetDownloadUrlForLayer permission can extract the raw layer bytes.

Proof: layer content survives rm shell
# Save the image as a tarball
$ docker save myapp:latest -o myapp.tar

# The tarball is a nested structure: manifest.json + per-layer tarballs
$ tar tf myapp.tar | head -20
manifest.json
repositories
sha256:3a7f8e.../
sha256:3a7f8e.../layer.tar     # Layer 1: base
sha256:b2c14d.../layer.tar     # Layer 2: .env was COPY'd here
sha256:9f1aa2.../layer.tar     # Layer 3: pip install
sha256:d43e8b.../layer.tar     # Layer 4: rm .env (whiteout only)

# Extract layer 2 and find the secret
$ mkdir layer2 && tar xf sha256:b2c14d.../layer.tar -C layer2
$ cat layer2/app/.env
DATABASE_URL=postgres://admin:[email protected]:5432/app
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
STRIPE_SECRET_KEY=sk_live_4eC39HqLyjWDarjtT1zdp7dc

Three commands. No special privileges. No exploits. The secrets are in plain text. This is the exact sequence an attacker runs after pulling the image, or after gaining read access to an S3-backed registry.

Extraction in practice: docker history, dive, and Skopeo

Three tools make this trivially accessible. You don't need to write code. You don't need root. You just need the image.

docker history β€” the quick check

docker history --no-trunc <image> prints every layer's creation command. ARG values and inline secrets passed via RUN are visible here in full, unredacted.

Terminal shell
$ docker history --no-trunc myapp:latest

IMAGE          CREATED BY
sha256:d43e8b  RUN /bin/sh -c rm /app/.env /root/.aws/credentials
sha256:9f1aa2  RUN /bin/sh -c pip install -r requirements.txt \
               --extra-index-url https://token:[email protected]/simple/
sha256:b2c14d  COPY .env /app/.env
sha256:3a7f8e  RUN apt-get update && apt-get install -y git

dive β€” interactive layer explorer

dive is an open-source tool that lets you interactively browse each layer's filesystem diff. It makes extracting secrets from any layer point-and-click: browse to the layer where .env was added, and its content is right there in the file tree pane. CI integration is available via CI=true dive <image>.

Skopeo β€” registry-level extraction without docker pull

Skopeo can copy registry images directly to disk in OCI layout without a Docker daemon. This matters because an attacker with registry read credentials (leaked in a CI log, for example) can extract every layer without ever running the container:

Terminal shell
# Copy image from ECR to local OCI directory without docker pull
$ skopeo copy \
  docker://123456789.dkr.ecr.eu-west-1.amazonaws.com/myapp:latest \
  oci:/tmp/myapp-oci

# Navigate layer blobs directly
$ ls /tmp/myapp-oci/blobs/sha256/
3a7f8e...  b2c14d...  9f1aa2...  d43e8b...

# Extract the layer containing secrets
$ tar xf /tmp/myapp-oci/blobs/sha256/b2c14d... -C /tmp/extracted
$ cat /tmp/extracted/app/.env

Registry exposure: public ECR and Docker Hub repositories

The blast radius of this problem scales with registry accessibility. A private registry with tight IAM controls limits exposure to internal attackers or credential-theft scenarios. A public Docker Hub repository exposes every layer to the entire internet.

GitHub Actions workflows frequently push images to Docker Hub or public ECR repositories. The combination of "we made the repo public for easier deployment" and "we once copied a .env into a layer" produces a public credential exposure. Researchers scanning Docker Hub for exposed secrets have found AWS keys, database credentials, and API tokens in images from well-known companies, all extracted via the docker save method above.

If you have ever pushed an image with secrets in a layer to a public registry, assume those secrets are compromised. Registry logs do not record layer-level pulls. You cannot know if someone has already extracted them. Rotate all credentials immediately before fixing the build process.

ARG and ENV variable leakage via docker history

Even without copying a secrets file, passing secrets as build arguments or environment variables leaks them into layer metadata β€” visible via docker history --no-trunc forever.

Dockerfile β€” ARG leakage dockerfile
FROM node:20-alpine

# ARG values are baked into layer metadata
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc && \
    npm install && \
    rm ~/.npmrc

# The RUN layer's creation command in docker history will show:
# /bin/sh -c echo "//registry.npmjs.org/:_authToken=npm_AbCdEf123..." > ~/.npmrc

The rm ~/.npmrc at the end of the same RUN instruction is the correct pattern β€” it keeps the file creation and deletion in a single layer, preventing file-level persistence. But the ARG value is still recorded verbatim in the layer's creation command metadata, readable via docker history. This is a separate, lesser-known leak vector.

Docker BuildKit secret mounts were introduced precisely to solve this. They provide a tmpfs mount at build time that is never committed to any layer and does not appear in history. Use them.

Patterns that actually prevent secrets from entering layers

Option 1: BuildKit secret mounts (recommended)

BuildKit's --mount=type=secret syntax provides a temporary, in-memory mount point available only during a single RUN instruction. The secret is never written to any layer, does not appear in docker history, and cannot be extracted by any image inspection tool.

Dockerfile β€” BuildKit secret mount dockerfile
# syntax=docker/dockerfile:1
FROM python:3.12-slim

RUN --mount=type=secret,id=pip_token \
    pip install \
      --extra-index-url "$(cat /run/secrets/pip_token)" \
      -r requirements.txt
Build command shell
# Secret is passed at build time, never stored in any layer
$ DOCKER_BUILDKIT=1 docker build \
  --secret id=pip_token,env=PIP_EXTRA_INDEX_URL \
  -t myapp:latest .

Option 2: Multi-stage builds to exclude the secret layer entirely

In a multi-stage build, the final image is copied from an intermediate builder stage. If the secret-using operations happen only in the builder stage and only non-secret artifacts are COPY --from=builder'd into the final stage, the secret never reaches the final image at all.

Dockerfile β€” multi-stage isolation dockerfile
# Stage 1: builder (never pushed to registry)
FROM python:3.12-slim AS builder
ARG PIP_EXTRA_INDEX_URL
RUN pip install --prefix=/install -r requirements.txt

# Stage 2: final image β€” contains only the installed packages, not ARGs
FROM python:3.12-slim
COPY --from=builder /install /usr/local
COPY . /app/
CMD ["python", "-m", "app"]

Scanning container images in CI before they reach the registry

The fix is preventive: scan images for secrets before pushing to the registry. Several tools can do this as a CI gate.

  • Trivy (trivy image --scanners secret myapp:latest) β€” scans layer content for secret patterns using a regex-based ruleset. Runs against the local daemon or an OCI archive.
  • Grype combined with Syft β€” Syft generates an SBOM with file hashes; Grype checks vulnerabilities. Neither scans for secrets natively, so combine with a dedicated secrets scanner.
  • AquilaX container scan β€” scans all image layers for secrets, PII, misconfigurations, and known CVEs in a single pipeline step, reporting before push.
.github/workflows/build.yml β€” gate on secret scan yaml
- name: Build image
  run: docker build -t myapp:{{ github.sha }} .

- name: Scan image for secrets
  run: |
    trivy image \
      --scanners secret \
      --exit-code 1 \
      --severity HIGH,CRITICAL \
      myapp:{{ github.sha }}

# Push only happens if the scan step exits 0
- name: Push to registry
  run: docker push myapp:{{ github.sha }}

"A container image pushed to a registry is immutable and permanently addressable. Every secret that ever entered any layer is preserved exactly as written β€” regardless of how many cleanup layers you stack on top. The only safe fix is to ensure secrets never enter a layer in the first place."

Remediation checklist

  • Audit existing images with docker save | tar xf | grep -r SECRET_PATTERN or a secrets scanner before deciding what to rotate.
  • Migrate all secret-passing patterns to BuildKit --mount=type=secret.
  • Use multi-stage builds so the build-time environment (with credentials) is never part of the pushed image.
  • Never pass real credentials via ARG β€” even for "temporary" builds.
  • Add a Trivy or AquilaX secrets scan as a CI gate before every registry push.
  • If a compromised image was ever public, rotate all credentials it touched immediately.

Scan your container images before they reach production

AquilaX scans every layer of your Docker images for secrets, PII, malware, and CVEs β€” blocking exposure before it reaches your registry.

See container scanning β†’