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.

When to Choose Nix vs Bazel

Mastering hermetic build systems requires understanding when to deploy Nix versus Bazel, with free flashcards helping cement these critical decision-making patterns. This lesson covers the philosophical differences between these tools, their technical trade-offs, and real-world scenarios where each excelsโ€”essential knowledge for building reproducible software systems at scale.

Welcome ๐ŸŽฏ

Choosing between Nix and Bazel isn't just a technical decisionโ€”it's an architectural commitment that shapes how your team builds, tests, and deploys software. Both tools promise hermetic builds, but they achieve this goal through fundamentally different approaches. Nix treats your entire system as an immutable functional program, while Bazel focuses on fine-grained caching of build actions with strict sandboxing.

Think of it like choosing between two navigation systems: Nix is like declaratively describing your destination and letting the system calculate the entire route, while Bazel is like meticulously mapping every turn with checkpoints to ensure you can resume from anywhere. Both get you there, but the journey differs significantly.

In this lesson, you'll learn:

  • The core mental models underlying each tool
  • Performance characteristics and when they matter
  • Ecosystem integration patterns
  • Team and project factors that influence the choice
  • Hybrid approaches for complex requirements

Core Concepts: The Fundamental Divide ๐Ÿ”

Philosophical Foundation

Nix operates on the principle of declarative system configuration. You describe what you want, and Nix's functional language calculates how to build it. Every package is identified by a cryptographic hash of its inputs, creating an immutable store where gcc-11.2.0-abc123 is forever distinct from gcc-11.2.0-abc124 (even if they differ by a single patch).

Bazel operates on the principle of action-level hermeticity. You define explicit build rules (targets) with declared inputs and outputs. Bazel executes these actions in sandboxes, caching results keyed on input hashes. The focus is on incremental correctnessโ€”rebuilding only what changed.

๐Ÿ’ก Key Insight: Granularity vs Scope

DimensionNixBazel
Build UnitPackage (coarse)Target (fine)
ScopeEntire systemProject workspace
LanguageFunctional DSLStarlark (Python-like)
Cache KeyInput hash (recursive)Action input hash
Output Location/nix/storebazel-out/

Technical Trade-offs

Nix's Strengths:

  1. System-Level Reproducibility ๐ŸŒ
    Nix doesn't just build your codeโ€”it builds the entire dependency graph including compilers, libraries, and even system utilities. A shell.nix file can recreate identical development environments across machines, regardless of the host OS (within Linux/macOS constraints).

    # Perfectly reproducible Python environment
    { pkgs ? import <nixpkgs> {} }:
    pkgs.mkShell {
      buildInputs = [
        pkgs.python310
        pkgs.python310Packages.numpy
        pkgs.python310Packages.pandas
      ];
    }
    
  2. Binary Substitution โšก
    Nix's centralized cache (cache.nixos.org) means you often download pre-built binaries instead of compiling. If someone else built nodejs-18.2.0 with the exact same inputs, you get their binary instantly.

  3. Cross-Language Naturally ๐Ÿ”ง
    Since Nix treats everything as packages, mixing C++, Rust, Python, and Go feels uniform. There's no special multi-language build supportโ€”it's all just derivations.

Bazel's Strengths:

  1. Incremental Build Speed ๐Ÿš€
    Bazel's fine-grained dependency tracking means changing one file in a million-line codebase rebuilds only affected targets. Large monorepos (Google-scale) see 10-100x faster iteration cycles.

    # Only rebuilds if utils.cc or its headers change
    cc_library(
        name = "utils",
        srcs = ["utils.cc"],
        hdrs = ["utils.h"],
    )
    
    cc_binary(
        name = "app",
        srcs = ["main.cc"],
        deps = [":utils"],  # Fine-grained dependency
    )
    
  2. Remote Execution โ˜๏ธ
    Bazel's Remote Execution API (part of the Build Event Protocol) lets you distribute builds across clusters. Change one line, and only that compilation unit runsโ€”potentially on a remote machine with 96 cores.

  3. Polyglot Monorepo Design ๐Ÿ—๏ธ
    Bazel was built for Google's monorepo. Rules for cc_binary, java_library, py_test, go_binary, and more integrate seamlessly with shared toolchains and cross-language dependencies.

