Ch. 13 · Docker

Docker Build Context and Ignore Rules

What the build context is, why COPY . . can ship your .env and .git, and how to write .dockerignore patterns that match nested folders.

~5 min readbeginnerupdated Oct 7, 2026

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.js
Text

The Dockerfile copies everything and lists what landed in the image:

FROM alpine:3.20
WORKDIR /app
COPY . .
RUN find . -type f | sort
dockerfile

With 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.js
Text

Sixty-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:

  1. 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.
  2. Cache misses. COPY . . is keyed on the contents of everything it copies. A changing log file, an editor swap file or a .git directory 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.
  3. Slow, wrong builds. Transferring 60 MB is slow on a remote builder. Worse, copying a macOS node_modules into 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
Text
#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.js
Text

Much 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
Text
#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
Text

.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.md
Text

Building 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 | sort after COPY . ., 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.

Keep going

More in Docker

read ✓Docker · easy

Docker Entrypoint and Command Overrides

How ENTRYPOINT and CMD combine into one argv, what docker run arguments replace, and why appending sh to some images never gives you a shell.

~6 min readread →
read ✓Docker · easy

Docker Layers and Build Cache Invalidation

Why one edited source file can rerun npm install: how BuildKit's layer cache keys each step, and how to order a Dockerfile so rebuilds stay fast.

~5 min readread →
esc