Skip to main content

Your Dockerfile Cache Is Blind to Your Monorepo: What Build-Graph Caching Adds to a Git-Push PaaS Pipeline

11 min readDora NodaDora Noda
Share
On this page

One line changed. Eleven minutes of build. You fixed a typo in a comment inside apps/web, pushed, and watched your platform rebuild the API, the worker, and every shared package as if the whole repository had been rewritten. Nothing was wrong with your Docker layer cache. It did exactly what it was designed to do — which is precisely the problem.

A git-push PaaS sees your monorepo the way a Dockerfile does: as an ordered list of filesystem snapshots. Change one byte in an early COPY, and every layer below it is garbage. But your monorepo already knows that a comment in apps/web affects exactly one app. That knowledge lives in the build graph — Bazel's action graph, Turborepo's task pipeline — and a cache keyed on the graph instead of the layers reuses everything the graph proves is unaffected.

The payoff, worked out below on a typical three-app monorepo: a leaf-package change drops from roughly eleven builder-minutes to about four, a cut of over 60%, for cache storage that costs about a dollar a month. This post shows the math, explains why the two caches behave so differently, maps the September 2026 vendor landscape, and answers the platform question: should a PaaS offer graph-aware caching itself, or leave it to tenants upstream of the build?

The worked example: one change, three ways​

Take a representative tenant repo: a Turborepo monorepo with three apps (web, api, worker) and six shared packages, deployed as one PaaS service per app. The deploy pipeline for web runs install, then the affected build, test, and typecheck tasks, then the OCI image build. The change is a one-line comment edit in apps/web — a leaf change that touches nothing any other task depends on. Assume a 2-vCPU builder, where the full affected-task set takes about 20 tasks at roughly 35 seconds each.

Cold build, no cache: ~12 minutes. Install takes about a minute, all 20 tasks run (~11 minutes of task time with some parallelism, call it 10 wall-clock), and the image build adds ~90 seconds. Twelve builder-minutes is the baseline everything else is measured against.

Docker layer cache only: ~11 minutes. A well-crafted Dockerfile copies lockfiles first, so the dependency-install layer hits and saves about a minute. Then COPY . . picks up the one changed line and invalidates every layer below it: all 20 tasks re-run because layers cannot express "only web changed," and the image layers rebuild. Savings versus cold: roughly one minute, under 10%. This is the ceiling of layer caching on a monorepo, not a misconfiguration. Depot's own documentation is explicit that its container-build cache is a separate, project-scoped Docker cache — fast NVMe-backed BuildKit layers, but layers all the same (Depot container builds).

Build-graph remote cache: ~4 minutes. turbo build hashes every task's inputs, replays 17 cache hits from the remote cache (~30 seconds of artifact fetch), and re-runs only web's three tasks (~2 minutes). The image layers for the one deployed service still rebuild (~90 seconds) because the build output genuinely changed. Total: about four builder-minutes — roughly 65% off the cold build and 64% off the layer-cache-only run. That sits squarely inside the 50–80% pipeline-time-reduction range practitioners report for Turborepo remote caching (monorepo standards notes), and below Vercel's headline claim of 85%+ build speedups with 55 years of cumulative Remote Cache time saved (iJS interview with Jared Palmer).

Now the sensitivity the headline needs. The leaf change above is the good case for graph caching. Flip it: a one-line change in a shared ui package that all three apps depend on. The graph cache still skips the unaffected branches (config, tsconfig, worker-only tasks), but ui plus all three app builds re-run — savings shrink to roughly 25–30% off cold.

The honest rule: graph caching saves exactly the tasks the graph proves unaffected, so its value scales with graph width and change locality, not with cleverness. A single-package repo gains nothing; a ten-package monorepo with leaf-heavy changes gains enormously.

The cost math, using metered list prices from September 2026: a 2-vCPU/4GB Linux builder on Namespace bills $0.002 per minute prepaid (Namespace pricing), so the leaf change costs $0.022 in layer-cache-only compute versus $0.008 with graph hits — $0.014 saved per deploy. Fifty deploys a day over 22 workdays saves about $15 a month in compute against roughly $1 a month for 5 GB of Turborepo cache at $0.20/GB-month. The money is trivial either way. The product is the minutes: seven builder-minutes back per deploy, multiplied by every frustrated developer waiting on a green check.

