You are viewing a preview of this lesson. Sign in to start learning
Back to Hermetic Builds

When to Choose Nix vs Bazel

Compare use cases: Nix for system-level reproducibility, Bazel for monorepo builds.

Last generated

Two Different Hermeticity Problems: System Scope vs. Build-Graph Scope

A new hire clones the repo, runs the test suite, and gets a different failure than everyone else on the team sees. Someone eventually tracks it down to a compiler version mismatch on their laptop — nothing to do with the code they wrote. Meanwhile, in the same organization, a different team's CI pipeline is technically running the same compiler everywhere, yet still ships a binary built against a header file that was deleted three commits ago, because the build system didn't know to rebuild that one object file. Both are "the build isn't reproducible." Both get fixed by tools with overlapping reputations — Nix and Bazel — but they are not fixing the same problem, and swapping one in for the other leaves the original failure untouched.

This lesson is about learning to tell those two failures apart before you reach for a tool, so this section draws the line precisely: what each tool actually seals off, and why one seal doesn't cover the other's territory.

Nix's boundary: the whole dependency closure

Nix treats hermeticity as a property of an environment. The unit it seals is everything a piece of software touches to exist at all: the compiler that built it, the C library it links against, the interpreter that runs it, the versions of every tool in between. Nix calls this full set the dependency closure — and it doesn't just document the closure, it addresses it. Every package build (a derivation, covered in depth in "Nix's Mental Model: Reproducible Systems via Content-Addressed Closures") produces a path in /nix/store named by a hash of its exact inputs. If two machines build the same derivation, they get the same hash, and therefore the same store path, because the hash is the identity of the build.

The practical effect: when you ask Nix for python311, you're not asking your OS's package manager to hand you "whatever Python 3.11 happens to be installed" — you're asking for one specific, hash-addressed closure of Python plus every library and system dependency that closure was built against. Two engineers on two different laptops, or a laptop and a CI runner, who both evaluate the same Nix expression get bit-for-bit the same closure.

## shell.nix — pins an entire development environment by content hash
{ pkgs ? import <nixpkgs> {} }:

pkgs.mkShell {
  buildInputs = [
    pkgs.python311      # exact Python closure, not "whatever's on PATH"
    pkgs.gcc12           # exact compiler closure
    pkgs.openssl         # exact system library closure
  ];
}

Running nix-shell against this file gives every developer and every CI runner the identical python311, gcc12, and openssl — down to the shared library versions each was linked against. This is a system-scope guarantee: it says nothing about which files inside your application changed since last time, only that the ground you're standing on is identical everywhere.

Bazel's boundary: the declared inputs and outputs of each action

Bazel treats hermeticity as a property of a build step, not an environment. It assumes a toolchain already exists on the machine and asks a narrower question repeatedly: for this one compile, or this one test run, what exactly goes in, and what exactly comes out? Each such step is an action with an explicitly declared input set and output set (the action graph itself is the focus of "Bazel's Mental Model: Hermetic Build Graphs via Sandboxed Actions"). Bazel sandboxes the action so it can only see its declared inputs — not the rest of the filesystem, not the network, not environment variables you forgot to pass through.

## BUILD file — this cc_library's hermeticity is scoped to its declared srcs/hdrs
cc_library(
    name = "parser",
    srcs = ["parser.cc"],
    hdrs = ["parser.h"],
    deps = ["//lib:tokenizer"],
)

When Bazel builds parser, the sandbox exposes exactly parser.cc, parser.h, and the declared dependency on //lib:tokenizer — nothing else on disk. If parser.cc secretly #includes a header that isn't listed anywhere in this graph, the build fails loudly in a correctly configured sandbox, rather than quietly succeeding on one machine because that header happened to be lying around. But notice what this guarantee does not cover: it says nothing about whether the gcc binary Bazel invokes to compile parser.cc is the same gcc on every machine running this build. Bazel's sandbox is scoped to the action's declared files, not to the toolchain that processes them.

The two failures, side by side

The two opening scenarios map directly onto these boundaries.

Scenario🔍 Root cause🔒 Scope of the failure🎯 Tool that targets it
New hire's tests fail differentlyDifferent Python/compiler versions across machinesSystem-level environment💡 Nix
CI ships a binary against a deleted headerIncremental build didn't detect a stale dependencyBuild-graph correctness within one repo🔧 Bazel

The Python version mismatch is a system-scope failure: nothing about the code changed, and no build graph inside the repo is wrong — the ground beneath every build differs from machine to machine. Nix's content-addressed closures exist precisely to make that ground identical everywhere, so the class of bug disappears rather than getting patched case by case.

