You've done it. I've done it. We've all done it at 2am when CI is failing and the private npm registry just won't authenticate.
COPY .npmrc /root/.npmrc
RUN npm ci
RUN rm /root/.npmrc # it's fine, I deleted it
It's not fine. The credentials are still there. They will always be there.
Layers Are Forever (Or At Least Until You Squash Them)
Docker images are built in stacked layers. Every RUN, COPY, and ADD instruction creates a new one. When you delete a file in a subsequent layer, you're not removing it — you're adding a "whiteout" marker on top. The original data sits in the lower layer, completely intact, readable by anyone who runs:
docker history my-app:latest
docker save my-app:latest | tar xf - | grep -r "npm_token"
Or the tool that makes this painfully obvious:
dive my-app:latest
dive will walk you through every file added and removed in every layer. Including your .npmrc with the auth token. Including the SSH key you "only temporarily" added for that one private repo. Including the .env file someone thought was .dockerignored but wasn't — because they had a typo in the filename.
At Cubet we had a team member who added a private PyPI registry token as a --build-arg, hit a cache miss, and watched it print to plain text in the CI logs. The image was already pushed to the registry. Two hours of credential rotation across three environments followed. Great afternoon.
Enter BuildKit Secrets
BuildKit (Docker's build engine, enabled by default since Docker 23) has a --mount=type=secret feature that solves this properly. The secret is made available inside a single RUN step as a file at /run/secrets/<id>, and it is never included in any image layer. Not as a whiteout. Not in history. Not anywhere.
# syntax=docker/dockerfile:1.4
FROM node:20-alpine AS build
# This .npmrc exists only for the duration of this RUN step
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
COPY --from=build /app/dist ./dist
CMD ["node", "dist/index.js"]
Build it like this:
docker build \
--secret id=npmrc,src=$HOME/.npmrc \
-t my-app:latest .
That's it. The .npmrc exists for the duration of that one RUN command, then it's gone. dive finds nothing. docker history shows RUN --mount=type=secret... but not the file contents. The credential never touched a layer.
SSH Agent Forwarding for Private Repos
Private Git dependencies are the other classic footgun. The instinct is to copy in an SSH key:
# DON'T DO THIS — key ends up in a layer forever
COPY id_rsa /root/.ssh/id_rsa
RUN git clone [email protected]:your-org/private-lib.git
RUN rm /root/.ssh/id_rsa
BuildKit handles this with --mount=type=ssh:
# syntax=docker/dockerfile:1.4
FROM golang:1.22 AS build
RUN --mount=type=ssh \
go mod download
COPY . .
RUN go build -o /app ./cmd/server
Build it with your SSH agent loaded:
ssh-add ~/.ssh/id_rsa
docker build \
--ssh default \
-t my-go-app:latest .
The build container gets a socket to forward SSH auth through. Your private key never touches the image filesystem. Works cleanly for private Go modules, private Python packages, Composer private repos — anything that needs SSH auth during the build.
CI/CD Integration
This is where the payoff is obvious. In GitHub Actions:
- name: Build and push image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/your-org/your-app:latest
secrets: |
npmrc=${{ secrets.NPMRC_TOKEN }}
The docker/build-push-action handles the --secret flag for you. Your GitHub Actions secret goes directly into the BuildKit secret mount, never touches disk on the runner, never ends up in a layer, never appears in build logs. That's the whole chain locked down.
Wait, Doesn't .dockerignore Fix This?
It helps for COPY mistakes, but it's not sufficient on its own.
.dockerignore prevents files from being sent to the build context. If you never COPY .env into the image, it won't be there. But:
ARGvalues are visible indocker history— loggingRUN echo $SECRET_TOKENfor debugging will immortalize it in the layer.dockerignoreglob patterns are easy to get wrong; files sneak through- CI systems that inject credentials via
--build-argalso land them in history - Multi-stage builds where you
COPY --from=buildcan accidentally carry secrets across stages if you're not careful
BuildKit secrets sidestep all of this. The credential is never in the build context, never in the Dockerfile text, never in a layer.
Auditing Existing Images
Not sure whether your production images have credentials baked in? Check now:
# Install dive: github.com/wagoodman/dive
dive your-image:tag
# Or the manual layer inspection approach
docker save your-image:tag | tar xO --wildcards '*/layer.tar' 2>/dev/null \
| tar tv 2>/dev/null \
| grep -E '\.(env|npmrc|pem|key|cfg|token)'
If you find something, rotate the credential immediately — before you fix anything else. Then fix the Dockerfile.
The Pattern to Follow
- Never use
ARGfor secrets.ARGends up indocker history. - Never copy credential files and delete them in a later layer. Layers are immutable.
- Use
--mount=type=secretfor file-based credentials — tokens,.npmrc, pip config, service account keys. - Use
--mount=type=sshfor SSH-authenticated operations — private repos, private Composer packages, anything that needsgit cloneover SSH. - Audit with
diveafter any Dockerfile change that touches credentials.
BuildKit's secret mounting has been production-ready for years, but it's one of those features that only surfaces after you've already made the expensive mistake. The Docker docs cover it, but nobody reads the docs until something burns.
If your containers touch private infrastructure — internal registries, private GitHub repos, cloud credentials for build-time config pulls — this is table stakes, not a nice-to-have. Your images are artifacts that get pushed, cached, copied, and sometimes leaked. What's in their layers is permanent.
Go audit your docker history output right now. I'll wait.