Scope note: this example uses Turborepo; the Bazel case follows the same shape with different vocabulary. A Bazel action cache maps a hash of the action to its recorded result, and a content-addressable store holds the output blobs by content hash; on a hit, Bazel downloads outputs instead of executing anything (Tuist on Bazel remote caching, REAPI reference). Namespace prices its Bazel cache at $0.10 per 1,000 cache hits with CAS storage at $0.20/GB-month and CAS reads included — a 1,000-action build at a 90% hit rate costs $0.09 in hit charges (Namespace pricing) — and optionally runs the actions themselves on its own workers via remote execution (Namespace Bazel docs).

Why the numbers differ: two invalidation models​

Docker layer caching is positional. Each instruction produces a snapshot; a cache hit requires every byte of every prior instruction's inputs to be identical. The unit of reuse is "everything above this line," and a monorepo's COPY . . means one changed file redraws the line near the top. Multi-stage builds and careful layer ordering push the line down, but they cannot express dependency: the layer cache has no idea that packages/config doesn't import apps/web.

Build-graph caching is semantic. Turborepo hashes each task's declared inputs — source files, dependency versions, environment variables, the task definition itself — and reuses a task's recorded outputs whenever the hash matches. Bazel goes further with hermeticity: actions declare their full input set, and the action cache treats two identical action hashes as the same action regardless of which machine or commit produced them. The unit of reuse is "this exact computation," independent of what else changed in the repo. That is why the leaf change replays 17 hits while the layer cache replays zero useful ones: the graph knows the blast radius, and the layers only know the byte offset.

The important corollary is that the two caches compose rather than compete. The graph cache decides which computations to skip; the layer cache still accelerates the image build itself (base images, installed system packages, the dependency-install layer). Every fast monorepo pipeline in the worked example used both. Anyone selling you graph caching as a replacement for layer caching is confused about which artifact each one produces: only the layer pipeline emits an OCI image.

The market in September 2026​

A year ago this post would have drawn a clean vendor line: Depot and Blacksmith sold fast colocated layer caching next to the runner, while Namespace treated Bazel and Turborepo as first-class cache targets. That line has since blurred — in the customer's favor.

Namespace remains the graph-native reference. Its Turborepo integration is a one-step GitHub Action plus a CLI that mints short-lived TURBO_API/TURBO_TEAM/TURBO_TOKEN credentials, with team-scoped cache isolation and read-only tokens for untrusted pull requests (Namespace Turborepo docs). Its Bazel offering spans cache-only (nsc bazel setup --remote=false) through full remote execution on Namespace workers, including cross-platform routing like driving a macOS build from a Linux host. Compute starts at $0.001/min for 1 vCPU/2 GB Linux with the 2 vCPU/4 GB shape at $0.002/min prepaid; cache storage across Turborepo, Bazel CAS, and Gradle sits at $0.20/GB-month (Namespace pricing).

Depot now plays both games. Its container builds still run on remote BuildKit builders with persistent NVMe layer cache and native Intel plus Arm (claimed up to 40x faster Docker builds — Depot — and 20x versus CI without cache — Depot). But its newer Depot Cache product offers native remote caching for Bazel, Go, Gradle, Turborepo, Nx, sccache, Pants, moonrepo, and Maven behind one service — explicitly keeping each tool's native graph, keys, and invalidation rules rather than forcing everything through one generic archive (Depot Cache, polyglot monorepo guide, updated September 2026). Pricing starts at $20/month (500 Docker build minutes, 25 GB cache and registry storage) with Docker overages at $0.04/min and cache storage at $0.20/GB-month (Depot pricing).

Blacksmith holds the runner-adjacent corner: bare-metal gaming-CPU hosts running ephemeral Firecracker microVMs, a colocated cache restoring roughly 4x faster than stock GitHub cache, and "sticky disk" NVMe volumes for dependency stores and Docker layers (Blacksmith caching docs). Third-party measurements put sticky-disk access at about 3 seconds versus ~66 seconds for stock cache restores, at 400+ MB/s versus ~90 MB/s (migration POC notes). Its pitch is raw runner speed with cache colocation, not graph semantics.