The stale header, by contrast, is a build-graph-scope failure: every machine may well have the exact same compiler installed, and the source tree is identical — the bug is that the build system's dependency tracking failed to notice one file's inputs changed, so it reused an out-of-date compiled object instead of rebuilding it. Bazel's action graph exists to make that class of bug structurally hard to hit: because every action declares its inputs, a changed header invalidates every action that declared a dependency on it, and Bazel's sandbox will also refuse to build an action that reads a header it never declared.

Why neither boundary substitutes for the other

🎯 Key Principle: Bazel's sandbox assumes a working, hermetic toolchain is already sitting on the host machine — it seals what a build step can see, not what compiler produced the bits that ran the step. If two CI runners have subtly different system-installed compilers, Bazel will happily produce two different binaries from the identical source tree, because "which compiler runs" was never part of the input set Bazel's sandbox was checking. That gap is exactly what the combined pattern in "Choosing (or Combining) Nix and Bazel: A Decision Framework" closes, by having Nix supply the toolchain Bazel then sandboxes around.

Conversely, Nix has no concept of a build graph within a project. It can hand you an identical gcc and an identical set of libraries on every machine, but it has no notion of "only recompile the three files that changed since the last commit," no test sharding, no remote cache of individual compilation results keyed to file-level hashes. If your monorepo has thousands of source files across several languages, Nix's closure model gives you a reproducible environment to build in — it doesn't give you a fast, correct incremental build of that codebase. That gap is the entire reason Bazel's action graph exists, and it's covered fully in "Bazel's Mental Model: Hermetic Build Graphs via Sandboxed Actions."

💡 Mental Model: Picture Nix as sealing the room — the walls, the air, the tools bolted to the workbench — identically everywhere. Picture Bazel as tracking, inside a room that's assumed already stable, exactly which of thousands of small tasks need redoing because a specific ingredient changed. A perfectly sealed room doesn't tell you which tasks are stale; a perfect task tracker is useless if the room's tools silently differ from one copy of the room to the next.

⚠️ Common Mistake: Assuming that because both tools use the word "hermetic," fixing one class of failure with one tool automatically fixes the other. Rerunning the Python-version bug through Bazel's sandbox won't help — the sandbox isn't tracking which python3 binary is installed system-wide. Rerunning the stale-header bug through Nix won't help either — Nix has no action graph inside your repository to detect that one file's inputs changed.

A quick sanity check before moving on

Given any hermeticity complaint, two questions separate which half of the problem you're facing: does the same source tree produce different results on different machines (system scope), or does the same machine produce a stale or inconsistent result after only part of the tree changed (build-graph scope)? A team debugging "CI passes but the artifact behaves differently in production" should ask which of these two questions the discrepancy actually answers before reaching for either tool — reaching for Bazel to fix a toolchain drift problem, or reaching for Nix to fix a stale-object problem, spends real setup effort on a boundary that was never the one that broke.

Nix's Mental Model: Reproducible Systems via Content-Addressed Closures

With the system-scope vs. build-graph-scope distinction in place, the next step is to see concretely how Nix earns its system-scope guarantee — not through convention or discipline, but through a specific mechanical trick applied at every layer of a dependency tree.

Derivations as Pure Functions

A derivation is Nix's unit of buildable work: a description of a build step that takes a fixed set of inputs — source code, a build script, and references to other derivations it depends on — and produces an output. The defining property is that a derivation behaves like a pure function: given the exact same inputs, it always produces a result stored at the exact same path. Nix computes that path by hashing the derivation's inputs (not the output — the description of how to build it), and uses the resulting hash as part of the output's location on disk, inside /nix/store.

Concretely, building a package produces a path that looks like this:

/nix/store/kq9y0d3f8j2xvz1m7c4b6n5p8w0e2r1t-openssl-3.2.1

That leading string isn't arbitrary — it's derived from a hash of everything that went into the build: the exact source tarball, the exact compiler, the exact build flags, the exact patches. Change any one of those inputs and you get a different hash and therefore a different store path; the old path is left untouched. This is why Nix installs are additive rather than destructive — upgrading a library doesn't overwrite anything, it just adds a new hash-addressed path alongside the old one, which is also how multiple versions of the same library can coexist on one machine without conflict.

A minimal derivation in the Nix language makes this explicit:

## default.nix — describes how to build a small tool from source
{ pkgs ? import <nixpkgs> {} }:

pkgs.stdenv.mkDerivation {
  pname = "hello-tool";
  version = "1.0.0";

  src = pkgs.fetchurl {
    url = "https://example.org/hello-tool-1.0.0.tar.gz";
    sha256 = "1a2b3c4d5e6f7g8h9i0jklmnopqrstuvwxyz1234567890abcdefghijk";
  };

  # Every dependency listed here becomes part of this derivation's
  # input hash — and part of its dependency closure.
  buildInputs = [ pkgs.zlib pkgs.openssl ];

  buildPhase = "make";
  installPhase = "make install PREFIX=$out";
}

