
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.
| App | Repo | Local clutter | .git |
|---|---|---|---|
| Python | [fastapi/full-stack-fastapi-template](https://github.com/fastapi/full-stack-fastapi-template) | .venv 267 MB | 8 MB |
| Node | [vercel/commerce](https://github.com/vercel/commerce) | node_modules 411 MB | 12 MB |
| Java | [spring-projects/spring-petclinic](https://github.com/spring-projects/spring-petclinic) | target 66 MB | 12 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
| App | Context, no ignore file | With ignore file | Time to send, no ignore file | With ignore file |
|---|---|---|---|---|
| Python | 314 MB | 1.91 MB | 14 s | 0.1 s |
| Node | 455 MB | 499 kB | 20 s | 0.1 s |
| Java | 133 MB | 1.42 MB | 5.5 s | 0.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:
| App | Sent, no ignore file | Each build | Sent, with ignore file | Each build |
|---|---|---|---|---|
| Python | 322 MB | 14 s | 2.112 MB | 0.1 s |
| Node | 467 MB | 19 s | 570 kB | 0.1 s |
| Java | 134 MB | 5.5 s | 1.536 MB | 0.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, median | A, no ignore file | A, with | B, no ignore file | B, with |
|---|---|---|---|---|
| Python | 61 s | 52 s | 51 s | 48 s |
| Node | 91 s | 99 s | 96 s | 61 s |
| Java | 191 s | 199 s | 210 s | 210 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 change | A, no ignore file | A, with | B, no ignore file | B, with |
|---|---|---|---|---|
| Python | 42 s | 0.7 s | 1.8 s | 0.6 s |
| Node | 64 s | 0.6 s | 3.2 s | 0.6 s |
| Java | 95 s | 0.6 s | 12 s | 0.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 edit | A, no ignore file | A, with | B, no ignore file | B, with |
|---|---|---|---|---|
| Python | 41 s | 40 s | 1.9 s | 0.7 s |
| Node | 77 s | 71 s | 3.4 s | 0.7 s |
| Java | 102 s | 89 s | 12 s | 7.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
| App | Image, no ignore file | With ignore file | .env in image, no ignore file | .git in image, no ignore file |
|---|---|---|---|---|
| Python | 265 MB | 125 MB | Yes | Yes |
| Node | 422 MB | 246 MB | Yes | Yes |
| Java | 175 MB | 175 MB | No | No |
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:
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

