Docker Builds: 5 Bazel Image Build Patterns in a Monorepo

2026/02/21

BUILDbazeldocker🍞

I recently went down a rabbit hole tracing how container images get built in a large monorepo. Not reading docs — actually following the code path from BUILD file to registry push, grepping through macros, reading the Python scripts that do the real work. What I found was a set of patterns that are genuinely clever, and more importantly, stealable for anyone running Bazel at scale.

The repo uses rules_oci (not the older rules_docker) with two custom macros layered on top: one for Python services, one for Go/compiled binaries. Base images come from Azure Container Registry. Pretty standard foundation — the interesting stuff is what they built on top of it.

The Star of the Show: Layer Splitting for Cache Efficiency

This is the pattern that made me stop scrolling and actually read the code twice.

A Python script called mtree_filter.py takes a binary’s runfiles and classifies every file into one of 8 distinct layers:

Layer Contents Change Frequency
0 Prefix directory (/openai) Basically never
1 Bazel tools (runfiles helpers) Rarely
2 Python interpreter (rules_python) When Python upgrades
3–5 Internal repo deps (up to 3 layers) Occasionally
6 pip dependencies (third-party Python) When deps change
7 App code — your code Every commit

The classification is regex-based:

BAZEL_TOOL_REGEX = r"\.runfiles/bazel_tools/.*"
PY_INTERPRETER_REGEX = r"\.runfiles/(rules_python\+\+python\+.*)"
PIP_DEPENDENCIES_REGEX = r"\.runfiles/rules_python\+\+pip\+.*"
MAIN_REPOS_REGEX = r"\.runfiles/\+.*"
# everything else → main_app

Here’s why this matters. OCI images are a stack of layers, and registries deduplicate by layer digest. When you push an updated image, only the changed layers actually upload. Without splitting, your entire binary and all its deps are one fat layer — change one line of app code, re-upload hundreds of megabytes.

With this splitting, most pushes only upload layer 7 — the app code. That’s often single-digit megabytes. The Python interpreter (tens of MB), pip deps (potentially hundreds of MB), and everything else? Already in the registry from last time. Same digest, zero upload.

In a CI system that builds and pushes hundreds of images per day, this is the difference between “image push takes 45 seconds” and “image push takes 3 seconds.” Multiply that across a fleet and you’re saving real hours of compute time.

The elegance is in the ordering too. Layers are stacked from least-frequently-changing (bottom) to most-frequently-changing (top). This maximizes the number of layers that stay cached across builds. It’s the same principle as putting COPY requirements.txt before COPY . . in a Dockerfile, but taken to its logical extreme with eight layers and automated classification.

Pattern: Base Images Pinned via Plain Text Files

This one is simple and brilliant. Instead of hardcoding image digests in BUILD files or MODULE.bazel:

A plain .txt file contains the full registry reference with its sha256 digest:

openaiapibase.azurecr.io/api/base:applied-minimal-base-71@sha256:6f917b31d8b8...

A custom bzlmod extension (oci_pull_from_txt) reads this file and calls oci.pull under the hood. But here’s the trick — the same .txt file is also read by non-Bazel Docker builds elsewhere in the repo.

One source of truth. Two build systems. No drift.

Updating the base image is a one-line change to a text file. Any reviewer can see exactly what changed. Grep finds it instantly. No macro invocations to parse, no indirection to chase. When you have hundreds of engineers and multiple build systems all needing to agree on which base image to use, this kind of simplicity is a feature, not a shortcut.

Pattern: The Registry Is Never Hardcoded

The push targets in the repo look like this:

repository = "SHOULD_BE_PASSED_ON_CMD_LINE"

Yes, literally that string. The CI system passes the actual registry (staging vs. production ACR) at build time. If someone accidentally runs a push locally without the flag, they get an error about a registry called SHOULD_BE_PASSED_ON_CMD_LINE not existing, which is about the clearest failure mode you could ask for.

This is intentional friction. You physically cannot push to prod from your laptop without going out of your way to override it. The blast radius of a fat-fingered command drops to zero. It’s the kind of thing that sounds obvious when you describe it but that surprisingly few repos actually enforce.

Pattern: Push Telemetry

Every image push automatically generates Datadog metrics: uncached layer bytes, total image size, push count. This is the kind of instrumentation that pays for itself the first time someone asks “why did our images get 200MB bigger last month?”

Without telemetry, image bloat is invisible. It creeps in one dependency at a time. By the time someone notices CI is slow, you’re uploading gigabytes of layers that haven’t meaningfully changed. With per-push metrics, you can set alerts, track trends, and catch the PR that added a 150MB dependency before it ships.

Pattern: System Packages as Layers via rules_distroless

When you need actual deb packages in the image — say, libssl or curl — they’re declared in a YAML file (e.g., noble.yaml), resolved by rules_distroless, and added as tar layers. Platform transitions handle cross-compilation so you can build on macOS but target Linux amd64.

This keeps the image definition declarative. No RUN apt-get install in a Dockerfile somewhere. No mystery about what’s installed. The package list is version-controlled, reviewed, and reproducible. Bazel’s cache means you don’t re-resolve packages on every build, and the layer separation means unchanged system packages don’t re-upload.

The Gotchas (Because There Are Always Gotchas)

A few sharp edges I found while reading through the codebase:

node_modules in a Python image will blow up. The layer classification script doesn’t know what to do with JavaScript dependencies and throws a ValueError. If your Python service happens to depend on something that pulls in node_modules, you need to build those separately using js_image_layer and pass them via extra_layers. The error message is actually helpful here — it tells you what to do — but it’ll surprise you the first time.

Deb symlink conflicts. Some deb packages create symlinks from /bin to /usr/bin (or vice versa), and when you layer multiple packages, those symlinks can conflict. This manifests as mysterious “file already exists” errors during image assembly.

Layer depth limits. OCI images have a maximum layer count. If you’re pulling in a lot of deb packages as individual layers, you can hit this limit. The fix is merge_deb_layers = True, which collapses them into a single layer. You lose some cache granularity, but you stop hitting depth errors.

Conventions aren’t always consistent. At least one subtree in the repo has its own completely different image build conventions. In a large enough monorepo, you get archaeological layers of “well, that team did it differently three years ago.” The core patterns described here are the current blessed path, but grep will find fossils.

What’s Reusable Here

If you’re running Bazel with rules_oci in your own monorepo, here’s what I’d steal, in order of impact:

  1. Layer splitting by change frequency. This is the big one. Write a script that classifies your runfiles into layers ordered from “never changes” to “changes every commit.” The specific regex patterns will differ for your repo, but the principle is universal. Your CI will thank you.

  2. Base image pinning in plain text files. Especially if you have multiple build systems or want simple, auditable base image updates.

  3. Sentinel registry values. SHOULD_BE_PASSED_ON_CMD_LINE is a zero-cost guardrail against pushing to the wrong place.

  4. Push telemetry. You can’t manage what you can’t measure, and image size is one of those things that only ever goes up unless you’re watching.

  5. Declarative system packages. rules_distroless with a YAML manifest beats apt-get install in a Dockerfile for reproducibility.

None of these patterns require a massive monorepo to be useful. A team with 10 services and a shared base image would benefit from most of them. The monorepo just makes the payoff more dramatic — when you’re building hundreds of images, saving 90% of upload bytes per push adds up fast.

The underlying insight is that container images aren’t opaque blobs — they’re layered caches, and the more carefully you structure those layers around your actual change patterns, the less work your CI does on every push. The mtree_filter.py script is maybe 200 lines of Python, but it probably saves more compute hours per week than most optimization projects that take a quarter to ship.