We build several independently deployed Python API services out of one monorepo — an image-processing service, a document service, a similarity service, and a handful of others. They all share a common/ package with GCP storage helpers, parallel download utilities, and other plumbing nobody wants to duplicate three times.
One afternoon I ran a build for image-service and watched this happen:
[internal] load build context
=> transferring context: 1.48GB
Ten seconds. Before a single line of the Dockerfile had executed. Ten seconds just to hand files to the builder.
That line is what sent me down the rabbit hole this post is about.
The Setup
Here's roughly what the repo looks like:
repository/
├── common/
│ ├── gcp/
│ │ ├── download.py
│ │ ├── upload.py
│ │ └── storage.py
│ └── utils/
│
├── services/
│ ├── image-service/
│ │ ├── Dockerfile
│ │ ├── src/
│ │ └── models/
│ │ └── model.bin # ~1.5GB
│ │
│ ├── document-service/
│ │ ├── Dockerfile
│ │ └── src/
│ │
│ └── similarity-service/
│ ├── Dockerfile
│ └── src/
└── ...
image-service ships with a local ML model file baked into the repo (don't ask — legacy reasons). It needs two things to build: its own source, and common/. Simple enough, except those two requirements pull in opposite directions on how you'd normally structure a build.
Attempt 1: Repo Root as Context
The obvious move, since common/ lives at the top level, is to build from the repo root:
docker build -f services/image-service/Dockerfile .
This works. The Dockerfile can do COPY common/ /app/common because everything under the repo root — including common/, every other service, every model file, every .git object — is now part of the build context: the set of files the Docker client tars up and streams to the builder before a single instruction runs.
That's the part that tripped me up initially. I assumed context size and image size were basically the same problem. They aren't.
Build context is what gets sent to the daemon/builder as raw input. Image size is what actually ends up in the layers of the final image, after your Dockerfile's COPY/ADD/RUN instructions decide what to keep. You can have a 1.5GB context and a 200MB image, because the model file for document-service never gets copied into image-service's image at all — it's just sitting there, transferred over the wire, unpacked, and then ignored.
That transfer isn't free. Every docker build from repo root re-sends the entire context — including every other service's model files, .git history, virtualenvs, whatever — even though image-service only needs a fraction of it. That's where the 10-second, 1.48GB "transferring context" step came from. Not a slow build. A build that hadn't even started yet.
Attempt 2: Shrink the Context
So the next instinct: build from inside the service directory instead.
cd services/image-service
docker build .
Now the context is just image-service/ — no other services, no unrelated model files, no repo-wide .git. Fast, small, focused.
Except now common/ is gone. It's outside the build context entirely, and the Dockerfile has no way to reach it:
COPY ../../common /app/common
This fails, and it's worth understanding why rather than just memorizing "don't do that." A build context isn't just "the current directory" in some loose sense — it's a boundary. The Docker client only ever tars up and sends what's inside the context you pointed it at. Instructions inside the Dockerfile can't reach outside that boundary using ../ or any other path trick, because as far as the builder is concerned, nothing outside the context exists. It was never transferred. There's nothing to COPY from.
So we're stuck between two bad options:
- Repo root as context → everything's reachable, but the context is huge and mostly irrelevant to any single build.
- Service directory as context → fast and focused, but
common/is unreachable.
Attempt 3: The Temporary Directory Hack
The first real fix I reached for was filesystem manipulation. Stage a temporary directory that contains exactly what the build needs, and build from there:
/tmp/image-service-build/
├── Dockerfile
├── src/
├── models/
└── common/
A small shell script does the assembly:
#!/usr/bin/env bash
set -euo pipefail
BUILD_DIR=$(mktemp -d)
cp -r services/image-service/* "$BUILD_DIR/"
cp -r common "$BUILD_DIR/common"
docker build -t image-service -f "$BUILD_DIR/Dockerfile" "$BUILD_DIR"
rm -rf "$BUILD_DIR"
This works, and for a while it was our actual solution. But it bothered me. We were solving a build-input problem — "this build legitimately needs files from two different locations" — by physically rearranging the filesystem to fake a single location. It's copy-paste as architecture. Every service needs its own version of this script, and any deviation between the service's actual layout and what the script assembles becomes a silent, easy-to-miss bug.
There had to be a way to tell Docker directly: "the build needs inputs from two places," without lying to it about where those places are.
Attempt 4: Named Build Contexts
This is what BuildKit's named build contexts are for. Instead of forcing everything into one directory, you give the builder multiple, independently named sources for a single build:
docker build \
--build-context common=./common \
-f services/image-service/Dockerfile \
services/image-service
Breaking this down:
-
services/image-service(the final positional argument) is the default build context — this is what unqualifiedCOPYinstructions read from. -
--build-context common=./commonregisters an additional, named context calledcommon, pointing at thecommon/directory. - Nothing gets merged or copied on disk. Both contexts stay exactly where they are; the builder just knows about both of them now.
The Dockerfile then references each context explicitly:
FROM python:3.12-slim
WORKDIR /app
# reads from the default context: services/image-service/
COPY . /app
# reads from the named context: "common"
COPY --from=common . /app/common
RUN pip install --no-cache-dir -r requirements.txt
CMD ["python", "src/main.py"]
The key line is COPY --from=common . /app/common. Normally --from in a COPY refers to a previous build stage in a multi-stage build. With named build contexts, --from can also refer to a named context registered via --build-context. So this instruction means: "copy everything from the common context (i.e. ./common on disk) into /app/common in the image" — completely independent of whatever the default context is doing.
Conceptually, the build now looks like this:
Docker Build
├── default context
│ └── services/image-service/
│ ├── Dockerfile
│ ├── src/
│ └── models/
│
└── named context: common
└── common/
├── gcp/
└── utils/
Two separate, explicit inputs. No repo-root scan. No temp directory. No ../../ path traversal that the builder would reject anyway.
This is strictly better than the repo-root approach because the build now only ever sees what it actually asked for. document-service's model file, similarity-service's source, the .git directory — none of it is transferred, none of it is even considered, because it was never part of either context.
.dockerignore: Also About Context, Not Just Image Size
It's easy to think of .dockerignore as "the file that keeps junk out of my image." That's a side effect, not the main point. .dockerignore controls what enters the build context in the first place — before the Dockerfile ever runs, before any COPY decides what to keep.
A typical one for a service like this:
.git
__pycache__
.venv
*.log
tests/
The flow looks like:
Repository
↓
.dockerignore (filters what gets read into the context)
↓
Build context (what's actually sent to the builder)
↓
Dockerfile (decides what from the context becomes image layers)
↓
Image
One thing worth being precise about: .dockerignore applies to the context it lives next to — it doesn't automatically reach across and filter every named context you register separately. If you have a .dockerignore in services/image-service/, it governs the default context (services/image-service/). It has no bearing on what gets sent from the common named context unless common/ has its own .dockerignore. Named contexts and .dockerignore solve two different questions:
-
.dockerignoreanswers: what files should be excluded from this context? - Named build contexts answer: what additional, explicitly named sources should this build consume at all?
One trims an input you already have. The other declares an input you didn't have before. They're complementary, not substitutes for each other.
The Insight That Actually Mattered
The deeper realization here wasn't really about Docker flags. It was this:
The repository structure and the build structure don't have to be identical.
The monorepo is organized the way developers want to navigate it:
repository/
├── common/
├── service-a/
├── service-b/
└── service-c/
But a given Docker build shouldn't default to "the repository" just because the Dockerfile happens to live somewhere inside it. A build should be scoped to its actual inputs:
image-service/
+
common/
not
entire repository
This matters more as a monorepo accumulates the things monorepos tend to accumulate: multiple independently deployable services, shared libraries, ML models, generated files, frontend and backend projects living side by side, node_modules, datasets, test artifacts, documentation. None of that is relevant to any single service's build, and none of it should be treated as if it were just because it's reachable from the repo root.
What I Got Wrong Initially
A few assumptions I had to unlearn:
- I thought the Dockerfile's location determined the build context. It doesn't — the context is whatever path (or paths) you point
docker buildat; the Dockerfile can live anywhere and be referenced with-f. - I treated "the repository" as the natural, default build context for anything inside a monorepo, mostly out of convenience.
- I assumed a large context mainly mattered because it would produce a larger final image. It doesn't directly — context size and image size are separate concerns, linked only by what your
COPYinstructions choose to keep. - I saw the temp-directory copy trick as the only real workaround for cross-directory dependencies, because I didn't yet know named build contexts existed as a first-class BuildKit feature.
The Mental Model I Use Now
Before writing or changing a Dockerfile in this repo, I ask three questions:
-
What is my build context? Where am I telling
docker buildto look, and what does that actually include? - What are the actual inputs required by this build? Not "what's nearby," but what does this specific service genuinely need to compile and run?
-
Which files are unnecessary and should be excluded? Anything not required is a candidate for
.dockerignoreor for simply not being in the context in the first place.
In practice that translates to:
- Default to a focused, service-scoped build context — not the repo root.
- Use
.dockerignoreto keep incidental junk (caches, logs, test artifacts, VCS metadata) out of whatever context you do use. - Reach for named build contexts when a build legitimately needs files that live outside its natural directory — shared libraries, generated schemas, whatever.
- Don't reach for repo-root-as-context just because it's the path of least resistance in a monorepo.
Final Picture
MONOREPO
│
├── common/
│
├── services/
│ ├── image-service/
│ ├── document-service/
│ └── similarity-service/
│
└── ...
For image-service specifically, the build only ever sees:
Docker Build
├── default context
│ └── image-service/
│
└── named context: common
└── common/
Nothing else in the monorepo is transferred, scanned, or even visible to this build.
Key Takeaways
- Build context size and final image size are different things — a huge context doesn't necessarily mean a huge image, but it does mean slower, wasteful transfers.
- A Dockerfile can't reach outside its build context with
../— the context is a hard boundary, not just "the current directory." - Named build contexts (
--build-context name=path+COPY --from=name) let a single build pull from multiple, explicitly declared locations without merging anything on disk. - Temporary-directory staging scripts work, but they're a filesystem workaround for what's really a build-configuration problem.
-
.dockerignorefilters its own context — it doesn't automatically apply to separately named contexts. - In a monorepo, the build context should represent the service's actual inputs, not the repository's overall layout.
- Don't make your repository the build context by default. Make the build context represent the actual inputs required by the build.
Top comments (0)