Skip to main content
.dockerignore Best Practices That Actually Matter

.dockerignore Best Practices That Actually Matter

KU
• 6 min read•41 views

A bloated build context slows every build, wrecks your cache, and quietly leaks secrets into images. Here's how to keep it lean.

Most people write a .dockerignore once, copy it from an old project, and never look at it again. Then they wonder why their builds take 40 seconds to even start, why the cache never hits, and why docker history shows a 900MB image for a Go binary that should weigh 15MB.

The .dockerignore file is not a nice-to-have. It controls what gets sent to the Docker daemon before a single instruction runs. Get it wrong and you pay for it on every build, in every CI run, on every developer's laptop.

The build context is the whole point

When you run docker build ., that trailing . is the build context. The CLI tars up everything in that directory and ships it to the daemon. COPY and ADD can only reference files inside that tarball. So the size of the context sets a floor on how slow your build starts.

Run a build in a repo with a fat node_modules and a .git history and you'll see it: Sending build context to daemon 512MB. That transfer happens before Dockerfile line one. On a laptop it's annoying. In CI, with a remote daemon or a slow disk, it's the difference between a 30-second pipeline and a 4-minute one.

The .dockerignore file lives at the root of the context and tells the CLI what to leave out of that tarball. Fewer files sent means a faster start and, just as important, a cache that actually works.

Syntax, and why it is not .gitignore

The patterns look like .gitignore, and that resemblance trips people up. It uses Go's filepath.Match rules, not the git wildmatch engine. A few things worth knowing:

code
# comments start with a hash
node_modules
**/*.log
.git
!keep-this.log
  • node_modules matches that directory anywhere it appears in the tree.
  • ** matches any number of path segments. **/*.log kills logs at every depth.
  • A leading ! negates a pattern, re-including something an earlier line excluded.
  • Trailing slashes and directory-only semantics behave differently from git. Don't assume a .gitignore copied verbatim does the same thing.

The negation order matters more than people expect. Rules are evaluated top to bottom, last match wins. If you exclude * and then try to re-include !src/, but a later broad rule re-excludes it, the file drops out again. When a negation isn't working, the fix is almost always moving it below the rule it's fighting.

What to always exclude

Some entries earn their place in nearly every project:

  • .git and .gitignore — history and metadata the image never needs. .git alone is often the biggest single thing in the context.
  • node_modules, vendor, venv, __pycache__ — dependency and build artifacts you rebuild inside the image anyway.
  • dist, build, target, *.o, *.pyc — local compiler output that would clash with what the build produces fresh.
  • .env, *.pem, *.key, secrets/, credentials.json — the ones that keep you up at night. More on this below.
  • .vscode, .idea, .DS_Store, *.swp — editor and OS junk.
  • README.md, docs/, *.md, test fixtures, large sample data — anything the runtime doesn't touch.
  • Dockerfile, docker-compose.yml, .github/ — CI and build config the image itself has no use for.

The security angle is the one that bites

Here's the failure mode. You write COPY . . in your Dockerfile because it's easy. Meanwhile there's a .env with a database password sitting in the repo root because that's how local dev works. Now that password is baked into an image layer, and it stays there even if a later layer deletes the file. Anyone who pulls the image runs docker history or unpacks the layers and reads it straight out.

A .dockerignore that lists .env, *.pem, and secrets/ is a real control here, not hygiene theater. It means a careless COPY . . can't scoop up a credential that never should have left the developer's machine. Pair it with build secrets (--mount=type=secret) for anything the build genuinely needs, and never let a plaintext secret ride along in the context.

Treat this as belt and suspenders. The ignore file stops the accident; scoped COPY and build secrets handle the intentional cases. If you're already fighting build failures, a lean context also makes them easier to reason about — our Docker troubleshooting guide walks through the common ones.

Cache and multi-stage builds

Every COPY invalidates cache when the copied files change. If your context includes churny junk — logs, local build output, an editor's scratch files — you get cache misses on COPY . . for changes that have nothing to do with your actual code. Excluding that noise means the cache holds until real source changes.

Multi-stage builds don't get you out of this. The .dockerignore applies to the whole build, every stage. A builder stage that does COPY . . to compile still pulls the full unfiltered context if you skipped the ignore file. The clean pattern is to copy narrowly first for dependency resolution, then copy source:

dockerfile.dockerfile
COPY package.json package-lock.json ./
RUN npm ci
COPY . .

That first COPY only busts cache when dependencies change, so npm ci stays cached across ordinary code edits. But it only works if node_modules is ignored — otherwise the local one clobbers what npm ci installed.

The mistake that's worse than a fat context

Excluding something the build actually needs. Someone adds *.md to trim the context, and a Go build that embeds a README.md via //go:embed breaks with a file-not-found that makes no sense until you check the ignore file. Or a broad config/ exclusion drops a config the app reads at startup.

The negation fix pattern is worth memorizing. Exclude a directory, then re-include the one file you need:

code
config/
!config/production.yaml

Order matters. Put the ! line after the exclusion or it does nothing.

A starter .dockerignore

Solid default for a Node, Python, or Go service. Trim what doesn't apply:

code
# VCS
.git
.gitignore
.gitattributes

# Dependencies / build output
node_modules
vendor
venv
.venv
__pycache__
*.pyc
dist
build
target
*.o

# Secrets and env
.env
.env.*
*.pem
*.key
secrets/
credentials.json

# Editor / OS
.vscode
.idea
.DS_Store
*.swp

# Docs, tests, CI
*.md
docs/
.github/
Dockerfile*
docker-compose*.yml
.dockerignore

# Logs and local data
*.log
tmp/
testdata/

The call we'd make

Write the .dockerignore first, before the Dockerfile, and treat .git, dependencies, and secrets as non-negotiable exclusions. Then verify: run a build, watch the context size the daemon reports, and unpack a layer or two to confirm nothing sensitive rode along. Two minutes of checking beats finding a leaked key in a published image. A lean context isn't a micro-optimization — it's the cheapest speed and safety win in the whole Docker workflow.

Explore topics:DockerDevOps
React
Share this post
KU

Kiril Urbonas

AI Engineer

553 articles
View all articles by Kiril Urbonas

You might have missed

Evergreen posts worth revisiting.