Nix's Weaknesses:

  1. Coarse Caching ๐ŸŒ
    Change one line in a source file? Nix might rebuild the entire package. While there are workarounds (splitting into subpackages), they add complexity.

  2. Learning Curve ๐Ÿ“š
    The Nix language is functional, lazy, and unlike most programmers' daily languages. Concepts like callPackage, overlays, and fixed-point recursion take time to internalize.

  3. macOS/Windows Limitations ๐ŸŽ๐ŸชŸ
    Nix works on macOS but with caveats (no full system config like NixOS). Windows support is experimental via WSL2.

Bazel's Weaknesses:

  1. No System Configuration โš ๏ธ
    Bazel doesn't manage your OS, system libraries, or even the toolchain (you must provide or use rules_foreign_cc). You still need Docker, Conda, or... Nix to set up the host environment.

  2. Boilerplate ๐Ÿ“
    Every target needs explicit declaration. A project with 1,000 files might need 1,000 BUILD file entries (though code generation helps).

  3. External Dependency Hell ๐Ÿ”—
    Fetching external dependencies (npm, Maven, etc.) requires custom rules. It works, but Nix's universal packaging often feels cleaner.

When Nix Makes Sense ๐ŸŽฏ

Scenario 1: Scientific Computing / Data Science ๐Ÿงช

You're building a bioinformatics pipeline using Python, R, CUDA libraries, and custom C++ tools. Reproducibility is paramountโ€”results published today must be verifiable in 5 years.

## environment.nix
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
  buildInputs = [
    pkgs.python39
    pkgs.python39Packages.scipy
    pkgs.python39Packages.matplotlib
    pkgs.R
    pkgs.rPackages.ggplot2
    pkgs.cudatoolkit_11
    pkgs.gcc10
  ];
  
  shellHook = ''
    export CUDA_PATH=${pkgs.cudatoolkit_11}
    echo "Pipeline environment loaded"
  '';
}

๐Ÿ’ก Why Nix wins here:

  • Need the exact Python 3.9.7 with exact SciPy 1.7.3? Nix locks it down.
  • Mixing R and Python? Nix treats both as first-class packages.
  • CUDA toolchain versioning nightmare? Nix's /nix/store isolates versions perfectly.

Scenario 2: DevOps / Infrastructure as Code ๐Ÿ—๏ธ

You're managing Kubernetes clusters and need consistent tooling: kubectl 1.24, helm 3.9, terraform 1.2.8, and custom operators.