The convergence takeaway: layer speed is now table stakes (everyone has NVMe-adjacent builders), and graph awareness is where vendors differentiate. Depot's expansion proves the categories were never exclusive.

What graph caching does not replace​

Four honest limits before any platform writes a roadmap item. First, the OCI image still gets built, per service, per deploy. Graph hits shrink the computation inside the image build; they don't ship the image. Budget for both caches, because you will run both.

Second, graph caching demands hermeticity, and hermeticity is a tenant discipline. Unhashed environment variables, network access during builds, timestamps baked into outputs — each one silently poisons cache keys or, worse, serves stale outputs as fresh. Bazel enforces hermeticity strictly; Turborepo trusts your turbo.json inputs declarations. A platform that injects cache credentials into undisciplined builds will produce impressively fast wrong answers.

Third, cache trust is a security boundary. A pull request from a fork must never be able to write to the cache your main branch reads, or a malicious PR can poison every subsequent build. This is why Namespace's read-only Turborepo tokens and token-scoped cache/turborepo permissions exist — and any platform-native offering needs the same fork-PR read-only default on day one, not as a follow-up.

Fourth, the PaaS status quo has no graph awareness at all. Railway's monorepo story is a per-service Root Directory plus Dockerfile path (Railway monorepo guide), and other git-push platforms follow the same per-service-settings pattern. That works, but each service still builds independently, with no shared task reuse across a monorepo's services — the exact waste the worked example quantifies.

Platform feature or tenant tool?​

The platform question, answered as a decision rule with both branches named.

Branch A: tenant-side tool, upstream of buildpacks. The tenant brings their own remote cache (Vercel, Depot Cache, Namespace, self-hosted) and the platform simply passes credentials through — TURBO_TOKEN/TURBO_API env vars, a mounted .bazelrc, cache directories that survive between builds. The buildpack or Dockerfile runs turbo build inside the image build and hits the tenant's cache. This is the right default today: zero platform state, the tenant's local machines share hits with CI and deploys, and the platform changes nothing when the tenant switches cache vendors. Its weakness is onboarding friction — every tenant wires credentials themselves — and zero cross-tenant reuse, which is fine because cross-tenant reuse would be a security nightmare anyway.

Branch B: platform-native managed cache. The platform detects turbo.json/package.json workspaces or Bazel workspaces at build time, provisions a per-organization cache namespace, injects short-lived scoped tokens into the build environment, and shares hits across all of that org's services and deploys. Choose this when monorepo tenants are a large enough share of build minutes that per-tenant onboarding friction measurably hurts activation, and when you can commit to the trust model (org-scoped namespaces, read-only-on-forks, retention and eviction policies, usage metering). The tell that you've crossed the threshold: support tickets asking "why did my one-line change take eleven minutes" with the answer being "your graph cache isn't wired into our builders."

The pragmatic path is A now with B's interface: pass through standard credentials today (TURBO_API, TURBO_TOKEN, Bazel --remote_cache flags), and design that passthrough so the platform can later become the endpoint — same env vars, platform-minted tokens, platform-hosted storage — without tenants changing a line. Depot's own guidance points the same way: each tool keeps its native protocol while the storage backend is interchangeable. A PaaS that speaks REAPI and the Turborepo cache protocol on its own builders gets branch B as an upgrade, not a migration.

The verdict​

Docker layer caching answers "which prefix of this Dockerfile is unchanged." Build-graph caching answers "which computations are unaffected by this change." For teams shipping from a monorepo, the second question is the one that determines build time, and the worked example puts the gap at roughly 11 minutes versus 4 for a typical leaf change.

The vendors have already converged on offering both; the remaining question for a git-push PaaS is only where the graph cache lives. Start with passthrough, standardize on the native protocols, and let the platform grow into the endpoint when monorepo minutes justify it. Your tenants' one-line changes will thank you.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex