🍱 Lunchbox Hands

docker

Your .dockerignore Is Not Broken — It Just Is Not .gitignore

The classic bug — "why is node_modules still in my build context?" — is not a bug at all. Docker matches patterns with Go's filepath.Match against the context root, not with gitignore rules: bare names don't recurse, negation is last-line-wins, and the Dockerfile is sent to the builder even when you exclude it.

You copy your trusty .gitignore into a file called .dockerignore, run a build, and watch Docker cheerfully upload a nested node_modules you were sure you’d excluded. The file looks right. The syntax looks right. So the tool must be broken — except it isn’t, because .dockerignore doesn’t speak gitignore: it’s a different matching engine with different anchoring rules, and a bare node_modules only matches at the root of the build context, not at every level the way Git would match it. The two files look identical, share a naming convention, and agree on maybe eighty percent of simple cases — which is exactly why the remaining twenty percent feels like a bug.

Developers file it as one, too. moby/moby issue #40318 is people discovering, one at a time, that patterns which recurse in Git don’t recurse in Docker. That issue isn’t evidence Docker is wrong; it’s evidence the mental model is. So let’s replace the model.

First: what the build context actually is

Before a single line of your Dockerfile executes, Docker packages up the build context — the directory you point docker build at — and sends it to the builder. COPY and ADD can only reach files that made the trip. That’s the entire job of .dockerignore: it filters what gets sent, before any instruction runs.

This ordering explains the symptom people misread. When your .dockerignore misses something, the pain shows up at the very start of the build — the “transferring context” step — not at any particular Dockerfile line. A pattern that doesn’t match doesn’t error; the files just quietly ride along, get hashed into your COPY steps, and potentially end up in image layers.

The engine: Go’s filepath.Match, not gitignore

Docker’s own documentation is explicit about the machinery: matching uses Go’s filepath.Match rules, after a preprocessing step that runs each pattern through Go’s filepath.Clean to trim whitespace and remove . and .. segments. On top of that, Docker adds one extension filepath.Match doesn’t have: a special ** wildcard that “matches any number of directories (including zero).”

Notice what’s not in that description: gitignore. The .dockerignore format is not gitignore syntax, not a superset of it, and not a subset of it — it’s a different engine that happens to overlap on the easy cases. Two rules do most of the damage when you assume otherwise.

Rule one: patterns are relative to the context root. In Docker, foo/bar means “the bar inside the foo at the top of the build context” — full stop. Leading and trailing slashes are disregarded entirely: /foo/bar/, /foo/bar, foo/bar/, and foo/bar are four spellings of the same pattern. In Git, those slashes are load-bearing — a leading slash anchors the pattern, a trailing slash restricts it to directories. In Docker they’re decoration.

Rule two: nothing recurses unless you say so. Git’s rule for a slashless pattern is that it “may also match at any level below” — a bare node_modules in .gitignore matches every node_modules in the tree. Docker has no such rule. A bare node_modules in .dockerignore matches exactly one path: ./node_modules at the context root. If you want recursion, you spell it out:

# Matches only <context root>/node_modules
node_modules

# Matches node_modules at any depth — including the root (zero directories)
**/node_modules

That’s the whole “why isn’t node_modules being ignored” bug. In a single-app repo where the only node_modules sits at the root, the bare pattern works and the misunderstanding survives. The day you build from a monorepo root, or a nested package appears, the pattern silently stops covering it — and it looks like Docker broke.

The side-by-side

Behavior.gitignore.dockerignore
EngineGit’s own pattern rulesGo’s filepath.Match + filepath.Clean preprocessing
Bare name (node_modules)Matches at any directory levelMatches only at the context root
Match at any depthAutomatic for slashless patternsMust write **/node_modules
Leading slash (/foo)Anchors the pattern to the .gitignore’s directoryDisregarded — patterns are always root-relative
Trailing slash (foo/)Restricts match to directoriesDisregarded
**Supported (**/foo, abc/**, a/**/b)Supported — matches any number of directories, including zero
Negation !Last matching pattern decides; cannot re-include inside an excluded parent directoryLast matching line decides
Where the file livesPer-directory files, plus global and repo-level excludesOne file, at the root of the build context
What it protectsYour commit historyYour build context — and everything downstream of it

The negation row deserves its own worked example, because “last line wins” has teeth.

Negation: the last matching line decides

Docker’s rule, straight from the docs: “The last line of the .dockerignore that matches a particular file determines whether it’s included or excluded.” Order isn’t a style preference — it’s the semantics. The documentation’s own example:

