Why a naive monorepo docker build wastes time

The typical first Dockerfile in a monorepo copies the whole repository early and then installs dependencies: COPY . . followed by the package manager's install step. It builds, but it treats every file in the repo as an input to every service.

Why the cache keeps missing

Docker caches each instruction as a layer. For a COPY, the cache check covers the contents and metadata of every file being copied. If any of those files changes, that layer is rebuilt, along with every layer after it. With COPY . . near the top, the copied set is the entire repo. A one-line README edit in services/billing therefore changes the checksum for the api, worker, and billing images. All three rerun their dependency installs, even though no lockfile changed. Separately, a large build context (including node_modules, .git, and build output) has to be sent to the builder before any instruction runs.

Illustrative estimate (assumed numbers, not a benchmark): wasted minutes per day = services × install minutes × unrelated commits per day. With 5 services, 3 minutes per install, and 20 commits: 5 × 3 × 20 = 300 minutes of redundant installs.

The target approach

Build each service from the repo root, but narrow what each build sees:

  • a .dockerignore that trims the context
  • a pruned workspace containing only that service and the internal packages it depends on
  • a multi-stage Dockerfile that installs from manifests before copying source and ships only runtime artifacts

This is for backend and platform engineers running several deployable services that share internal packages. It pays off once dependency installs dominate build time and most commits touch only one or two services.

# services/api/Dockerfile: the naive pattern this section warns against.
# Built from the repo root:  docker build -f services/api/Dockerfile .
FROM node:20-slim
WORKDIR /app

# Copies the whole repository: every service, every package, docs, and
# anything .dockerignore doesn't exclude (.git, node_modules, dist/).
# This layer's cache check looks at every one of those files, so a README
# edit in services/billing invalidates it for the api image too.
COPY . .

# Because the layer above changed, this install runs again from scratch,
# even though package.json and package-lock.json are unchanged.
RUN npm ci

# Every later layer is rebuilt as well.
RUN npm run build --workspace=services/api

# The final image also carries the source, dev dependencies, and code
# for services that api never uses.
CMD ["node", "services/api/dist/index.js"]

A typical first monorepo Dockerfile, where an early COPY . . puts the whole repo into the cache key, so any unrelated file change forces a full dependency reinstall and rebuild.

How the build pipeline fits together

Think of the build as a funnel. Each stage narrows what the next one sees, so a change only invalidates the layers that actually depend on it.

