Bars of rebuild time after a git commit that changed no code, in seconds. Python, copy everything first: 42 without .dockerignore, 0.7 with. Python, lockfile first: 1.8 without, 0.6 with. Node, copy everything first: 64 without .dockerignore, 0.6 with. Node, lockfile first: 3.2 without, 0.6 with. Java, copy everything first: 95 without .dockerignore, 0.6 with. Java, lockfile first: 12.1 without, 0.6 with.

A .dockerignore file is a few lines of text, and the usual reason given for it is a smaller upload. We built three real open-source apps 180 times, each from a working copy with its dependencies, test data and a .env file, with and without the file. The upload did shrink, by up to 900 times. The upload was not the expensive part, though.

Short version: the upload costs 5 to 20 s once, and then BuildKit sends only changes. The real cost shows up on every commit. With COPY . . before the install step and no .dockerignore, a commit that changes no code rebuilds the dependencies: 42 s on Python, 64 s on Node, 95 s on Java, against under 1 s with the file. Without it, the Python and Node images also carried the .env and .git folder, and were much larger.

What we built

Three public repos, cloned as they are, each with the clutter a developer's copy has: dependencies installed locally, 50 MB of test fixtures, and a dummy .env file. Each was built with two Dockerfile layouts: A copies everything before the install, and B copies the lockfile, installs, then copies the rest. Each layout ran with and without a .dockerignore, three rounds, and the tables show medians.

AppRepoLocal clutter.git
Python[fastapi/full-stack-fastapi-template](https://github.com/fastapi/full-stack-fastapi-template).venv 267 MB8 MB
Node[vercel/commerce](https://github.com/vercel/commerce)node_modules 411 MB12 MB
Java[spring-projects/spring-petclinic](https://github.com/spring-projects/spring-petclinic)target 66 MB12 MB

The machine was one Windows PC running Docker Desktop on WSL2, with 6 CPUs and 11.7 GB, Docker 29.6 and BuildKit 0.33 in a separate builder container. The build cache was cleared before each cold build, and each cold build downloaded its dependencies again, so cold times include our network on that day.

The upload: large, but paid once

AppContext, no ignore fileWith ignore fileTime to send, no ignore fileWith ignore file
Python314 MB1.91 MB14 s0.1 s
Node455 MB499 kB20 s0.1 s
Java133 MB1.42 MB5.5 s0.1 s

That's on one machine, with the builder a local container. On the following builds BuildKit sends only what changed: the Python context dropped from 314 MB to 1.10 MB on the second build, and Node from 455 MB to about 2.9 MB. So you pay the full price once per builder, which matters most when the builder is new each time or far away. Simple arithmetic, not measured: 455 MB over a 100 Mbit/s link takes about 36 s.

The old builder has no such shortcut. With DOCKER_BUILDKIT=0 it sent the whole context on every build, before running a single step, even for a Dockerfile that uses none of it:

AppSent, no ignore fileEach buildSent, with ignore fileEach build
Python322 MB14 s2.112 MB0.1 s
Node467 MB19 s570 kB0.1 s
Java134 MB5.5 s1.536 MB0.1 s

Cold builds barely changed. Downloading dependencies takes most of the time, and it varied more between rounds than the upload saved:

Cold build, medianA, no ignore fileA, withB, no ignore fileB, with
Python61 s52 s51 s48 s
Node91 s99 s96 s61 s
Java191 s199 s210 s210 s

Python saved 3 to 10 s. Java didn't save anything we could see. Node's rounds ranged from 45 to 131 s, so its differences are mostly noise, not a finding.

The commit: where the time goes

Git writes into .git on every commit. If .git is in the build context, COPY . . sees changed files and that layer is invalidated, along with every step after it. Here's a commit that changed no application code:

Rebuild after a commit, no code changeA, no ignore fileA, withB, no ignore fileB, with
Python42 s0.7 s1.8 s0.6 s
Node64 s0.6 s3.2 s0.6 s
Java95 s0.6 s12 s0.6 s

With layout A and no ignore file, each commit reinstalls everything. Layout B already protects the install step, but it still redoes COPY . . and anything after it, which on Java is the package step: 12 s against 0.6 s. We also ran git status between builds, in case it touched something; it didn't break the cache on any stack.

The two fixes cover different things. When a code file really changes, the ignore file can't save layout A, and only the Dockerfile order does:

Rebuild after a one-line code editA, no ignore fileA, withB, no ignore fileB, with
Python41 s40 s1.9 s0.7 s
Node77 s71 s3.4 s0.7 s
Java102 s89 s12 s7.4 s

Layout A stays between 40 and 102 s with or without the file. Docker's guidance on build cache gives the same order, and we measured it across more runtimes in Rebuild time when only your code changes. Layout B with the ignore file is the only column that's quick in both tables.

The image: size and a leaked .env

AppImage, no ignore fileWith ignore file.env in image, no ignore file.git in image, no ignore file
Python265 MB125 MBYesYes
Node422 MB246 MBYesYes
Java175 MB175 MBNoNo

Sizes are what docker image inspect reports for layout A; layout B came out within 0.4 MB of them. Compare the columns, not the folder sizes above, since the reported size is below what the files take on disk. With the ignore file, neither the .env nor .git was in any image.

Python and Node ship whatever COPY . . picked up, so the dummy secret we planted was readable in the final image, along with the git history. Java came out clean without any ignore file for one reason: its Dockerfile is multi-stage and only the built jar reaches the final image. That protects the final image only. Docker's page warns that files copied just to generate an artifact still go in the build cache.

The lines that matter

These are the files we used. The .git line does the most for your cache, and the .env line the most for your secrets:

text
# Python
.git
.venv
__pycache__
.pytest_cache
.mypy_cache
.ruff_cache
tests/fixtures-data
.env

# Node
.git
node_modules
.next
tests/fixtures-data
.env

# Java
.git
target
tests/fixtures-data
.env

Put the file at the root of the build context. If you build with a named Dockerfile, an ignore file beside it called <name>.dockerignore takes precedence. Patterns follow Go's matching, ** matches any number of folders, and with ! exceptions the last matching line wins. If your build really needs .git, for a version string say, pass the value in as a build argument instead.

What this test doesn't show

This is one machine, and the builder sat next to the client, so a remote builder or a CI runner will see longer uploads. The timed builds used --output type=cacheonly, so they don't include exporting an image. Some rounds had outliers: one Node rebuild took 263 s where the others took 49 and 64, which is why we show medians. Your dependency install sets the size of the commit penalty, so a project with few dependencies pays less.

Sources (read 3 Nov 2026): Docker's build context page and build cache optimization page. All timings are our own, from the setup above.

On systemi you send a zip of up to 50 MB or an https git URL and a branch. We fetch only the latest commit, and we install and build inside, so leave out node_modules, venv and target.

See how deploys work