skip to content

In a monorepo with a dozen deployable services that share internal libraries, how would you structure build contexts and ignore files so each service image builds from the smallest correct input, and what trade-offs would you weigh?

level: principalimportance: nice to knowfreq 24%

answer

  1. three shapes: per-service / root+allowlist / named contexts
  2. <dockerfile>.dockerignore replaces the root file
  3. allowlist = fail closed, needs a lint
  4. cache key isolation: other services must not enter it
  5. CI budget on transferring context bytes

basics

~20 s

Pick one of three shapes: per-service contexts (fast, but shared code must be published), one root context with per-Dockerfile ignore files (simple, pays the walk), or a small context plus BuildKit named contexts for shared libraries. Optimise for cache hit rate and transfer cost, and enforce the ignore files automatically.

solid answer

~60 s

Three viable shapes, chosen by how shared code is consumed: 1. **Context per service directory.** Smallest and fastest, but a service cannot COPY shared libraries — those must be published as internal packages or vendored. Best when the repo is polyglot and services are loosely coupled. 2. **Root context + `-f services/x/Dockerfile` + a per-Dockerfile `services/x/Dockerfile.dockerignore`.** Shared code just works. Cost: every build walks the whole repo, and each ignore file must be an allowlist or contexts stay huge. BuildKit's per-Dockerfile ignore file *replaces* the root one, so each must be self-sufficient. 3. **Small primary context + `--build-context shared=./libs/common`.** Minimal payload with explicit extra inputs, referenced as `COPY --from=shared`. Most precise, least familiar to newcomers. I would judge them on cache hit rate (does an unrelated service's change invalidate my COPY layer?), transfer cost on remote CI builders, and how visible the coupling is. Whatever the shape, enforce it: a CI guard on transferred context size and a lint that every service Dockerfile has a matching ignore file keeps the choice from decaying.

go deeper

for a junior

Not expected here; the useful takeaway is that each service should build from as little of the repo as possible.

for a middle

Describe the root-context-plus--f invocation and the need for per-service ignore files, without needing the full comparison of strategies.

for a senior

Compare the strategies on cache isolation and transfer cost, know that per-Dockerfile ignore files replace the root file, and propose a concrete CI guard.

for a principal

Lead with what is being optimised and the conflicts between correctness, speed and comprehensibility; commit to a default, allow mixing, and name the drift-prevention mechanism that keeps the choice alive.

## What is actually being optimised Three things, and they conflict: 1. **Correctness** — the build must see every file it needs, including shared libraries, and nothing that carries secrets. 2. **Speed** — the walk-and-transfer step, which scales with the tree, and the cache hit rate, which degrades when unrelated files sit in the context. 3. **Comprehensibility** — an engineer adding service thirteen should be able to copy the pattern without a platform-team consultation. A monorepo makes these collide because the natural context (the repo root) is orders of magnitude larger than the natural input (one service plus a couple of libraries). ## Option A: a context per service `docker build -t api services/api` with `services/api/.dockerignore`. Small, obvious, fast, and a change in `services/web` cannot invalidate `services/api`'s cache. The constraint is hard: shared code above the context is unreachable, so internal libraries must be published to a registry, vendored into each service, or built into a separately-published base image. That constraint is sometimes the point. Forcing shared code to be a versioned artifact makes coupling explicit and lets services upgrade on their own cadence — at the cost of a publish step and a two-commit change whenever a library and its consumer move together, which is exactly what monorepos exist to avoid. ## Option B: root context, per-Dockerfile ignore files `docker build -f services/api/Dockerfile -t api .` from the root, with `services/api/Dockerfile.dockerignore` beside it. BuildKit uses that file **instead of** the root `.dockerignore`, so it must be complete on its own — a fact that surprises people and is worth a comment at the top of each file. The practical move is to make each one an allowlist: ``` * !services/api !libs/common !package.json !pnpm-lock.yaml ``` Now the effective context is a few megabytes even though the nominal context is the whole repo, and — crucially — a change in another service does not enter the COPY layer's cache key. Without allowlists this option silently degrades: every service ships the entire monorepo and every service's cache is invalidated by every commit. The residual cost is that the client still walks the full tree on each build. On a large repo that is seconds, not minutes, but it grows. ## Option C: named build contexts `docker build --build-context shared=./libs/common -t api services/api`, then `COPY --from=shared . /app/libs/common`. The primary context stays tiny, extra inputs are explicit and named, and the dependency is visible in both the command and the Dockerfile. It is the most precise option and composes well with `--build-context` overrides in CI (for example pinning a library to a different revision). The cost is familiarity and portability: it requires BuildKit, it is unfamiliar to most engineers, and every build invocation needs the extra flags, which pushes teams toward a build wrapper or a Bake file. That wrapper is fine — but it is another thing to maintain and another place where the build differs between a laptop and CI. ## Deciding - If services are independently versioned and mostly do not share source, **A**. - If shared source changes with its consumers (the common monorepo reason), **B with allowlists** is the pragmatic default: standard invocation, no extra tooling, and per-service isolation as long as the allowlists are honest. - If build time is critical, contexts are huge, or CI builders are remote and the transfer dominates, **C** — the payload is minimal by construction. Mixing is legitimate: most services on B, one or two heavyweight ones on C. ## What makes any of these survive The failure mode is drift. Allowlists rot when someone adds a directory and forgets the ignore file; the build fails confusingly, or worse, silently ships a stale copy. Counter it with automation rather than documentation: - **Lint**: every `services/*/Dockerfile` must have a sibling ignore file; fail the build if not. - **Budget**: parse `transferring context:` from plain progress output and fail CI above a per-service threshold. This catches regressions the day they land. - **Generate**: if a build tool already knows a service's dependency graph (workspace tooling can produce a pruned subtree), generate the context or the ignore file from it rather than hand-maintaining both. - **Prove**: a periodic job that lists the effective context for each service and diffs it against the expectation. ## Trade-offs to name explicitly - *Allowlist vs denylist*: allowlists fail closed (missing file → build error, secrets never leak by default) but need maintenance; denylists fail open (junk and secrets sneak in) but need no upkeep. In a monorepo I take the allowlist and pay for it with the lint. - *Explicit coupling vs convenience*: published internal packages make dependencies visible and versioned but slow down cross-cutting changes; root contexts make cross-cutting changes trivial and coupling invisible. - *Standard commands vs optimal builds*: named contexts and Bake files are faster and more precise but move the build behind a wrapper, widening the gap between what an engineer runs locally and what CI runs. - *Local vs remote builders*: on a shared remote builder, context size is network cost paid per build per engineer; that is often what tips the decision toward C.

  • With one root context for a dozen services, why is an allowlist ignore file per service almost mandatory?
    Because otherwise every service ships the entire repository and, worse, every service's `COPY` layer is keyed on files owned by other teams — so any commit anywhere invalidates every image's cache. An allowlist narrows both the payload and the cache key to that service's real inputs, restoring isolation without giving up the shared-source convenience.
  • What breaks when someone adds a new shared library and forgets the allowlist entry?
    The build fails with a missing-file error at COPY, or silently uses a stale vendored copy if one exists. That is the maintenance cost of failing closed. It is mitigated by a lint that checks each service Dockerfile has an ignore file and, better, by generating the ignore file from the build tool's dependency graph.
  • When would you accept publishing internal libraries as versioned packages instead of sharing source through the context?
    When services need to adopt library changes on their own schedule, when the repo is polyglot enough that a shared context has little value, or when build isolation matters more than atomic cross-cutting commits. It costs a publish step and turns one commit into two, which is a real tax on refactoring across the boundary.

saying these in an interview costs you the question

  • Using one root context for every service with no per-service ignore file, then blaming Docker for slow builds.
  • Assuming a per-Dockerfile ignore file supplements the root one rather than replacing it.
  • Optimising only transfer size while ignoring that unrelated files in the context destroy cache isolation.
  • Adopting named build contexts without acknowledging the wrapper or Bake file they require.
  • Treating ignore-file discipline as a documentation problem rather than something CI enforces.

context