Context. The repo root is the build context, because a service needs its own code plus the internal packages it imports, and those live outside its directory. .dockerignore filters the context before it reaches the builder. Anything excluded there (VCS metadata, local node_modules, build output, other services' test fixtures) cannot bust a cache key.

Prune stage. This stage takes the full context and emits a reduced tree: the target service, its transitive internal dependencies, and a lockfile trimmed to match. Changes in unrelated services stop here.

Dependency-install stage. This stage copies only manifests and the pruned lockfile, then installs. Its cache key depends on those files alone, so editing source code reuses it. Adding a dependency rebuilds it.

Builder stage. This stage copies the pruned source on top of installed dependencies and compiles. Any source edit in the service or its internal packages invalidates it, which is expected.

Runtime stage. This stage starts from a slim base and copies only build artifacts and production dependencies. Compilers and dev tooling never ship.

Two pieces sit outside the Dockerfile. An external cache backend, such as a registry the builder can import from and export to, lets ephemeral CI runners reuse layers. An affected-service selector compares the commit against the dependency graph and builds only services whose inputs changed.

Per-service Dockerfiles

Each service keeps its Dockerfile next to its code, so ownership and review follow the service. Shared base images or common stages hold the toolchain and runtime. Services then differ only in which package they prune for and what they copy out at the end.

+----------------------------+      +------------------------------+
| Repo root = build context  |      | CI: affected-service selector|
| services/*  packages/*     |      | commit diff vs dep graph     |
+-------------+--------------+      +--------------+---------------+
              |                                    | only changed svcs
              v                                    v
+----------------------------+      +------------------------------+
| .dockerignore filter       |      | services/<svc>/Dockerfile    |
| drops .git, node_modules,  |      | reuses shared base stages    |
| build output, fixtures     |      +--------------+---------------+
+-------------+--------------+                     |
              |<-----------------------------------+
              v
+----------------------------+
| Prune: svc + internal deps |  out: reduced tree; unrelated
| + trimmed lockfile         |       service edits stop here
+-------------+--------------+
              v
+----------------------------+
| Deps install: manifests +  |  busted by: dependency changes
| pruned lockfile only       |  (source edits reuse it)
+-------------+--------------+     +------------------------------+
              v                    | External cache backend       |
+----------------------------+     | (registry): import + export  |
| Builder: copy src, compile |<--->| layers for ephemeral runners |
+-------------+--------------+     +------------------------------+
              v   builder busted by: any svc or internal pkg edit
+----------------------------+
| Runtime: slim base,        |  ships artifacts + prod deps;
| artifacts + prod deps only |  no compilers or dev tooling
+----------------------------+

The repo-root context is filtered by .dockerignore, then pruned, installed, built and slimmed, with a registry cache and a CI affected-service selector outside the Dockerfile.

Choosing the build context and writing the .dockerignore

Run the build from the repository root and point at the service's Dockerfile with -f, for example docker build -f services/api/Dockerfile .. The trailing . sets the build context. A COPY instruction can only read files inside that context, so if the context were services/api, the shared code under packages/ would be unreachable. A root context makes every shared package visible, and the per-service Dockerfile decides what to use.

The cost is size. Without filtering, the client sends the whole tree to the builder: Git history, every service's source, local dependency installs, and old build outputs. The builder also has to walk that tree on every build. That cost grows as the monorepo grows, even when the service you are building hasn't changed.

The root .dockerignore should exclude anything no image needs:

  • .git
  • node_modules or vendor directories at every depth, using a pattern such as **/node_modules
  • build outputs such as dist, build, and coverage reports
  • local environment files and editor directories

Excluding other services' sources needs a different file, because each build needs a different service. BuildKit checks for an ignore file named after the Dockerfile, such as services/api/Dockerfile.dockerignore. When that file exists, BuildKit uses it instead of the root file. In it you can exclude services/*, re-include !services/api with a negation pattern, and repeat the common exclusions. Patterns are still relative to the context root.

These exclusions also improve caching. Files that never enter the context can't change a COPY checksum and trigger a needless rebuild.

# Root .dockerignore: the fallback for any Dockerfile without its own ignore file.
cat > .dockerignore <<'EOF'
.git
**/node_modules
**/vendor
**/dist
**/build
**/coverage
**/.env*
.vscode
.idea
EOF

# Per-Dockerfile ignore file. BuildKit uses it instead of the root file
# when building with -f services/api/Dockerfile.
# Patterns are relative to the context root, not to services/api.
cat > services/api/Dockerfile.dockerignore <<'EOF'
# Drop every service, then re-include only this one.
services/*
!services/api

# Repeat the common exclusions. The last matching line wins, so these
# come after the negation and also apply inside services/api.
.git
**/node_modules
**/vendor
**/dist
**/build
**/coverage
**/.env*
EOF

# Build from the repo root so packages/ is inside the context.
# The trailing '.' is the context. -f selects the service's Dockerfile.
DOCKER_BUILDKIT=1 docker build \
  -f services/api/Dockerfile \
  -t example/api:dev \
  .

This creates a root .dockerignore and a BuildKit per-Dockerfile ignore file that sends only the api service and the shared packages, then builds from the repository root with -f.

Implementing a per-service Dockerfile: prune, order, cache, slim down

Prune to the service's dependency graph

Start by running your workspace tool's prune step for the target service. It should output only that service, its transitive internal packages, and a lockfile reduced to those dependencies. The build then never sees unrelated packages, so a change in another team's code can't invalidate this image.

Order layers from stable to volatile

The naive Dockerfile copies the pruned tree and then installs. Any source edit changes the COPY checksum, so the install layer and every layer after it rebuild. The improved version copies the manifests and the reduced lockfile first, runs the install, and copies source last. Now trace a one-file edit in a service handler. The base image, manifest copy and dependency install are all reused. Only the source copy and the compile rerun.

Add cache mounts

Use RUN --mount=type=cache,target=... on the install and compile steps, pointed at the package manager's download cache and the compiler's incremental cache. These mounts persist between builds but never become part of the image. Treat them as an optimization only. A fresh CI runner starts with an empty mount, and the build must still succeed and produce the same output.

Slim down with multi-stage

A builder stage compiles the service. The final stage starts from a minimal base and copies only the built output and production dependencies.

Risks

A wrong pruned graph or lockfile can pass locally, where stray files fill the gap, and then fail in CI with missing internal packages. Minimal runtime images may also have no shell, so plan another way to debug them.

# syntax=docker/dockerfile:1
# Assumes a Turborepo + pnpm workspace; service name "api" is a placeholder.
FROM node:20-slim AS base
RUN corepack enable
WORKDIR /app

# 1) Prune: emit only api + its internal deps and a reduced lockfile.
FROM base AS pruner
COPY . .
RUN npx turbo prune api --docker

# 2) Builder: manifests + lockfile first, so source edits keep the install layer.
FROM base AS builder
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
# Cache mount speeds up downloads; an empty cache on CI must still work.
RUN --mount=type=cache,target=/pnpm-store \
    pnpm install --frozen-lockfile --store-dir /pnpm-store
# Source last: a one-file edit reruns only this COPY and the build.
COPY --from=pruner /app/out/full/ .
RUN pnpm turbo run build --filter=api

# 3) Production deps only, resolved from the same reduced lockfile.
FROM base AS prod-deps
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN --mount=type=cache,target=/pnpm-store \
    pnpm install --frozen-lockfile --prod --store-dir /pnpm-store

# 4) Runtime: prod node_modules plus built output, no sources or dev tools.
FROM node:20-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps /app/ .
COPY --from=builder /app/apps/api/dist ./apps/api/dist
# If internal packages compile to dist/, copy those too, e.g.:
# COPY --from=builder /app/packages/shared/dist ./packages/shared/dist
USER node
CMD ["node", "apps/api/dist/index.js"]

A per-service Dockerfile that prunes the workspace, installs from manifests before copying source, uses a pnpm cache mount that tolerates starting empty, and ships only built output plus production dependencies.

Making the cache survive CI and building only what changed

Ephemeral CI runners start from a fresh VM or container, so the local BuildKit cache that makes rebuilds fast on a laptop is empty on every job. Without an external cache, each pipeline run reinstalls dependencies and recompiles every layer, even when nothing in a service changed.

The fix is to export the cache when a build finishes and import it before the next one. With docker buildx build, pass --cache-to type=registry,ref=<registry>/cache/<service>,mode=max and --cache-from type=registry,ref=<registry>/cache/<service>. If your CI platform has its own cache backend that buildx supports, you can use that instead. mode=max also exports the intermediate stages, such as the pruned-workspace and dependency-install stages, not just the final image layers. Give each service its own cache reference. If every service writes to one shared ref, each export overwrites the previous one, so the services evict each other's cache and nobody gets a hit.

Building only what changed

Compute the affected services in CI. Diff the changed paths against the merge base, map each file to the package that owns it, then walk the dependency graph in reverse to find every service that depends on that package, directly or transitively. Pass the resulting list to docker buildx bake or to parallel build jobs. Bake runs independent targets concurrently, but how many builds actually run at once depends on the runner's CPU and memory.

Path-based detection has blind spots. Root config files, the lockfile, toolchain version files and shared build scripts don't belong to any one package, but changing them affects every image. Keep an explicit list of these paths and rebuild everything when one of them changes. Also measure your cache pulls: for a small service, downloading a large remote cache can take longer than just rebuilding it. For those services, turn off --cache-from or reduce what they export.

Verifying the images and the cache

Every Dockerfile change needs a quick check that it still caches and stays small. Three checks cover most problems.

Layer sizes

Run docker history <image> against the final image. Each row is one instruction and how much it added. The only large rows should be the base image, the dependency install, and the built artifact. If a COPY row is large, it probably copied the whole workspace when it should have copied the pruned output. The context or the .dockerignore is letting too much through.

Cache hits on the install layer

Build with --progress=plain so BuildKit prints every step. Change one source file in the service and rebuild. The dependency install step should be marked CACHED. If it runs again, something copied before the install depends on more than the lockfile and manifests. A common cause is a broad COPY . . placed too early, which invalidates every step after it.

Final image contents

Distroless and scratch images have no shell, so you cannot exec in to inspect them. Instead, run docker create on the image, then pipe docker export of that container into tar -t to list its filesystem. Search the listing for directories from other services, test frameworks, compilers, and package-manager caches. If any of these show up, the runtime stage is copying too much from the builder stage.

Checklist after a Dockerfile change

  • The dependency install step shows CACHED after a source-only edit.
  • No unexpectedly large COPY rows appear in docker history.
  • The filesystem listing has no code from other services, no dev dependencies, and no build tools.
  • The image size has not grown compared with the last tagged release.

Trade-offs, when to avoid it, and alternatives

This setup is not free. Skip it or simplify it when:

  • The repo has one or two services. A plain multi-stage Dockerfile per service gets most of the benefit without prune stages or affected-service logic.
  • Your toolchain cannot prune reliably. If you can't extract a correct dependency subset for one service, the pruned context will either miss files or include everything.
  • Nobody owns the shared base stages. Without an owner, they rot.

Expect coupling. A change to a widely used internal package invalidates every image that depends on it. That is correct behaviour, because those images really did change. If it happens on most commits, the package boundary is the problem to fix, not the cache. There is also a trade-off between independent and shared Dockerfiles. Independent per-service Dockerfiles drift apart over time. Shared base stages prevent that drift but make every change a coordinated one.

Approach Strength Weakness
Single whole-repo Dockerfile Simplest to maintain Large images, any change rebuilds everything
Per-service context with vendored packages Small, isolated contexts Copies go stale; sync logic needed
Separate repositories Natural build isolation Cross-repo changes and versioning overhead
Dockerfile-less image builders Deterministic layers from build graph New toolchain to learn and debug

Illustrative estimate. The inputs below are assumptions, not measurements: 10 services, 30 commits per day, 5 minutes per image build. The affected-service logic is assumed to build 2 images per commit instead of 10.

  • Time saved: 8 builds × 5 minutes × 30 commits = 1,200 runner-minutes per day.
  • Time added: an assumed 20-second prune stage on each of the 60 remaining builds adds about 20 minutes per day.
  • Other costs: registry storage and transfer for the remote cache, plus ongoing upkeep of the ignore files and the affected-service logic.

Under these assumptions, the savings are far larger than the added costs.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to monorepo docker build, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.