## devops-shell.nix
{ pkgs ? import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/22.05.tar.gz") {} }:
pkgs.mkShell {
  buildInputs = [
    pkgs.kubectl
    pkgs.kubernetes-helm
    pkgs.terraform_1_2
    (pkgs.writeShellScriptBin "deploy" ''
      kubectl apply -f manifests/
      helm upgrade --install myapp ./chart
    '')
  ];
}

๐Ÿ’ก Why Nix wins here:

  • Version pinning: That 22.05 tarball ensures everyone uses the same Nixpkgs snapshot.
  • Custom scripts: The writeShellScriptBin creates a deploy command in your PATH.
  • Onboarding: New engineers run nix-shell and have the entire toolchain instantly.

Scenario 3: Cross-Compilation ๐ŸŒ

You're building embedded firmware that runs on ARM Cortex-M4 but developing on x86_64 Linux.

## Build for ARM target
pkgs.pkgsCross.armv7l-hf-multiplatform.stdenv.mkDerivation {
  name = "firmware";
  src = ./src;
  buildInputs = [ pkgs.gcc-arm-embedded ];
}

๐Ÿ’ก Why Nix wins here:

  • pkgsCross provides cross-compilation toolchains for dozens of architectures.
  • The same derivation expression works for native or cross buildsโ€”just change the package set.

When Bazel Makes Sense ๐ŸŽฏ

Scenario 1: Large Monorepo with Multiple Teams ๐Ÿข

You have 5 million lines of code across Java backends, C++ services, Go microservices, and React frontends. Teams deploy independently but share libraries.

## //backend/BUILD
java_library(
    name = "api",
    srcs = glob(["src/main/java/**/*.java"]),
    deps = [
        "//libs/auth:java",
        "@maven//:com_google_guava_guava",
    ],
    visibility = ["//frontend:__pkg__"],
)

## //frontend/BUILD
ts_library(
    name = "ui",
    srcs = glob(["src/**/*.ts"]),
    deps = ["@npm//react"],
)

## Cross-language dependency!
cc_binary(
    name = "processor",
    srcs = ["processor.cc"],
    data = ["//frontend:ui"],  # Bundles frontend assets
)

๐Ÿ’ก Why Bazel wins here:

  • Selective testing: bazel test //backend/... runs only backend tests. Change frontend code? Backend tests don't rerun.
  • Shared caching: Team A's build cache helps Team B if they touch the same libraries.
  • Explicit boundaries: visibility rules enforce architectural constraints (frontend can't depend on backend internals).

Scenario 2: Continuous Integration at Scale โš™๏ธ

Your CI system runs 50,000 tests per commit. Build times must be under 10 minutes or developer productivity collapses.

## //tests/BUILD
cc_test(
    name = "parser_test",
    srcs = ["parser_test.cc"],
    deps = ["//src:parser"],
    size = "small",  # <60s timeout
)

cc_test(
    name = "integration_test",
    srcs = ["integration_test.cc"],
    deps = ["//src:server"],
    size = "large",  # Can run on beefier machines
)

๐Ÿ’ก Why Bazel wins here:

  • Remote caching: If commit A ran parser_test and commit B doesn't touch parser code, Bazel fetches the cached PASS result. Test literally doesn't run.
  • Remote execution: Those 50,000 tests distribute across a cluster. Horizontal scaling is transparent.
  • Size hints: Small tests run on cheap machines, large tests get more resources.

Scenario 3: Deterministic Artifacts for Compliance ๐Ÿ“œ

You're in fintech/healthcare and auditors require proof that binary X came from source commit Y with no tampering.

## Bazel's --workspace_status_command embeds commit SHA into binaries
cc_binary(
    name = "trading_engine",
    srcs = ["engine.cc"],
    stamp = 1,  # Embed version info
)
## Verify build is bit-for-bit reproducible
bazel build //trading_engine --config=release
sha256sum bazel-bin/trading_engine  # abc123...

## On another machine, same commit:
bazel build //trading_engine --config=release
sha256sum bazel-bin/trading_engine  # abc123... (identical!)

๐Ÿ’ก Why Bazel wins here:

  • Hermetic guarantees: Sandbox prevents undeclared dependencies (e.g., reading /etc/config breaks the build).
  • Content-addressable cache: The SHA-256 of inputs is the cache key. If SHAs match, outputs are guaranteed identical.

Detailed Comparison: Technical Dimensions ๐Ÿ”ฌ

Build Performance Characteristics

Metric Nix Bazel Winner
Cold build (no cache) Slow (minutes to hours) Slow (minutes to hours) Tie
Warm build (local cache) Fast if package unchanged Very fast (target-level) Bazel
Incremental (1 file changed) Rebuilds entire package Rebuilds only affected targets Bazel (10-100x)
Binary cache hit rate High (if using cache.nixos.org) Depends on team setup Nix (public cache)
Remote execution scaling Not built-in Native support Bazel

๐Ÿงฎ Example: 1 Million Line C++ Project

Scenario: Change 1 source file in a library

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚               Nix                       โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ 1. Rebuild entire library package       โ”‚
โ”‚    (100k lines) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ†’ 5 minutes     โ”‚
โ”‚ 2. Rebuild dependent packages           โ”‚
โ”‚    (if outputs differ) โ”€โ”€โ†’ +10 minutes  โ”‚
โ”‚ Total: ~15 minutes                      โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚              Bazel                      โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ 1. Recompile changed file โ”€โ”€โ†’ 2 seconds โ”‚
โ”‚ 2. Relink dependent targets            โ”‚
โ”‚    (only if interfaces changed) โ”€โ†’ +5s  โ”‚
โ”‚ Total: ~7 seconds                       โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

๐Ÿ’ก For active development: Bazel ~100x faster
๐Ÿ’ก For CI cold builds: Similar (both ~1 hour)

Dependency Management Philosophy

Nix: "Everything is a Derivation"

## A derivation is a build recipe
myPackage = stdenv.mkDerivation {
  name = "my-app-1.0";
  src = ./src;
  buildInputs = [ openssl zlib ];
  
  buildPhase = ''
    gcc -o app main.c -lssl -lz
  '';
  
  installPhase = ''
    mkdir -p $out/bin
    cp app $out/bin/
  '';
};

## openssl's path is /nix/store/abc123-openssl-1.1.1k
## zlib's path is /nix/store/def456-zlib-1.2.11
## They CANNOT conflictโ€”different paths!

Bazel: "Everything is a Target"

## A target is a buildable/testable unit
cc_library(
    name = "crypto",
    srcs = ["crypto.cc"],
    deps = ["@openssl//:ssl"],  # External dependency
)

cc_binary(
    name = "app",
    srcs = ["main.cc"],
    deps = [
        ":crypto",  # Local target
        "@zlib//:zlib",  # External dependency
    ],
)

## WORKSPACE file declares external deps:
http_archive(
    name = "openssl",
    urls = ["https://openssl.org/source/openssl-1.1.1k.tar.gz"],
    sha256 = "...",
)

๐Ÿ’ก Key Difference:

  • Nix: Dependencies are immutable packages in /nix/store. You depend on fully-built artifacts.
  • Bazel: Dependencies are build targets. You depend on recipes that produce artifacts. Bazel decides when to rebuild.

Language Ecosystem Integration

Ecosystem Nix Approach Bazel Approach
Python python3Packages.* (17k+ packages)
Poetry2nix for Poetry projects
rules_python
pip_parse() for requirements.txt
Node.js node2nix, buildNpmPackage
Converts package-lock.json
rules_nodejs
npm_install() for package.json
Rust buildRustPackage (uses Cargo.lock)
Naersk, Crane (alternative builders)
rules_rust
crates_repository() for Cargo.toml
Java/JVM buildMaven, buildGradle
Awkwardโ€”not Nix's strength
rules_jvm_external
Native Maven support
Go buildGoModule (uses go.sum)
Excellent support
rules_go
Gazelle auto-generates BUILD files

๐Ÿง  Mental Model:

  • Nix: Each language has importers that translate native lockfiles (Cargo.lock, package-lock.json) into Nix derivations. You're lifting the ecosystem into Nixpkgs.
  • Bazel: Each language has rules that teach Bazel how to invoke native tools (cargo, npm, maven). You're wrapping the ecosystem in Bazel targets.

Common Mistakes and Pitfalls โš ๏ธ

Mistake 1: Using Nix for Fast Iteration in Monorepos

โŒ The Problem:
Team adopts Nix for a 2-million-line C++ monorepo expecting Google-scale build performance.

## Each component is a separate derivation
frontend = stdenv.mkDerivation {
  name = "frontend";
  src = ./frontend;  # 500k lines
  buildInputs = [ nodejs ];
};

backend = stdenv.mkDerivation {
  name = "backend";
  src = ./backend;  # 1.5M lines
  buildInputs = [ gcc openssl ];
  # Depends on frontend for static assets
  buildPhase = ''
    cp -r ${frontend}/share/static ./public
    make
  '';
};

What Happens:
Change one frontend file โ†’ entire frontend derivation rebuilds (5 minutes) โ†’ backend sees new input hash โ†’ entire backend rebuilds (20 minutes). Total: 25 minutes per change.

โœ… The Solution:

  • For monorepos with frequent changes: Use Bazel for the build, Nix for the environment.
  • Hybrid approach: nix-shell provides toolchain (gcc, nodejs), bazel build handles incremental compilation.
## shell.nix provides tools, NOT the build
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
  buildInputs = [
    pkgs.bazel_5
    pkgs.gcc12
    pkgs.nodejs-18_x
  ];
}
## Enter Nix environment, then use Bazel
$ nix-shell
[nix-shell]$ bazel build //frontend //backend
INFO: Analyzed 2 targets (1 change detected, 3 rebuilt).
## Only 3 targets rebuilt, not entire packages!

Mistake 2: Using Bazel Without Remote Caching

โŒ The Problem:
Team adopts Bazel but each developer builds from scratch because there's no shared cache.

What Happens:
The "incremental build" promise evaporates. Every git pull triggers a cold build because your local cache doesn't have what your teammate built.

โœ… The Solution:

  • Set up a remote cache (Google Cloud Storage, AWS S3, or self-hosted).
  • Configure .bazelrc for all developers:
## .bazelrc
build --remote_cache=https://your-cache.example.com
build --remote_upload_local_results=true

๐Ÿ’ก Pro tip: Use --remote_download_minimal to fetch only final outputs, not intermediate artifacts. This saves bandwidth while keeping build speed benefits.

Mistake 3: Mixing System Python with Nix Python

โŒ The Problem:
Developer has Python 3.9 installed via Homebrew (macOS) or apt (Ubuntu), then tries to use Nix:

$ nix-shell -p python310 python310Packages.numpy
[nix-shell]$ python --version
Python 3.9.7  # Still using system Python!
[nix-shell]$ python -c "import numpy"
ModuleNotFoundError: No module named 'numpy'

What Happens:
The system python is found first in $PATH. Nix's Python and numpy are isolated in /nix/store/... but not activated.

โœ… The Solution:

  • Use nix-shell --pure to remove system PATH pollution:
$ nix-shell --pure -p python310 python310Packages.numpy
[nix-shell]$ python --version
Python 3.10.5  # Nix's Python!
[nix-shell]$ python -c "import numpy; print(numpy.__version__)"
1.23.1
  • Or create a proper shell.nix with shellHook:
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
  buildInputs = [
    pkgs.python310
    pkgs.python310Packages.numpy
  ];
  
  shellHook = ''
    # Ensure Nix Python is first in PATH
    export PATH=${pkgs.python310}/bin:$PATH
    echo "Using Python: $(python --version)"
  '';
}

Mistake 4: Not Declaring All Bazel Dependencies

โŒ The Problem:
Build works on your machine because system libraries exist, but fails in CI:

## BUILD file missing dependency
cc_binary(
    name = "app",
    srcs = ["main.cc"],
    # Missing: deps on libcurl!
)
// main.cc
#include <curl/curl.h>  // Found on dev machine, not in sandbox

int main() {
    CURL* curl = curl_easy_init();
    // ...
}

What Happens:
On your machine: compiles (accidentally links against /usr/lib/libcurl.so).
In CI sandbox: Error: curl/curl.h: No such file or directory

โœ… The Solution:

  • Always declare dependencies explicitly:
cc_binary(
    name = "app",
    srcs = ["main.cc"],
    deps = ["@curl//:curl"],  # Explicit!
)
  • Use --sandbox_debug to catch undeclared deps:
$ bazel build --sandbox_debug //app
## Logs all sandbox inputsโ€”if /usr/lib appears, you have a leak!

Example 1: Hybrid Nix + Bazel Setup ๐Ÿ”ง

Scenario: You're building a microservices platform with Go backends and React frontends. You want:

  • Reproducible developer environments (Nix)
  • Fast incremental builds (Bazel)
  • CI/CD that leverages both

Step 1: Nix provides the toolchain