Running nix-build against this file computes a hash from the source tarball's checksum, the exact versions of zlib and openssl pulled in, and the build instructions themselves, then places the result at a store path keyed by that hash. Run the build again — on the same machine or a different one — and if none of those inputs changed, Nix either reuses the existing path or reproduces the identical one; it never silently substitutes a different openssl because a newer one happened to be sitting on the host's PATH.

The Dependency Closure

A single derivation rarely stands alone — hello-tool depends on openssl, which depends on its own build toolchain, which depends on further libraries. The full set of hash-addressed paths required to build or run something is its dependency closure. Nix computes this closure explicitly and pins every member of it by hash, which is the mechanism that eliminates reliance on whatever happens to be installed system-wide.

hello-tool (hash A)
  ↓ depends on
openssl (hash B)
  ↓ depends on
zlib (hash C)
  ↓ depends on
glibc (hash D)

Each arrow in that chain is a hash reference, not a name lookup. A traditional package manager resolves "openssl" against whatever is currently registered on the system — which is exactly the mechanism that produces "works on my machine": one laptop has openssl 3.0, another has 3.2, and a build that implicitly links against "whichever openssl is on the path" behaves differently on each. Nix's closure instead says "this specific hash of openssl, built from this specific hash of zlib," so the same closure resolves to the same set of store paths everywhere it's evaluated, regardless of what else is installed on the host.

You can inspect a closure directly:

## List every store path that this build depends on, transitively
nix-store -q --requisites /nix/store/r5t8y1u3i6o9p2a4s6d8f0g2h4j6k8l0-hello-tool-1.0.0

The output is a flat list of hash-addressed paths — the concrete, inspectable evidence that "the closure" isn't a metaphor but an enumerable set of pinned dependencies.

Pinning a Whole Environment

Because closures compose, an entire toolchain or development environment can be captured as a single pinned expression and reproduced identically elsewhere. A Nix flake pins not just individual packages but the entire package set (nixpkgs) they're drawn from, at a specific commit:

## flake.nix — pins nixpkgs to one exact commit, so every package
## resolved through `pkgs` below is fixed for every user of this flake
{
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0";

  outputs = { self, nixpkgs }:
    let
      pkgs = import nixpkgs { system = "x86_64-linux"; };
    in {
      devShells.x86_64-linux.default = pkgs.mkShell {
        buildInputs = [ pkgs.python311 pkgs.rPackages.tidyverse pkgs.gcc ];
      };
    };
}

Anyone who runs nix develop against this flake — on their laptop, on a CI runner, on a fresh cloud instance — gets Python, R with tidyverse, and gcc resolved from the same commit of nixpkgs, which means the same hashes, which means the same binaries. There's no "install these versions" instruction sheet to go stale; the pin itself is the instruction sheet, and it's checked into version control alongside the code.

🎯 Key Principle: Nix's guarantee is that a given input closure always maps to the same output paths — it says nothing about when to rebuild something or which parts of a large project changed since the last build. That question belongs to a different mental model entirely, covered under Bazel's action graph in "Bazel's Mental Model: Hermetic Build Graphs via Sandboxed Actions."

What This Model Targets — and What It Leaves Alone

The closure-and-derivation approach is well suited to three recurring problems: giving every developer an identical shell (same compiler, same interpreter, same native libraries) regardless of their laptop's prior state; describing whole-system configuration so that an OS-level environment can be reproduced on a new machine from a declarative spec; and packaging software that spans several language ecosystems — say, a project mixing Python, R, and a native C library — under one pinned dependency graph rather than juggling separate package managers that don't know about each other.

What it does not provide is any notion of a build graph inside a large application. A closure tells you that hello-tool needs exactly this openssl and exactly this zlib — it has no concept of "only 40 of this monorepo's 2,000 source files changed since the last commit, so rebuild only the actions that depend on them." Nix derivations are typically treated as atomic: rebuild the whole derivation, or don't. ⚠️ Common Mistake: assuming that because Nix reproducibly builds each package, it also gives you fast, fine-grained incremental rebuilds across a large multi-language codebase — that's a distinct capability, and it's the specific gap the build-graph model in the next section is designed to fill.

💡 Mental Model: Think of a Nix derivation as a mathematical function build(inputs) -> output_path where equality of inputs guarantees equality of output location — the store path is essentially a cache key computed over everything the build could possibly be sensitive to. This is a simplified framing; in practice a handful of Nix's build primitives (fetchers that hit the network for a fixed-hash download, for instance) still require an explicit, declared hash to remain part of that pure picture rather than an unpinned escape hatch.

