What happened
We kept finding a .env file inside a final image, even though we believed .dockerignore was keeping it out. It was a worrying result, because that image was about to be pushed to a registry.
The file turned up through a later stage that copied a whole directory from an earlier stage, so our ignore rules never got a chance to apply.
Why it happens
The .dockerignore file filters the build context, which is the set of files the client sends to the builder for instructions like COPY . . and ADD. It does not describe the filesystem of a stage. COPY --from=<stage> reads from that stage or image directly, so the ignore file plays no part.
That means anything that did get into the earlier stage can travel on. A file might get there because a pattern did not match (a bare .env only matches at the context root, so nested ones need **/.env), because a RUN step generated it, or because it was already in a base image. Copying a whole directory then carries all of it along.
What we do now
In later stages we now copy specific paths, such as the build output directory and the package manifest, and never a whole app directory. We also tightened .dockerignore, using **/.env* so nested files are covered, but we treat that as the first line of defence rather than the only one.
Finally, we inspect the result. Listing files in the final image takes seconds and tells us whether something slipped through. Also, secrets needed at build time should be passed with BuildKit secret mounts instead of being copied into any stage.
FROM node:20-slim
WORKDIR /app
# Explicit paths only. Not: COPY --from=build /app .
COPY --from=build /app/dist ./dist
COPY --from=build /app/package*.json ./
COPY --from=build /app/node_modules ./node_modules
# Verify afterwards:
# docker run --rm --entrypoint ls my-image -la /appQuick checklist
- 1List the exact paths the runtime needs and copy only those.
- 2Avoid COPY --from=build /app . in the final stage.
- 3Use **/.env* in .dockerignore so nested env files are excluded too.
- 4Run the finished image and list /app to confirm no secrets are present.
Rule of thumb
Treat .dockerignore as a context filter only, and be explicit about every path you copy between stages.