*.md
README-secret.md
!README*.md

Intent: exclude markdown, definitely exclude the secret README, allow the ordinary READMEs. Actual result: every README is included — README-secret.md too — because !README*.md matches it and comes last. The middle line does nothing. To get the intended behavior, the more specific exclusion has to come after the exception it needs to beat:

*.md
!README*.md
README-secret.md

Now the last line matching README-secret.md is an exclusion, and the file stays out. Same three lines, opposite outcome. If a negation in your file seems to be “not working,” read the file bottom-up and find the last line that matches the path in question — that line is the verdict.

Excluded from the image ≠ excluded from the upload

Here’s the nuance that sounds like a contradiction until the mechanism clicks. You can list Dockerfile and .dockerignore in your .dockerignore. Doing so does not keep them out of the build context — the builder needs to read both files to run the build at all, so they’re always sent. What the exclusion changes is that they can no longer be copied into the image via ADD, COPY, or bind mounts.

So the exclusion is real, it just guards a different door than you’d guess: it’s an image-contents guard, not an upload guard. A wildcard COPY . . can’t accidentally bake your Dockerfile — comments, internal hostnames, whatever it reveals about your infrastructure — into a layer someone can later docker save and read.

Why any of this matters

The stakes are lopsided: a too-loose .dockerignore costs you three different ways, and one of them is a genuine security problem.

Secrets become layers. If .env, *.pem, or .git are in the context and any COPY . . runs, they’re in an image layer — and layers are archives anyone with the image can unpack. .git is the sneaky one: it’s an entire history of your repository, including files you deleted years ago, hiding in a directory nobody thinks of as “a file.” (node_modules is the other one worth excluding on principle — and if you want to know what’s actually in it, triaging a dependency audit covers reading those reports without drowning in them.)

Cache invalidation. COPY steps are cached by the content of what they copy. When logs, editor droppings, and OS cruft ride along in the context, every incidental change to them can invalidate the cache from that step onward, forcing rebuilds of work that didn’t actually change.

Upload weight. The whole context is transferred to the builder before anything else happens — a decade of node_modules, virtualenvs, and build artifacts, shipped before line one of your Dockerfile runs. Trimming the context trims the wait, every single build.

A starting point that encodes all of this

Our .dockerignore generator exists so you don’t have to re-derive these rules at 11pm. You pick your stacks — Node, Python, Go, Rust, Java, .NET, Ruby, PHP — and it merges them with an always-on General base into one sectioned, deduplicated file you can copy or download.

The output takes positions on the things this post covered. The General base excludes .git, CI configs, docs, and — critically — .env and .env.* with a !.env.example exception placed after the exclusions, in last-line-wins order, plus *.pem and *.key. It excludes Dockerfile* and .dockerignore themselves, with a comment right in the generated file explaining the nuance above: they’re still sent to the builder, they just can’t be copied into the image. The stack sections carry their own footnotes for the judgment calls — why dist/ is commented out by default (some setups copy a pre-built output into the final stage instead of building in-container), why Go’s vendor/ and PHP’s vendor/ are only safe to ignore under specific build setups. And the merge logic deliberately never deduplicates a ! exception line, because dropping one as a “duplicate” would silently change what’s ignored — which is exactly the class of bug this post is about.

One honest note on anchoring: the generated patterns are root-relative, written for the common case where your build context root is your app root. If you build from a monorepo root, add **/ variants (**/node_modules) for the directories that recur — now you know exactly why.

And since the two files genuinely are different dialects: generate the Git side with the .gitignore generator rather than copying between them in either direction. If the Dockerfile you’re feeding this context to started life as a gnarly docker run one-liner, docker run to compose will translate it — the flag-by-flag mapping is covered in our docker run to Compose guide.

The short version

The gitignore instinct says.dockerignore actually does
node_modules ignores it everywhere”Matches only at the context root — write **/node_modules to recurse
”Leading slash anchors, trailing slash means directory”Both are disregarded; every pattern is root-relative
”Same syntax, same engine”Go’s filepath.Match + filepath.Clean, plus Docker’s ** — not gitignore, in either direction
”My ! exception isn’t working”The last matching line wins — reorder so the line you want decides comes last
”I excluded the Dockerfile, so it isn’t uploaded”It’s still sent (the builder must read it) — it just can’t be COPY’d into the image
”It’s only about upload size”It’s also secrets-in-layers (.env, .git, keys) and cache invalidation

The file isn’t broken. It was just never speaking the language you assumed.