## shell.nix
{ pkgs ? import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/nixos-22.11.tar.gz") {} }:

pkgs.mkShell {
  buildInputs = [
    pkgs.bazel_5
    pkgs.go_1_19
    pkgs.nodejs-18_x
    pkgs.docker
    pkgs.kubectl
  ];
  
  shellHook = ''
    # Bazel needs to find Go and Node
    export GOROOT=${pkgs.go_1_19}/share/go
    export NODE_HOME=${pkgs.nodejs-18_x}
    
    echo "๐Ÿ”ง Toolchain loaded:"
    echo "  Go: $(go version)"
    echo "  Node: $(node --version)"
    echo "  Bazel: $(bazel version | head -n1)"
  '';
}

Step 2: Bazel handles the build

## WORKSPACE
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")

## Go rules
http_archive(
    name = "io_bazel_rules_go",
    sha256 = "...",
    urls = ["https://github.com/bazelbuild/rules_go/releases/download/v0.35.0/rules_go-v0.35.0.zip"],
)

## Node rules
http_archive(
    name = "build_bazel_rules_nodejs",
    sha256 = "...",
    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.5.3/rules_nodejs-5.5.3.tar.gz"],
)

load("@io_bazel_rules_go//go:deps.bzl", "go_rules_dependencies", "go_register_toolchains")
go_rules_dependencies()
go_register_toolchains(version = "1.19.3")

load("@build_bazel_rules_nodejs//:index.bzl", "node_repositories", "npm_install")
node_repositories()
npm_install(
    name = "npm",
    package_json = "//frontend:package.json",
    package_lock_json = "//frontend:package-lock.json",
)
## backend/BUILD
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")

go_library(
    name = "api",
    srcs = ["api.go", "handlers.go"],
    importpath = "mycompany.com/backend/api",
    deps = [
        "@com_github_gorilla_mux//:mux",
        "@org_golang_x_crypto//bcrypt",
    ],
)

go_binary(
    name = "server",
    embed = [":api"],
)
## frontend/BUILD
load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary")
load("@npm//webpack-cli:index.bzl", "webpack_cli")

webpack_cli(
    name = "bundle",
    args = ["--mode", "production"],
    data = glob(["src/**/*"]) + ["webpack.config.js"],
    output_dir = True,
)

Step 3: Unified workflow

## Enter Nix environment
$ nix-shell

## Build everything (incremental)
[nix-shell]$ bazel build //backend/... //frontend/...
INFO: Analyzed 5 targets (0 packages loaded, 0 targets configured).
INFO: Found 5 targets...
INFO: Elapsed time: 0.823s, Critical Path: 0.01s

## Run backend (Bazel manages the binary)
[nix-shell]$ bazel run //backend:server
INFO: Starting server on :8080

## Deploy to Kubernetes (kubectl from Nix)
[nix-shell]$ kubectl apply -f k8s/

๐Ÿ’ก Why this works:

  • Nix ensures everyone has Go 1.19, Node 18, and Bazel 5โ€”no "works on my machine."
  • Bazel's incremental builds mean changing api.go rebuilds only //backend:api and //backend:server (2 seconds).
  • CI can use the same shell.nix for reproducible builds.

Example 2: Nix for Development, Docker for Production ๐Ÿณ

Scenario: You're building a Python web app. Development needs reproducibility, but production uses Docker.

## shell.nix for development
{ pkgs ? import <nixpkgs> {} }:

let
  pythonEnv = pkgs.python310.withPackages (ps: [
    ps.flask
    ps.sqlalchemy
    ps.psycopg2
    ps.pytest
  ]);
in
pkgs.mkShell {
  buildInputs = [
    pythonEnv
    pkgs.postgresql_14
  ];
  
  shellHook = ''
    # Start local Postgres for testing
    export PGDATA=$PWD/postgres_data
    if [ ! -d "$PGDATA" ]; then
      initdb --auth=trust
      pg_ctl -l postgres.log start
      createdb myapp_dev
    fi
    
    export DATABASE_URL=postgresql://localhost/myapp_dev
    echo "๐Ÿš€ Dev environment ready. Run: python app.py"
  '';
}
## Dockerfile for production (uses Nix for determinism)
FROM nixos/nix:latest

WORKDIR /app
COPY . .

## Build Python environment with Nix
RUN nix-build -E '
  with import <nixpkgs> {};
  python310.withPackages (ps: [ ps.flask ps.sqlalchemy ps.psycopg2 ])
' -o result

## Run the app
CMD ["./result/bin/python", "app.py"]

Development workflow:

$ nix-shell  # Activates Python 3.10, Flask, Postgres
[nix-shell]$ pytest tests/
===== 42 passed in 2.13s =====
[nix-shell]$ python app.py
 * Running on http://127.0.0.1:5000

Production workflow:

$ docker build -t myapp .
$ docker run -e DATABASE_URL=postgresql://prod-db/myapp myapp

๐Ÿ’ก Benefits:

  • Development: nix-shell is faster than Docker for iteration (no image rebuilds).
  • Production: Docker is industry-standard, but Nix inside Docker ensures the exact Python environment.

Example 3: Bazel for Monorepo CI Optimization โšก

Scenario: Your monorepo has 50 services. Every commit triggers CI, but most commits touch only 1-2 services.

Problem with naive CI:

## .github/workflows/naive.yml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: make test-all  # Tests ALL 50 services (~30 minutes)

Solution with Bazel:

## Each service is a Bazel package
## //services/auth/BUILD
go_library(
    name = "auth",
    srcs = glob(["*.go"]),
)

go_test(
    name = "auth_test",
    srcs = glob(["*_test.go"]),
    embed = [":auth"],
)

## //services/payments/BUILD
go_library(
    name = "payments",
    srcs = glob(["*.go"]),
    deps = ["//services/auth"],  # Depends on auth!
)

go_test(
    name = "payments_test",
    srcs = glob(["*_test.go"]),
    embed = [":payments"],
)
## .github/workflows/smart.yml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      # Cache Bazel artifacts
      - uses: actions/cache@v3
        with:
          path: ~/.cache/bazel
          key: bazel-${{ hashFiles('WORKSPACE', 'BUILD') }}
      
      # Only test what changed!
      - run: |
          bazel test \
            --remote_cache=https://your-cache.example.com \
            $(bazel query 'kind(test, rdeps(//..., set($CHANGED_FILES)))' | tr '\n' ' ')
        env:
          CHANGED_FILES: ${{ steps.changes.outputs.files }}

What happens:

Commit touches services/auth/login.go:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ Bazel query finds affected tests:    โ”‚
โ”‚ - //services/auth:auth_test โœ“        โ”‚
โ”‚ - //services/payments:payments_test โœ“ โ”‚
โ”‚   (depends on auth!)                  โ”‚
โ”‚                                       โ”‚
โ”‚ All other tests: โœ“ (cached, skipped) โ”‚
โ”‚                                       โ”‚
โ”‚ Time: 45 seconds (was 30 minutes!)   โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

๐Ÿ’ก Key insight: Bazel's dependency graph (rdeps) finds exactly what needs retesting. Remote cache makes this practical.

Example 4: When NOT to Use Either ๐Ÿšซ

Scenario: You're building a simple WordPress site with a custom theme.

โŒ Don't use Nix:

  • Nixpkgs' WordPress is often outdated.
  • Plugins aren't packagedโ€”you'd need to write derivations for hundreds of plugins.
  • Overhead isn't justified for a CMS with automatic updates.

โŒ Don't use Bazel:

  • WordPress expects files in specific locations (wp-content/themes/).
  • No compilation step to optimize.
  • Bazel's hermeticity conflicts with WordPress's dynamic plugin loading.

โœ… Use instead:

  • Docker Compose for local dev (standardized, not hermetic).
  • Managed hosting (WordPress.com, WP Engine) for production.
  • Version control for custom theme only.

Counterexample where tools DO make sense:

If your "WordPress site" is actually a headless CMS with a Next.js frontend consuming the WordPress API:

  • โœ… Nix for Node.js toolchain.
  • โœ… Bazel for Next.js build (if part of larger monorepo).

Key Decision Framework ๐Ÿ—บ๏ธ

๐Ÿ“‹ Quick Reference: Nix vs Bazel Decision Tree

                Start Here
                    |
    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
    โ–ผ                               โ–ผ
Need system-level             Need sub-second
reproducibility?              incremental builds?
(compilers, OS libs)          (monorepo, CI)
    |                               |
    YES                             YES
    โ–ผ                               โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”               โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚   Use Nix   โ”‚               โ”‚  Use Bazel  โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค               โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ โœ“ Science   โ”‚               โ”‚ โœ“ Monorepo  โ”‚
โ”‚ โœ“ DevOps    โ”‚               โ”‚ โœ“ CI/CD     โ”‚
โ”‚ โœ“ Cross-compโ”‚               โ”‚ โœ“ Polyglot  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜               โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
    |                               |
    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                โ–ผ
    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
    โ”‚   Consider Hybrid:        โ”‚
    โ”‚ Nix for env + Bazel build โ”‚
    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
If you need... Choose
Reproducible Python/R/Julia environment Nix
Fast iteration on 1M+ line codebase Bazel
Cross-compile to embedded targets Nix
Distribute builds across cluster Bazel
Package desktop apps (GUI, system integration) Nix
Enforce architectural boundaries in monorepo Bazel
Replace Docker Compose for dev envs Nix
Test only what changed in CI Bazel

Key Takeaways ๐ŸŽ“

  1. Nix optimizes for system-level reproducibilityโ€”it's a package manager that happens to build software. Ideal when the environment is the problem (conflicting dependencies, "works on my machine").

  2. Bazel optimizes for incremental build speedโ€”it's a build system that happens to be hermetic. Ideal when iteration time is the bottleneck (large codebases, frequent changes).

  3. Granularity matters: Nix rebuilds at package boundaries (coarse). Bazel rebuilds at file/target boundaries (fine). For active development, fine granularity wins.

  4. Remote caching is essential for Bazel. Without it, you lose the "incremental" benefit across team members and CI.

  5. Hybrid approaches are common: Use Nix to provide compilers/libraries (the environment), then use Bazel for the build. Example: nix-shell + bazel build.

  6. Learning curves differ: Nix requires understanding functional programming and the Nix language. Bazel requires understanding explicit dependency declaration and build targets.

  7. Ecosystem integration varies: Nix feels native for Python, Go, Rust, Haskell. Bazel feels native for Java, C++, Go. Both can handle anything, but some languages fit better.

  8. For science/research: Nix's strength is long-term reproducibility ("rerun this analysis in 2030").

  9. For engineering teams: Bazel's strength is developer velocity ("test this PR in 30 seconds, not 30 minutes").

  10. Neither is always right: Simple projects, dynamic languages without compilation, or CMS-based sites often don't benefit from hermetic builds. Use conventional tools.

Further Study ๐Ÿ“š

  1. Nix Pills - Comprehensive Nix tutorial: https://nixos.org/guides/nix-pills/
    Deep dive into Nix's functional approach and package management philosophy.

  2. Bazel Concepts and Terminology - Official guide: https://bazel.build/concepts/build-ref
    Understand targets, actions, rules, and the execution model.

  3. Tweag Blog: Nix + Bazel - Real-world integration: https://www.tweag.io/blog/2022-09-01-nix-bazel-build/
    Case study on combining both tools in production systems.


๐Ÿง  Final Mental Model:

Think of Nix as version control for your entire dependency graphโ€”every package is immutable, addressed by hash, and you can switch between "commits" (derivations) freely.

Think of Bazel as Make on steroids with a content-addressable cacheโ€”every build step is a pure function from inputs to outputs, and the cache ensures you never rebuild the same thing twice.

Choose Nix when the environment is the variable. Choose Bazel when the code is the variable. Use both when you need the best of both worlds.