Every docker build starts with => [internal] load build context 2.13GB and takes a minute before the first instruction runs; COPY . . invalidates the cache on every build even when no source file changed, because .git/ or a log file did; and occasionally an image ships .env with local credentials because nothing excluded it. All three come from the build context — the set of files sent to the builder — being far larger than the image needs. This page measures the context, replaces a leaky denylist with an allowlist .dockerignore, and verifies the result, as part of Docker build cache optimization.

A small, precise context speeds up every build, makes cache hits reliable, and keeps secrets out of images by construction.

Diagnostic

See how big the context is and what dominates it:

#!/usr/bin/env bash
set -euo pipefail
docker build --no-cache --progress=plain -t ctx-probe . 2>&1 | grep -m1 'load build context' -A2 | tail -1
du -sh .git node_modules dist coverage .cache 2>/dev/null | sort -h -r
cat .dockerignore 2>/dev/null || echo "no .dockerignore"
docker run --rm ctx-probe sh -c 'ls -a /app | head -20; test -f /app/.env && echo "LEAK: .env is in the image"'

Expected bad output:

#6 transferring context: 2.13GB 58.4s done
1.4G	node_modules
512M	.git
148M	coverage
61M	dist
no .dockerignore
.  ..  .env  .git  coverage  dist  node_modules  src  package.json
LEAK: .env is in the image

Two gigabytes transferred per build, dominated by node_modules and .git, and a local .env baked into the image.

Build Context Size by Directory Bar chart of the largest directories sent in the build context before adding a .dockerignore. Build Context Size by Directory node_modules 1.4 GB .git 512 MB coverage 148 MB dist 61 MB src and config 9 MB
Almost nothing the build needs is in the largest directories.

Root cause

docker build . sends the entire directory tree to the builder, minus whatever .dockerignore excludes. Without a .dockerignore, that includes dependency directories rebuilt inside the image anyway, the git history, test output, build output and local secrets. Transfer time is the obvious cost; cache invalidation is the subtle one. COPY . . computes a checksum over every copied file, so any change anywhere in the context — a new commit updating .git/index, a log line, a coverage report — produces a new checksum and invalidates that layer and every layer after it. A denylist .dockerignore helps but leaks by default: every new directory or file type is included until someone remembers to add it. An allowlist — ignore everything, then re-include only what the build needs — fails closed instead.

The secret leak in the diagnostic follows directly from the same mechanism. COPY . . copies whatever is in the context, and a developer's .env, a .npmrc with a registry token, or an SSH key someone dropped into the project directory all travel with it. Multi-stage builds reduce the risk only if the final stage copies from the build stage selectively; many Dockerfiles copy the whole application directory into the runtime stage as well. Keeping those files out of the context entirely is the only guarantee that no stage can copy them, which is why the allowlist is as much a security control as a performance one.

Resolution

  1. Write an allowlist .dockerignore that excludes everything and re-includes only build inputs:
*
!package.json
!package-lock.json
!tsconfig.json
!src/
!public/
src/**/*.test.ts
src/**/__snapshots__/

Patterns are evaluated in order; * excludes everything, each ! re-includes a path, and later lines can exclude again inside re-included directories (the test files here).

  1. Copy precisely in the Dockerfile so dependency layers depend only on manifests:
# syntax=docker/dockerfile:1.7
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY tsconfig.json ./
COPY src ./src
COPY public ./public
RUN npm run build
FROM node:20-bookworm-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]
  1. Use per-Dockerfile ignore files in monorepos. BuildKit reads <Dockerfile name>.dockerignore next to the Dockerfile, so each service can have its own allowlist even with a shared context:
