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
| Dimension | Nix | Bazel |
|---|---|---|
| Build Unit | Package (coarse) | Target (fine) |
| Scope | Entire system | Project workspace |
| Language | Functional DSL | Starlark (Python-like) |
| Cache Key | Input hash (recursive) | Action input hash |
| Output Location | /nix/store | bazel-out/ |
Technical Trade-offs
Nix's Strengths:
System-Level Reproducibility ๐
Nix doesn't just build your codeโit builds the entire dependency graph including compilers, libraries, and even system utilities. Ashell.nixfile 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 ]; }Binary Substitution โก
Nix's centralized cache (cache.nixos.org) means you often download pre-built binaries instead of compiling. If someone else builtnodejs-18.2.0with the exact same inputs, you get their binary instantly.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:
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 )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.Polyglot Monorepo Design ๐๏ธ
Bazel was built for Google's monorepo. Rules forcc_binary,java_library,py_test,go_binary, and more integrate seamlessly with shared toolchains and cross-language dependencies.
Nix's Weaknesses:
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.Learning Curve ๐
The Nix language is functional, lazy, and unlike most programmers' daily languages. Concepts likecallPackage, overlays, and fixed-point recursion take time to internalize.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:
No System Configuration โ ๏ธ
Bazel doesn't manage your OS, system libraries, or even the toolchain (you must provide or userules_foreign_cc). You still need Docker, Conda, or... Nix to set up the host environment.Boilerplate ๐
Every target needs explicit declaration. A project with 1,000 files might need 1,000BUILDfile entries (though code generation helps).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/storeisolates 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.05tarball ensures everyone uses the same Nixpkgs snapshot. - Custom scripts: The
writeShellScriptBincreates adeploycommand in your PATH. - Onboarding: New engineers run
nix-shelland 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:
pkgsCrossprovides 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:
visibilityrules 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_testand 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/configbreaks 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-shellprovides toolchain (gcc, nodejs),bazel buildhandles 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
.bazelrcfor 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 --pureto 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.nixwithshellHook:
{ 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_debugto 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.gorebuilds only//backend:apiand//backend:server(2 seconds). - CI can use the same
shell.nixfor 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-shellis 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 ๐
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").
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).
Granularity matters: Nix rebuilds at package boundaries (coarse). Bazel rebuilds at file/target boundaries (fine). For active development, fine granularity wins.
Remote caching is essential for Bazel. Without it, you lose the "incremental" benefit across team members and CI.
Hybrid approaches are common: Use Nix to provide compilers/libraries (the environment), then use Bazel for the build. Example:
nix-shell+bazel build.Learning curves differ: Nix requires understanding functional programming and the Nix language. Bazel requires understanding explicit dependency declaration and build targets.
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.
For science/research: Nix's strength is long-term reproducibility ("rerun this analysis in 2030").
For engineering teams: Bazel's strength is developer velocity ("test this PR in 30 seconds, not 30 minutes").
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 ๐
Nix Pills - Comprehensive Nix tutorial: https://nixos.org/guides/nix-pills/
Deep dive into Nix's functional approach and package management philosophy.Bazel Concepts and Terminology - Official guide: https://bazel.build/concepts/build-ref
Understand targets, actions, rules, and the execution model.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.