Your Dockerfile Is Leaking Secrets - The Layer Trap 99% of Devs Miss

·Operation Risk·6 min read

Translated from the original Korean post. 한국어 원문 보기 →

Four Minutes, and It's Over

A coworker of mine burned an entire Friday rotating 14 production credentials by hand. The culprit was an API token baked into a Docker image — and he was convinced right up to the end that he had deleted it properly. The rm was right there in the Dockerfile.

That's the trap.

There's a stat floating around that the average time from a credential landing in a public Docker image to being actively exploited is 4 minutes. Not four hours. Not four days. Four minutes. Scanners crawl registries a lot faster than humans notice their own mistakes.

The root cause is simple. Most developers think of a Docker image like a zip file — unpack it and you get the final state. But that's not the structure. An image is closer to a stack of transparent sheets. Look down from the top and it looks clean. Peel the sheets apart and everything underneath is still sitting there.

The Mistake That Looks Perfect

Can you spot the problem in this Dockerfile?

FROM node:20-alpine

COPY . .
RUN npm install
RUN echo "$NPM_TOKEN" > ~/.npmrc && npm install && rm ~/.npmrc
CMD ["node", "server.js"]

At a glance it looks airtight. Create the token, use it, delete it at the end. Creation and cleanup both happen inside a single line, so surely nothing is left behind.

Docker doesn't work that way.

Every RUN instruction creates a new immutable layer. The layer capturing the moment .npmrc existed is frozen into the image. rm only removes the file from the final filesystem view. The layer beneath it — the instant the token was alive — is untouchable. Doing it all in one line buys you nothing here. Layers aren't cut at some sub-RUN boundary; one instruction becomes one whole layer.

How to Check in 30 Seconds

Seeing it beats being told. Run this against an image you've already built.

docker history --no-trunc my-app:latest

Or, more bluntly:

docker save my-app:latest | tar -xO | strings | grep -i "token\|secret\|password"

Take a minute with the output. You may not enjoy it. Anyone with pull access to your registry can lift your credentials straight out with stock Docker commands. No hacking tools, no special privileges.

Understanding the Layer Structure

Unroll the layers and it's obvious why this happens.

Layer 0: 베이스 이미지 (깨끗함)
Layer 1: COPY . . (소스 코드)
Layer 2: RUN npm install (node_modules 생성)
Layer 3: RUN echo $TOKEN > .npmrc (토큰이 여기서 동결됨)
Layer 4: rm ~/.npmrc (삭제는 새로운 레이어에서)
-----------------------------------------------
최종 이미지에는 .npmrc가 보이지 않지만, Layer 3은 그대로 존재

The delete doesn't erase the secret. It stacks one more layer on top that hides it from view. Layer 3 and Layer 4 are both permanently embedded in the image. The final view is clean; the entire past is preserved.

Think of Git history. Delete a password from a file, commit again, and it's still sitting in the earlier commit for anyone who looks. Image layers behave exactly the same way. A clean tip tells you nothing.

The Right Fix: BuildKit Secrets

Docker BuildKit ships --mount=type=secret for precisely this. The secret is mounted only for the duration of the build, the running process reads it normally, and no layer ever records it.

# syntax=docker/dockerfile:1
FROM node:20-alpine

RUN --mount=type=secret,id=npm_token \
    cp /run/secrets/npm_token ~/.npmrc && \
    npm install && \
    rm ~/.npmrc
CMD ["node", "server.js"]

Build it like this:

docker build \
  --secret id=npm_token,src=.npmrc \
  -t my-app:latest .

The key detail: /run/secrets is a temporary mount that exists only during the build. It lives outside the filesystem that gets committed into layers, and it's gone once the build finishes. Run docker history again and the token has vanished completely. Not buried under a layer — absent from the image manifest entirely. That distinction matters. Hidden and nonexistent are two very different security postures.

The Alternative: Multi-Stage Builds

If you'd rather not touch build flags, or you want a mental model that's easier to picture, multi-stage builds solve the same problem.

FROM node:20-alpine AS builder
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
RUN npm install

FROM node:20-alpine AS runner
COPY --from=builder /app/node_modules ./node_modules
COPY . .
CMD ["node", "server.js"]

Here's why it works. The final image is built from the runner stage alone. The builder stage used the token and wrote .npmrc, but not a single line of that stage's layer history is inherited by the final image. All runner pulls in are the artifact files you explicitly named in COPY --from. No secret, no history, no past.

One caveat worth stating plainly. The token you passed via ARG still lives in the builder stage's layers. It doesn't follow into the final image, but it's baked into the intermediate output. If your pipeline pushes CI caches or intermediate images separately, that side needs an audit too. For production, BuildKit secrets are the cleaner answer; multi-stage is the pragmatic compromise when reworking your build structure is too much to take on.

What to Check Right Now

  1. Start with the Dockerfile you deploy most often
  2. Look for RUN instructions containing "token", "key", "password", "secret"
  3. If you're not using --mount=type=secret, assume you're exposed
  4. Audit images you've already built with docker history --no-trunc

This isn't a theoretical risk. The 2023 CircleCI breach exposed build-environment secrets at scale and dragged thousands of teams into emergency credential rotation on a Sunday night. Security researchers still scrape public registries on a regular basis and pull credentials out of image layers. To an automated scanner, an exposed layer is free data.

BuildKit is on by default starting with Docker 23. On older versions, one environment variable before your build — DOCKER_BUILDKIT=1 — is all it takes. Either fix takes about 10 minutes to apply. Ten minutes to close an exposure that may have been open for months.

One last thing about the shape of this problem. That first Dockerfile wasn't badly written code. It had cleanup logic. Bundling creation and teardown into one line was reasonable reasoning. And it leaked anyway.

Separate the cause from the effect and it comes down to this: the code isn't wrong. The real cause is the gap between the developer's mental model and how Docker actually stores an image. We think of a filesystem as current state. Docker treats it as accumulated change history. That gap is invisible no matter how carefully you read the Dockerfile — right up until you crack the layers open.

The most dangerous thing in container security isn't the obvious mistake. It's the wrong model that appears to work. Everything's green while it quietly leaks underneath. Which is why I recommend running docker history once before pushing a new image. A 30-second check can save your Friday.

Was this post helpful?

One click helps me write the next one

#Docker#Security#Dockerfile#BuildKit#DevOps