🤔 Did you know? Because store paths are content-addressed, two entirely unrelated projects that happen to depend on the identical hash of a library share the exact same /nix/store entry on disk — Nix builds it once and every consumer reuses that one copy, which is why large Nix-based systems don't multiply disk usage the way separate per-project virtual environments often do.

The practical upshot is a sharp line: Nix's closure answers "will this environment behave identically everywhere it's built," while questions about rebuilding only what changed inside a sprawling codebase sit outside that model and are picked up by the action-graph approach covered next.

Bazel's Mental Model: Hermetic Build Graphs via Sandboxed Actions

Where Nix seals an entire environment, Bazel takes the opposite bet: leave the host machine alone, and instead seal every individual step of the build so tightly that it cannot cheat, even if the surrounding system is messy.

The Action Graph: Nodes With Declared Inputs and Outputs

Bazel does not think in terms of "run this shell script and hope." It thinks in terms of an action graph — a directed graph where every node is a single build or test step (compile this file, link this binary, run this test), and every edge represents a data dependency. Each action declares, up front, exactly which files it reads (its inputs) and exactly which files it produces (its outputs). Nothing else is supposed to matter.

This matters practically because Bazel uses that declaration to decide what to rerun. If you change one source file in a thousand-file repository, Bazel does not recompile the thousand files — it walks the graph, finds the actions whose declared inputs changed, and reruns only those, plus anything downstream of them.

## BUILD file for a small Bazel workspace (using the cc_* rules)
## Each rule below becomes one or more nodes in the action graph.

cc_library(
    name = "parser",
    srcs = ["parser.cc"],
    hdrs = ["parser.h"],
    deps = [":tokenizer"],
)

cc_library(
    name = "tokenizer",
    srcs = ["tokenizer.cc"],
    hdrs = ["tokenizer.h"],
)

cc_test(
    name = "parser_test",
    srcs = ["parser_test.cc"],
    deps = [":parser"],
)

Here, parser declares tokenizer as a dependency, and parser_test declares parser. If you edit tokenizer.cc, Bazel reruns the compile action for tokenizer, the compile/link actions for parser (since it depends on tokenizer), and the test action for parser_test — but nothing in unrelated parts of the workspace, because the graph shows no edge connecting them.

🎯 Key Principle: the action graph only gives correct incremental results if the declared inputs and outputs are complete. An action that quietly reads a file it never declared is a lie the graph has no way to catch on its own — which is exactly the problem sandboxing exists to solve.

Sandboxing: Turning Hidden Dependencies Into Build Failures

Declaring inputs is a promise; sandboxing is what enforces it. When Bazel runs an action inside a sandbox, it constructs a restricted filesystem view containing only the files that action declared as inputs. If the action's underlying tool tries to read anything else — a stray config file in the user's home directory, a header that exists on disk but was never listed as a dependency, a binary picked up from a directory earlier in $PATH — that read fails, because the file simply isn't visible inside the sandbox.

This converts a dangerous class of bug into a loud, early one. Consider a C++ target that compiles fine on one engineer's machine because a system header happens to be installed globally there, but was never declared in the BUILD file:

cc_library(
    name = "image_codec",
    srcs = ["image_codec.cc"],
    hdrs = ["image_codec.h"],
    # Bug: image_codec.cc does '#include <turbojpeg.h>' but this
    # dependency was never added below.
    deps = [],
)

Without sandboxing, this builds successfully on any machine that happens to have turbojpeg.h sitting in a system include path, and fails mysteriously in CI or on a teammate's laptop that lacks it — the classic "works on my machine" bug. With sandboxing enabled, the compile action can't see turbojpeg.h at all, regardless of what's installed on the host, so the build fails immediately with a missing-header error on the very first build, on every machine, including the one where it would otherwise have "worked." The undeclared dependency is caught at the moment it's introduced rather than discovered weeks later when someone else's environment differs slightly.

⚠️ Common Mistake: assuming a green build proves the dependency graph is complete. A build that passes without sandboxing can still be silently relying on host state; sandboxing is what actually tests the graph's honesty, not just its existence.

Content Hashing, Caching, and Remote Execution

Because every action's inputs are fully declared, Bazel can compute a hash over those inputs (source file contents, flags, and the versions of tools involved) and use that hash as a cache key. If the hash for an action matches one Bazel has seen before — on this machine or a different one — it can skip rerunning the action and reuse the previous output.

This is what makes remote caching and remote execution work at all: they depend on the guarantee that identical inputs really do produce identical outputs, which is only true if the action was sandboxed and nothing outside its declared inputs leaked in. A CI runner and a developer's laptop can compute the same hash for the same compile step and share a cache entry, so the second machine to build that exact step gets the artifact instantly instead of recompiling it. Remote execution takes this further by dispatching actions to a pool of worker machines, since any worker can execute any action correctly — the sandbox means the result doesn't depend on which physical machine ran it.

