Skip to content
Blog

.dockerignore: The Forgotten Build Optimization

A good .dockerignore shrinks build contexts, speeds up builds, and keeps secrets and junk out of your images. Syntax, patterns, and a solid default template.

Published on • October 15, 2026

AI Assistant

Everyone writes a Dockerfile. Almost nobody writes a .dockerignore. The file is six lines of glob patterns that decides which files leave your machine and which stay, and it is the cheapest build optimization you will ever ship — plus a surprisingly effective guard against committing junk and secrets into images.

What the build context actually is

docker build doesn’t read your Dockerfile in isolation. It takes a build context — the set of files the build can access — and everything COPY/ADD can reference must come from it. For a local directory, the context is processed recursively: all subdirectories included.

Before BuildKit touches a single RUN instruction, it transfers that context. The Docker docs show it plainly in build output:

$ docker build .
...
#16 [internal] load build context
#16 transferring context: 13.16MB 2.2s done

That 13.16MB includes node_modules, .git, coverage reports, and last year’s log files — unless a .dockerignore says otherwise. On a laptop with a warm cache you won’t notice. On a remote builder, in CI, or in a monorepo where the context is a few gigabytes, “transferring context” is where your build minutes go.

Where it lives and how it’s found

The build client looks for .dockerignore in the root of the context directory. If it exists, matching files and directories are removed from the context before it’s sent to the builder.

If you build several images from one repo, each Dockerfile can have its own ignore file using a naming convention: place it next to the Dockerfile and prefix the Dockerfile’s name.

.
├── index.ts
├── src/
├── docker
│   ├── build.Dockerfile
│   ├── build.Dockerfile.dockerignore
│   ├── lint.Dockerfile
│   ├── lint.Dockerfile.dockerignore
│   └── test.Dockerfile.dockerignore
├── package.json
└── package-lock.json

A Dockerfile-specific ignore file takes precedence over the root .dockerignore when both exist.

Syntax: globs, **, and negation

The file is a newline-separated list of patterns, similar to shell globs. Leading and trailing slashes are disregarded, so /foo/bar/, /foo/bar, foo/bar and foo/bar/ all exclude the same path. Lines starting with # in column one are comments. Blank lines (after preprocessing) are ignored, and — for historical reasons — the pattern . is ignored.

Matching follows Go’s filepath.Match rules, plus one Docker extension: ** matches any number of directories, including zero. So **/*.go excludes every Go file anywhere in the context.

Negation with ! is where people get burned:

*.md
!README.md

All markdown files directly under the context are excluded — except README.md. But subdirectory markdown is still included, because *.md doesn’t match across /.

The last matching line wins. This is the single most important rule:

*.md
!README*.md
README-secret.md    # wins: README-secret.md is excluded

versus

*.md
README-secret.md
!README*.md         # wins: README-secret.md is included

Same patterns, different order, opposite results. Read your ignore file bottom-up when debugging.

You can also exclude Dockerfile and .dockerignore themselves. They are still sent to the builder (the build needs them), but they can no longer be copied into the image with ADD, COPY, or bind mounts.

A solid default template

This is the starting point I drop into most Node/web projects:

# VCS & tooling
.git
.gitignore
.gitattributes
.github
.vscode
.idea

# dependencies & build output
node_modules
dist
build
coverage
.next
.turbo
*.tsbuildinfo

# logs & caches
*.log
npm-debug.log*
.eslintcache
.cache
.parcel-cache

# local environment & secrets — never ship these
.env
.env.*
*.pem
*.key
secrets/

# docs and non-runtime assets
*.md
docs
screenshots

# CI / infra that the image never runs
docker-compose*.yml
Dockerfile
.dockerignore
Makefile
terraform/

Two rules to keep in mind when you copy it around:

  1. Everything you exclude must be reachable without it. If your RUN npm ci needs package-lock.json, don’t exclude it. Excluded files fail loudly at COPY time — which is a good failure, but a build stopper.
  2. .dockerignore is not a secret store. It keeps secrets out of the image, which matters, but the files are still on disk and still in your Git history if you committed them. Use BuildKit’s --mount=type=secret (never --build-arg) for anything real, and keep .env files out of Git in the first place.

Why it helps builds, not just hygiene

  • Smaller context transfer. The builder only receives what you allow. With remote or cloud builders, that transfer happens over the network on every uncached context upload.
  • Fewer accidental inclusions. COPY . . pulls in whatever the context contains. With a tight ignore file, COPY . . is predictable — no stale dist/, no host .env overriding your container config.
  • Better cache behavior. COPY cache keys are computed from the content of the files actually copied. Excluding volatile files (logs, temp outputs) from the context keeps them from being part of what the builder considers, so unrelated churn is less likely to show up in your cache invalidation.
  • Smaller attack surface. Nothing you exclude can be found in a stopped container’s filesystem layers. Credentials, internal docs, and CI configs that never enter the image can’t leak from it.

Verify the effect by watching the transferring context line in your build output — it should shrink noticeably after your first pass.

Gotchas and pitfalls

  • Negation ordering is load-bearing. Last match wins. When a file you meant to exclude shows up in the image, read the file from the bottom.
  • Excluding a directory removes the whole subtree. There’s no “ignore this folder except one file” without a ! rule for that specific path — and the negation must come after the directory rule.
  • * is a nuclear option. It excludes everything at the root, so your Dockerfile’s COPY steps will fail until you re-include each needed path with !. Powerful for minimal contexts, tedious to maintain.
  • Pattern matching is relative to the context root. src/*.ts doesn’t match lib/src/*.ts; use **/src/*.ts for anywhere-in-tree matching.
  • Ignore files don’t affect runtime mounts. A bind mount at docker run -v $(pwd):/app is governed by the mount, not by .dockerignore. Container isolation is a separate problem.
  • A Dockerfile-specific ignore file overrides the root one entirely — it doesn’t merge with it. Copy the shared patterns across, or keep one root file.
  • Remote Git contexts behave differently. When the context is a Git URL, the repository’s own contents (and its ignore file) define what’s transferred — your local uncommitted .dockerignore tweaks don’t apply.
  • Excluding files doesn’t shrink the final image by itself. Multi-stage builds do that. .dockerignore shrinks the context; combine it with multi-stage COPY --from for the image-size win.

Sources