#!/usr/bin/env bash
set -euo pipefail
ls services/*/Dockerfile | while read -r df; do
  test -f "$df.dockerignore" || echo "missing: $df.dockerignore"
done
docker build -f services/api/Dockerfile -t api:ctx .
  1. Verify what actually reaches the builder by listing the context from a throwaway build stage:
#!/usr/bin/env bash
set -euo pipefail
printf 'FROM busybox\nCOPY . /ctx\nRUN find /ctx -type f | sort > /ctx.txt && du -sh /ctx\n' > /tmp/Dockerfile.ctx
docker build -f /tmp/Dockerfile.ctx --progress=plain --no-cache . 2>&1 | grep -E '^#[0-9]+ [0-9.]+ [0-9.]+[KMG]?\s+/ctx' || true
docker build -f /tmp/Dockerfile.ctx -t ctx-list . >/dev/null && docker run --rm ctx-list cat /ctx.txt | head -30
From Directory to Cached Layer Flow from the project directory through .dockerignore filtering and context transfer to the cache check on COPY. From Directory to Cached Layer project dir all files .dockerignore allowlist filter context sent only inputs COPY checksum stable cache
Everything not excluded is transferred and checksummed, so exclusions speed up and stabilise builds.

Expected output

$ docker build --progress=plain -t api:ctx . 2>&1 | grep 'transferring context'
#6 transferring context: 3.92MB 0.1s done
$ docker run --rm api:ctx sh -c 'ls -a /app; test -f /app/.env && echo LEAK || echo "no .env in image"'
.  ..  dist  node_modules
no .env in image
$ touch README.md && docker build -t api:ctx . 2>&1 | grep -c CACHED
9

The context drops from 2.1 GB to about 4 MB, builds start instantly, local secrets never enter the image, and touching a file outside the allowlist no longer invalidates any layer.

The last command is the most useful ongoing check: after changing a file the image does not use, every layer should still be CACHED. If one is not, some COPY still includes more than it needs, or the allowlist lets through a file that changes often. Running that probe after any Dockerfile or .dockerignore edit catches regressions before they slow down everyone's builds.

Prevention

  1. Fail CI if the context grows unexpectedly. Parse the transferring context line from a CI build and fail above a threshold, such as 50 MB, which catches a missing or broken .dockerignore immediately.

  2. Scan images for secret files.env, *.pem, id_* — as covered in scanning Docker images for embedded secrets.

  3. Review .dockerignore with Dockerfile changes. A new COPY of a directory must be matched by an allowlist entry, or the build fails loudly — which is the point of failing closed.

Denylist vs Allowlist .dockerignore Comparison of excluding known-bad paths against excluding everything and re-including build inputs. Denylist vs Allowlist .dockerignore denylist allowlist node_modules, .git listed * then !src/ etc new files included new files excluded secrets leak by default secrets excluded by default context grows silently context stays minimal
The allowlist fails closed: new files stay out until someone decides they belong.

Platform caveats

macOS (Docker Desktop): context transfer crosses the VM boundary, so a large context costs more on macOS than on Linux; the improvement from an allowlist is correspondingly larger.

WSL2: builds run from /mnt/c send the context through the Windows filesystem bridge; keep repositories in the WSL filesystem for fast context transfer.

Apple Silicon (ARM64): multi-platform builds send the context once per build, not once per platform, so context size affects them the same way as single-platform builds.

Rollback

Delete or rename the .dockerignore; builds return to sending the full directory:

#!/usr/bin/env bash
set -euo pipefail
git mv .dockerignore .dockerignore.disabled
docker build -t api:ctx .

Frequently Asked Questions

Why does COPY . . bust the cache when I did not change any code?

It checksums every file in the context, including .git, logs and build output. Any change there invalidates the layer. Exclude those paths with .dockerignore and copy only the directories the build needs.

Should .dockerignore be an allowlist or a denylist?

An allowlist (* then ! entries) is safer because new files are excluded until explicitly added, which keeps secrets and large directories out by default. A denylist is easier to start with but leaks over time.

Does .dockerignore apply to docker compose build?

Yes. Compose builds use the same context rules, reading .dockerignore from the context directory or <Dockerfile>.dockerignore next to the Dockerfile.

How do I see what is in the build context?

Build a throwaway image that copies the context and lists it, as shown above. BuildKit does not print the file list directly, but the transferring context size is a quick signal.