💡 Mental Model: think of each action as a pure function from a content hash to an output. Caching and remote execution are just consequences of that purity — the same reasoning that makes memoization work for a recursive function works for a build graph, provided the "function" truly has no hidden inputs.

One Graph Across Languages

A single Bazel workspace can hold Python libraries, Java services, Go binaries, and native C++ code side by side, each described with language-specific rules (py_library, java_binary, go_binary, cc_library, and so on), all participating in the same action graph. A Java service can depend on a Python-generated protobuf schema, which depends on a .proto file compiled by yet another rule — and Bazel tracks the whole chain as one dependency graph rather than as separate, disconnected build systems that happen to live in the same repository.

## A polyglot dependency: a Go binary depends on generated code
## produced by compiling a .proto file, which is itself a build action.

proto_library(
    name = "user_proto",
    srcs = ["user.proto"],
)

go_proto_library(
    name = "user_go_proto",
    proto = ":user_proto",
)

go_binary(
    name = "user_service",
    srcs = ["main.go"],
    deps = [":user_go_proto"],
)

This is the practical payoff for a large monorepo: one change to user.proto triggers exactly the regeneration and recompilation steps that depend on it, across language boundaries, without the team needing to hand-wire Makefiles or separate CI jobs per language and manually keep them in sync.

What This Model Deliberately Leaves Out

Bazel's sandbox seals off actions from each other and from stray files on disk, but it does not seal off the toolchain itself from the host operating system. The compiler binary, its shared libraries, and the OS kernel underneath it are, by default, whatever is installed on the machine running Bazel — Bazel assumes it has been handed a working, consistent toolchain and focuses on making sure that toolchain is applied identically and only to declared inputs. If two machines have different compiler versions installed and both are pointed at as "the C++ toolchain," Bazel's sandbox will faithfully use whichever one is configured on each machine, and the two builds can still diverge — the action graph and sandbox never claimed to fix that.

This is precisely the gap that motivates pulling in system-level tooling, an idea developed fully in "Choosing (or Combining) Nix and Bazel: A Decision Framework": pinning the toolchain itself by content hash closes the one hole that action-level sandboxing was never designed to close.

Choosing (or Combining) Nix and Bazel: A Decision Framework

With both mental models in hand, the practical question is: given a real project, which one do you reach for — and is it ever both? The two decision cues below turn the abstract boundary distinction into a checklist you can apply to an actual repository.

Decision Cue 1: What Scope Is Broken?

Ask what kind of failure you're actually trying to prevent. If the failure sounds like "it built on my machine but not on Priya's machine" or "CI has a different compiler version than any laptop," that's a system-scope failure — every developer's machine and every CI runner needs an identical toolchain and identical OS-level dependencies. That points to Nix.

If the failure sounds like "the incremental build reused a stale artifact and shipped a bug" or "a 40-minute CI run only needed to rebuild two of two hundred packages but rebuilt all of them," that's a build-graph-scope failure — a large, often polyglot repository needs to rebuild only what changed, and rebuild it correctly. That points to Bazel.

Symptom: "works on my machine, not on CI"        → system scope   → Nix
Symptom: "stale build reused old compiled output" → build-graph    → Bazel
Symptom: "CI rebuilds everything on every commit"  → build-graph    → Bazel
Symptom: "toolchain version differs across laptops"→ system scope   → Nix

Decision Cue 2: What Unit Are You Sealing?

The first cue asks about the symptom; the second asks about the unit of sealing, and it's a useful gut-check because it maps directly to what each tool was built to hash and cache. Nix seals a whole environment — a shell, a package, an entire toolchain closure — as one content-addressed unit. Bazel seals individual build or test actions — a single compile step, a single test invocation — as separate nodes in a graph. If you find yourself wanting one sealed thing that everyone drops into and works inside, you want Nix's unit. If you find yourself wanting thousands of small sealed steps that get cached and rerun independently, you want Bazel's unit.

Cue🔒 Points to Nix🔒 Points to Bazel
Symptom🖥️ toolchain/OS mismatch across machines📦 stale or slow incremental builds
Unit sealed🌍 whole environment🔧 single build/test action
Granularitycoarse (one closure)fine (per-action graph)
Typical scalefleet of machinessingle large repo

Worked Scenario 1: The Cross-Language Research Team

Suppose a team ships a data-science product where analysts write R, a backend service is in Python, and a shared numerical routine is a compiled C extension. New hires routinely lose the better part of a day getting pip install and R package installs to agree with what's already running in staging, and a library upgrade on one laptop silently changes results that don't reproduce on a colleague's machine.

