# .dockerignore Best Practices That Actually Matter

URL: https://www.devopsness.com/blog/dockerignore-best-practices
Author: Kiril Urbonas
Published: 2026-07-12
Updated: 2026-07-12
Categories: DevOps
Tags: Docker, Security, Containers, DevOps, Build

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

---
# .dockerignore Best Practices That Actually Matter

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:

```
# 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](/blog/docker-error-troubleshooting-guide) 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
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:

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

```
# 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.

