The . at the end of docker build -t app . is easy to ignore. It’s the build context: the set of files the builder is allowed to see. Everything COPY and ADD can reach comes from there. If the context is your whole project directory and your Dockerfile says COPY . ., then your .git history, your local node_modules and the .env file with the staging database password all go into the image unless something stops them.
That something is .dockerignore. The examples below were built with BuildKit against alpine:3.20, using a deliberately messy project.
What actually gets sent
Here’s the test project. A 40 MB node_modules, a 20 MB .git, a nested workspace with its own node_modules, and an .env:
.
├── .env DATABASE_PASSWORD=hunter2
├── .git/pack 20 MB
├── Dockerfile
├── README.md
├── node_modules/big/blob 40 MB
├── packages/web/node_modules/x/i.js
└── src/index.jsThe Dockerfile copies everything and lists what landed in the image:
FROM alpine:3.20
WORKDIR /app
COPY . .
RUN find . -type f | sortWith no ignore file:
#6 transferring context: 62.93MB 1.2s done
#8 0.311 ./.env
#8 0.311 ./.git/pack
#8 0.311 ./Dockerfile
#8 0.311 ./README.md
#8 0.311 ./node_modules/big/blob
#8 0.311 ./packages/web/node_modules/x/i.js
#8 0.311 ./src/index.jsSixty-three megabytes transferred to the builder, and the password file is now in a layer of the image. Anyone who can pull the image can read it with docker run --rm app cat /app/.env, or extract the layer with docker save. Adding RUN rm .env afterwards doesn’t help, because layers only add; the file stays in the COPY layer underneath.
There are three separate costs here, and interviewers like candidates who can name all three:
- Leaks. Credentials, SSH keys, cloud config and Git history (which may contain secrets that were “removed” years ago) end up in an artifact that gets pushed to registries and pulled onto servers.
- Cache misses.
COPY . .is keyed on the contents of everything it copies. A changing log file, an editor swap file or a.gitdirectory that changes on every commit invalidates that layer and every step after it, even when no source code changed. The layer cache note shows the knock-on effect. - Slow, wrong builds. Transferring 60 MB is slow on a remote builder. Worse, copying a macOS
node_modulesinto a Linux image can overwrite the dependencies the Dockerfile just installed, giving you native modules built for the wrong platform.
BuildKit softens the transfer cost by only sending files it hasn’t seen in a previous build, but it can’t fix the other two. Only the ignore file does.
Writing .dockerignore patterns that match
The file lives at the root of the context (not next to the Dockerfile, if they differ). A first attempt:
node_modules
.git
.env#6 transferring context: 377B 0.0s done
#8 0.190 ./.dockerignore
#8 0.190 ./Dockerfile
#8 0.190 ./README.md
#8 0.190 ./packages/web/node_modules/x/i.js
#8 0.190 ./src/index.jsMuch better, but the nested packages/web/node_modules slipped through. This catches people coming from .gitignore, where a bare name matches at any depth. In .dockerignore, patterns are matched against paths relative to the context root, using Go’s filepath.Match rules plus ** for any number of directories. node_modules means exactly ./node_modules. To match every level you need **/node_modules.
The second detail is exceptions. Lines starting with ! re-include something an earlier line excluded, and the last matching line wins:
**/node_modules
.git
.env*
!.env.example#6 transferring context: 296B done
#8 3.568 ./.dockerignore
#8 3.568 ./.env.example
#8 3.568 ./Dockerfile
#8 3.568 ./README.md
#8 3.568 ./src/index.js.env.local is gone, .env.example (a template with no real values) is kept. Order matters: put !.env.example before .env* and the exclusion wins again.
Some teams flip the approach for strict control: exclude everything with *, then re-include what the build needs (!src, !package.json, !package-lock.json). It’s harder to get wrong in the “leak” direction, and easier to get wrong in the “build fails because a file is missing” direction, which is the safer failure.
Several Dockerfiles, one repository
Monorepos often have several Dockerfiles sharing one context. Since BuildKit, an ignore file named after the Dockerfile takes precedence over the root .dockerignore. With api.Dockerfile and api.Dockerfile.dockerignore side by side:
**/node_modules
.git
.env*
README.mdBuilding with -f api.Dockerfile used that file and dropped README.md, while other images kept using the shared .dockerignore. Note that the Dockerfile and the ignore file themselves are always available to the builder even if a pattern matches them; ignoring Dockerfile just stops COPY . . from copying it into the image.
The other lever is a smaller context. If only services/api matters, run docker build -f services/api/Dockerfile services/api rather than building from the repository root. Then files outside that directory can’t be copied at all; COPY ../shared fails with a “not found” error, because paths can’t climb above the context root. For a shared folder, BuildKit’s named contexts (--build-context shared=./libs/shared, then COPY --from=shared) let you add one directory without widening the main context to the whole repository.
Interview tip
To check what a build can see, temporarily add
RUN find . -type f | sortafterCOPY . ., as in the examples here. It’s the fastest way to verify an ignore file.
Interview practice
A security scan of your company’s public image finds an AWS access key in /app/.aws/credentials. The Dockerfile has COPY . . followed by RUN rm -rf .aws. A developer says “but we delete it”. Explain why the key is still there, what you’d do immediately, and how you’d prevent it.
Answer
The COPY . . layer contains .aws/credentials. The later RUN rm creates a new layer with a whiteout entry that hides the file in the final filesystem, but the earlier layer is still part of the image and can be extracted with docker save or by pulling the layer blob from the registry. Deleting in a later step never removes data from an image.
The immediate response is to treat the key as compromised: revoke and rotate it in AWS, check CloudTrail for use, and then remove or rebuild the published image tags. Rotating comes first because the image may already have been pulled or cached anywhere.
To prevent it, add .aws, .env*, *.pem and similar to .dockerignore (or use an allow-list .dockerignore), keep credentials out of the project directory, and use BuildKit secret mounts (RUN --mount=type=secret) when a build step genuinely needs a credential. Adding a secret scanner to CI that inspects image layers catches the next mistake. The secrets in builds note walks through secret mounts.