This is squarely a system-scope problem: the unit that needs to be identical is the whole environment — Python version, R version, the specific BLAS library the C extension links against, and every native dependency underneath. The team reaches for Nix and defines the environment declaratively:

## shell.nix — one sealed environment for the whole team
{ pkgs ? import <nixpkgs> {} }:

pkgs.mkShell {
  buildInputs = [
    pkgs.python311
    pkgs.python311Packages.numpy
    pkgs.R
    pkgs.rPackages.dplyr
    pkgs.openblas   # the exact BLAS backend, pinned
  ];
}

Running nix-shell on a laptop or on a cloud CI runner drops both into a shell built from the same content-addressed closure, so "which BLAS did you link against" stops being a question anyone has to ask. Bazel has no comparable primitive here: it has no notion of provisioning R itself, only of running actions once a toolchain already exists on the host (a point developed in "Bazel's Mental Model: Hermetic Build Graphs via Sandboxed Actions").

Worked Scenario 2: The Polyglot Monorepo

Now suppose a different team keeps a dozen backend services (Go and Java) and a handful of shared libraries (C++ and Python) in a single repository. Every commit currently triggers a full rebuild and full test run across all twelve services, because the existing build scripts have no way to know that a change to one Go service's internal package doesn't affect the Java billing service at all. CI takes long enough that engineers batch up commits to avoid waiting, which defeats the point of fast feedback.

Here the unit that needs sealing is not "the environment" — the toolchains are already reasonably consistent — it's each individual compile and test action, so that only the actions whose declared inputs changed get rerun. That's Bazel's job:

## BUILD.bazel — an action node with explicit inputs and outputs
go_library(
    name = "pricing",
    srcs = ["pricing.go"],
    deps = ["//shared/currency:currency_go"],
)

go_test(
    name = "pricing_test",
    srcs = ["pricing_test.go"],
    embed = [":pricing"],
)

Because each target's inputs are declared explicitly, a change to pricing.go reruns pricing_test but leaves the Java billing service and every other untouched target's cached results alone. On top of that, because Bazel content-hashes each action's inputs, identical actions across different CI runners or different engineers' machines can reuse a previously computed result instead of recompiling — the remote-caching benefit covered in "Bazel's Mental Model." Nix, by contrast, has no concept of a dependency graph within a single build the way Bazel does; it would happily give every engineer the same Go and Java toolchains, but it has nothing to say about which of the twelve services actually needs rebuilding after a given commit.

The Combined Pattern: Nix Supplying Bazel's Toolchains

The two scenarios above are deliberately clean, but many real repositories have both problems at once: a large polyglot codebase (Bazel's territory) where the compiler versions on different CI runners and laptops still drift (Nix's territory). Recall that Bazel's sandbox seals each action's declared inputs and outputs, but by default it still assumes the compiler, linker, and system headers it invokes already exist correctly on the host — Bazel's hermeticity is scoped to the build graph, not to the machine underneath it. If two CI runners have different system-installed versions of gcc, Bazel will faithfully and reproducibly use whichever one it finds, which means the build graph is hermetic but the toolchain feeding into it is not.

The combined pattern closes that gap: use Nix to build and pin the exact toolchain — the compiler, standard library, and any native dependencies — as a content-addressed derivation, and then point Bazel's toolchain configuration at that Nix-built path instead of whatever the host happens to have on $PATH.

            Nix layer                         Bazel layer
  ┌───────────────────────────┐      ┌───────────────────────────────┐
  │ pinned gcc, glibc, libs    │      │ action graph: compile, test,   │
  │ built to /nix/store/<hash> │ ───▶ │ link — sandboxed per action    │
  └───────────────────────────┘      └───────────────────────────────┘
     seals: which compiler exists        seals: which inputs an action
     and what it links against           can see, and what reruns

Concretely, this means Bazel's toolchain definition stops pointing at /usr/bin/gcc and instead points at the specific /nix/store/<hash>-gcc-<version> path that Nix produced, so that a laptop, a CI runner, and a teammate's machine all invoke byte-for-byte the same compiler regardless of what their OS package manager happens to have installed. The result is a build where both boundaries are sealed at once: Nix guarantees the toolchain itself is identical everywhere, and Bazel guarantees that, given that identical toolchain, only the actions whose inputs actually changed get rebuilt. Wiring this up in full — writing the Bazel toolchain rule that consumes a Nix derivation — is a nontrivial integration exercise, but the mental model above is the part that determines whether you should bother: reach for the combination specifically when you have a Bazel-sized build-graph problem sitting on top of a Nix-sized toolchain-drift problem, not by default.

