Back to blog
·4 min read·Cell Webb

The 477-Megabyte Build Context

A routine Fly deploy was uploading a 477 MB Docker build context. On dockerignore semantics, silent deploy waste, and the fix that cut it to about 3 MB.

On July 20, 2026, during a routine backend deploy, fly printed a number I had apparently been scrolling past for months: the Docker build context was 477 MB across 25,142 files. Uploaded, in full, on every single deploy.

The backend image is small. The Dockerfile copies seven things: api/, alembic/, alembic.ini, scripts/, pyproject.toml, uv.lock, and README.md. Everything the running service needs fits in a few megabytes of source. The other 474 MB was along for the ride.

Nothing was broken. That is the whole problem with this class of waste: every deploy succeeded and every health check passed. There is no error state for "you shipped your mypy cache to the build server again." The number is right there in the deploy output, but a number next to a green checkmark is very easy to not read.

Where 477 MB hides

Two causes, one of them a genuine semantics trap.

First, the trap. The old .dockerignore had a line saying .mypy_cache, and I assumed it worked like gitignore, where a bare name matches at any depth. It does not. Dockerignore patterns use Go's filepath.Match semantics: a bare .mypy_cache matches only at the root of the build context. api/.mypy_cache (244 MB) and frontend/.mypy_cache (174 MB) sailed straight through, along with nested __pycache__ and .pytest_cache directories everywhere. If you want gitignore-style depth matching, you have to write it explicitly:

.mypy_cache
**/.mypy_cache
.pytest_cache
**/.pytest_cache

Yes, both lines. The **/ variant does not match the root-level directory on its own in every builder, so the safe move is the pair.

Second, the oversight. The entire frontend/ tree, 237 MB of Next.js application and build debris, was being uploaded to Fly's remote builder on every backend deploy. The Dockerfile never copies a byte of it. The old ignore file excluded frontend/node_modules and frontend/.next specifically, which felt thorough when it was written and quietly stopped being thorough as the tree grew. The fix was blunter: ignore frontend/ entirely, with a comment saying when to un-ignore it.

PR #1310 did both, plus swept out other non-runtime trees (docs/, eval/, agent caches, worktrees). Estimated result: roughly 3 MB across about 524 files. A 99% cut, achieved by deleting nothing and fixing one text file.

Actually measuring it

The estimate came from walking the tree locally with the exclusion rules applied, because I wanted the number before pushing, not after. If you want to check your own project, the cheapest honest measurement is to read what your builder already tells you: docker build prints "transferring context" with a size, and fly's remote builds report the context they receive. If the number surprises you, du -sh */ at the repo root plus a skim of your Dockerfile's COPY lines will usually find the culprit in about two minutes. The context should approximate the union of what COPY touches. Anything beyond that is upload time you pay per deploy for nothing.

The footgun on the way out

I nearly got this part wrong twice. The ignore file also has root-anchored patterns like test_*.py and *_test.py, keeping stray test scripts out of the image. After learning the root-anchoring lesson, the "consistent" move is obvious: add **/ variants to those too.

Review caught what that would have done. The repo contains api/routers/repositories/discord_test.py and api/core/ai/model_test.py, which are not tests. They are runtime modules (a Discord webhook test endpoint and an admin model-settings test endpoint) whose names happen to match *_test.py. A recursive pattern would have silently excluded them from the image, and the container would have died at import time on the next deploy. Commit 1190848b added nothing but a comment, in capital letters, telling future me not to be consistent here:

# WARNING: keep these patterns ROOT-anchored — do NOT add **/ variants.

The admin model-test endpoint was later retired in #1463, but the anchoring warning remains necessary for other runtime modules such as api/routers/repositories/discord_test.py.

So the file now contains both lessons at once: patterns that must be recursive and patterns that must not be, each with a comment explaining why. I think that is the honest state of dockerignore knowledge. The syntax resembles gitignore just enough to be dangerous, and it punishes symmetry.

Does any of this matter to KittyLog the product? Not directly. KittyLog reads your repository through the GitHub API and never sees your build context; it cannot fix your dockerignore, and a changelog tool has no business having opinions about your Docker setup. But the failure shape is familiar from changelogs: things that succeed silently never get audited, and the cost compounds one unread deploy at a time. The deploy still prints the context size every time, and now I read it.

Source records

  • Commit fa978421, shrinking the fly deploy build context from 477 MB to roughly 3 MB.
  • Commit 1190848b, the root-anchoring warning for *_test.py patterns.
  • PR #1310, the build-context pull request containing both commits.
  • .dockerignore and Dockerfile.fly in the KittyLog repository.

Ready to automate your changelogs?

Try KittyLog free - no credit card required.

Get Started Free