⚠️ Common Mistake: assuming that adopting Bazel automatically buys you Nix's guarantee, or vice versa. Bazel's sandbox stops a stray file read from leaking into a build; it does nothing about which gcc binary that sandbox happens to invoke. Nix pins that gcc binary perfectly; it has no mechanism for deciding that only three of your two hundred build targets need to be recompiled after a given commit. Each tool's guarantee stops exactly at the boundary it was designed to seal — a distinction the next section revisits in the context of pitfalls that erode these guarantees even after the right tool has been chosen.

Common Pitfalls When Choosing Between Nix and Bazel

The decision framework from the previous section only holds up if the two guarantees stay separate in practice. In real projects, teams quietly blur them — assuming one tool covers the other's job, or reaching for a tool that solves a problem they don't actually have. Each of these mistakes has a recognizable signature, and each has a concrete fix.

Mistake 1: Assuming Bazel Alone Gives System-Level Reproducibility

⚠️ Common Mistake: Teams adopt Bazel, see its sandboxed actions and remote caching, and conclude the build is now hermetic end-to-end. It isn't — not by itself.

Recall that Bazel's sandbox declares and isolates the inputs to each action, but as covered in "Bazel's Mental Model," it assumes a working, hermetic toolchain is already available on the host. If Bazel is configured to invoke whatever gcc or python3 happens to be on PATH, then two CI runners with different compiler patch versions can produce different outputs from an identical action graph — and Bazel will report success on both, because from its perspective the declared inputs matched.

## BUILD.bazel — this rule looks hermetic, but isn't
cc_binary(
    name = "server",
    srcs = ["server.cc"],
    # No toolchain is pinned here — Bazel will resolve
    # a C++ toolchain from the host or a loosely
    # configured default, not a hash-pinned compiler.
)

❌ Wrong thinking: "Bazel sandboxes the build, so the build is hermetic." ✅ Correct thinking: "Bazel sandboxes the action graph; the toolchain feeding that graph still needs to be pinned by something else — often Nix, as covered in the combined pattern from the decision framework."

The fix isn't a Bazel feature at all — it's supplying Bazel with a toolchain whose identity is itself content-addressed, which is exactly the gap Nix-supplied toolchains close.

Mistake 2: Assuming Nix Alone Solves Monorepo Build Performance

The mirror-image mistake: a team frustrated by slow, flaky builds in a large polyglot repo adopts Nix, expecting faster and more correct incremental builds. Nix pins environments with content-hashed precision, but it has no concept of a fine-grained action graph over a codebase's internal files — no built-in tracking of "only this one Go package changed, so only its dependents rebuild," no test sharding across CI machines, and no remote-execution model for distributing individual build steps.

Concretely: a flake.nix can guarantee that every developer and CI runner gets the identical version of go, node, and protoc. It does nothing to tell the build system that changing one .proto file should only trigger rebuilds of the three services that depend on it — that granularity is what Bazel's action graph exists for.

## flake.nix — pins a reproducible toolchain, but has
## no notion of "rebuild only what changed" across
## the repo's internal files
{
  outputs = { self, nixpkgs }: {
    devShells.x86_64-linux.default =
      nixpkgs.legacyPackages.x86_64-linux.mkShell {
        buildInputs = with nixpkgs.legacyPackages.x86_64-linux; [
          go_1_22
          protobuf
        ];
      };
  };
}

🎯 Key Principle: Nix answers "is everyone's environment identical?" Bazel answers "given that identical environment, what's the minimal correct set of work to redo?" Neither answer substitutes for the other, and a team chasing monorepo build speed through Nix alone will find their CI still rebuilds far more than necessary.

Mistake 3: Treating a Docker Image as a Substitute for Either Tool

Shipping a Docker image feels like it should guarantee reproducibility — after all, the whole filesystem is frozen into layers. But a Dockerfile is a script, and scripts can call out to mutable state at build time.

## Dockerfile — produces a working image today,
## but is not a hermetic build
FROM ubuntu:latest
RUN apt-get update && apt-get install -y python3 curl
RUN pip3 install requests flask
COPY . /app
CMD ["python3", "/app/server.py"]

Every RUN apt-get install and pip3 install line here resolves package versions from whatever the package index serves at build time, with no lockfile pinning exact versions or hashes. Build this image today and again in six months, and apt-get install -y python3 can silently resolve to a different Python patch version, requests and flask can resolve to different releases, and the resulting layers will differ even though the Dockerfile text never changed. The image you get is a snapshot of one build's outcome, not a guarantee that the build process itself is repeatable.

💡 Mental Model: A Docker image is a result, not a guarantee. Hermeticity is a property of how the artifact was produced, not a property you inherit for free by shipping the artifact in a container. Nix and Bazel can each produce inputs that go into an image reproducibly, but the image format itself enforces nothing about how those inputs were resolved.

This is a simplified framing — a Dockerfile that pins exact package versions and hashes (or is generated from Nix-built or Bazel-built artifacts rather than live package-manager calls) can be part of a genuinely hermetic pipeline; the failure mode above is specifically about unpinned calls inside the image build, not about container images in general.

Mistake 4: Letting Pins Float Unlocked

Both tools are only as hermetic as their pin discipline. Two failure points look different on the surface but are the same underlying mistake.

On the Nix side, following an unpinned channel means nixpkgs itself moves underneath you:

## shell.nix — this channel reference is a moving target,
## not a fixed point
import <nixpkgs> {}

Every time this resolves against whatever nixpkgs channel is currently registered on a machine, it can pick up a different package set than a colleague running the same file a week later — quietly reintroducing the cross-machine drift Nix was adopted to remove. The fix is pinning nixpkgs itself to a specific commit or using a lockfile-based flake input, so the input to the derivation is fixed rather than "whatever the channel currently points to."

On the Bazel side, the equivalent leak is an external dependency fetched by a floating reference rather than a locked hash:

## WORKSPACE or MODULE.bazel — floating reference
http_archive(
    name = "some_lib",
    urls = ["https://example.com/some_lib/main.tar.gz"],
    # No sha256 pin — the archive behind this URL can
    # change without the build file changing at all.
)

Without a sha256 pin, the archive fetched today is not guaranteed to be the same bytes fetched next month, even though the BUILD file is byte-for-byte identical in version control. Bazel will happily rerun this action and cache the result, but the input itself was never fixed.

📋 Quick Reference Card:

🔧 Tool🔓 Unlocked pin🔒 Fix
NixFollowing a channel (<nixpkgs>)Pin to a commit / flake lock
Bazelhttp_archive with no hashAdd sha256 pin

⚠️ Common Mistake: treating the presence of Nix or Bazel in a project as itself proof of hermeticity, without checking whether every pin the tool depends on is actually locked. The tool provides the mechanism for hermeticity; the pin discipline provides the guarantee.

Mistake 5: Mismatched Tool-to-Problem Scale

The last pitfall isn't about misusing a tool's guarantees — it's about paying for guarantees the project doesn't need.

Adopting Bazel for a small single-language project. Bazel's payoff comes from managing a large action graph across many languages and packages, with remote caching amortizing cost across many CI runs and many contributors. A small single-language service with a handful of files gains little from an explicit action graph — the setup cost (writing BUILD files, learning bzl rules, standing up remote cache infrastructure) can exceed what a language's native build tool already provides for free.

Adopting Nix purely as a general package manager. Nix's payoff is eliminating environment drift across machines — when the actual problem is "our team keeps hitting works on my machine" or "CI has a different library version than developers." If no such drift problem exists — a single developer on a single machine, or a project whose native package manager's lockfile already pins every dependency adequately — introducing Nix mainly adds a new language and mental model to learn, without removing a failure mode that was actually occurring.

🧠 Mnemonic: Scope the pain before you scope the tool. Bazel earns its cost on graph size and polyglot breadth; Nix earns its cost on environment drift across machines. Neither tool's cost is justified by its reputation alone.

Summary

You now know that Nix and Bazel each guarantee hermeticity over a different unit — a whole environment versus an individual build action — and that neither guarantee automatically extends to cover the other's territory. Bazel trusts the host toolchain unless that toolchain is itself pinned (often by Nix); Nix has no answer to "what changed and what needs rebuilding" across a codebase's internal graph. A Docker image is a frozen result, not proof that the process producing it was reproducible, since the commands inside a Dockerfile can still resolve against a moving package index. And both tools' guarantees collapse the moment their own pins — a Nix channel, a Bazel http_archive without a hash — are left unlocked, silently reopening the exact non-hermeticity gap the tool was adopted to close.

📋 Quick Reference Card:

⚠️ Pitfall🎯 What's missed
Bazel = full reproducibilityHost toolchain still untrusted unless pinned
Nix = fast monorepo buildsNo incremental graph, sharding, or remote exec
Docker image = hermetic buildImage can hide unpinned package-manager calls
Unlocked pins (channel / archive)Reintroduces the drift the tool was meant to remove
Tool adopted at wrong scaleHigh setup cost for a problem that doesn't exist yet

As a next step, audit one real build you maintain: check whether its toolchain is pinned by hash, whether any dependency fetch or channel reference is unlocked, and whether the tool in use actually matches the scope of the problem you have. That single pass through the pitfalls above will surface more hidden non-hermeticity than most teams expect from a system they already believed was reproducible.