Nix Build System
Philosophy and architectural patterns for reproducible builds and declarative infrastructure
Philosophy
Nix is not just a package manager—it’s a fundamentally different approach to software construction. We treat the entire system as a pure function: given the same inputs (source code + dependencies), we always produce the same outputs (binaries + configurations).
Core Concepts
What is Reproducibility?
Traditional build systems produce different outputs based on:
- System libraries installed on the build machine
- Environment variables and PATH
- Network state during dependency resolution
- Implicit dependencies not declared in the build file
Nix eliminates these variables by:
- Isolating builds in clean environments with only declared dependencies
- Locking all inputs including transitive dependencies and their hashes
- Content-addressable storage where outputs are named by their content hash
- No global state—each build starts from a pristine environment
The Flake Paradigm
A flake is a self-contained, versioned package description:
- Declarative: Build instructions written in Nix expression language
- Reproducible:
flake.lockpins every dependency to exact versions - Composable: Other flakes can depend on your flake
- Hermetic: No access to the outside world during builds
This means a build that succeeds on one developer’s machine will succeed identically on CI and production.
Architecture Layers
The Nix architecture separates concerns into four layers:
Layer 1: Package Definitions
Purpose: Describe how to build software from source
Packages define:
- Source location (Git repository, local path, etc.)
- Build dependencies (compilers, libraries, tools)
- Build script (configure, make, install equivalents)
- Runtime dependencies (libraries needed at runtime)
Key insight: Packages are values in a functional language. They don’t execute—they describe what would be built.
Layer 2: Development Environment
Purpose: Provide a shell with all tools needed for development
The devShell provides:
- Exact versions of compilers and build tools
- Project-specific utilities (formatters, linters)
- Environment variables and shell hooks
- Isolation from host system packages
When you run nix develop, you enter a subshell where java, bun, and other tools are exactly as specified—regardless of what’s installed on your laptop.
Layer 3: Process Composition
Purpose: Orchestrate multi-service local development
Process-compose replaces Docker Compose for local development:
- Declares which processes to run (backend, frontend, database)
- Manages dependencies between services
- Provides unified logging and monitoring
- Restart policies for failed processes
Unlike Docker, processes run natively on the host—no virtualization overhead, faster startup, and easier debugging.
Layer 4: System Configuration
Purpose: Define entire NixOS machines
NixOS modules describe:
- Operating system configuration (users, networking, services)
- Service definitions with systemd units
- Security hardening and firewall rules
- Secrets management integration
These configurations are deployed to create reproducible infrastructure—prod server #1 is identical to prod server #2 because both are built from the same expression.
Dependency Management
Lock Files
Nix flakes generate flake.lock files that pin:
- Direct flake inputs (nixpkgs version)
- Transitive dependencies (libraries your dependencies use)
- Git revisions and content hashes
This means even if nixpkgs updates a library, your build continues using the pinned version until you explicitly update the lock file.
Supply Chain Security
Nix provides multiple layers of supply chain protection:
- Source verification: Dependencies are fetched by content hash, not just URL
- Reproducible builds: Same source always produces same output
- Binary caches: Signed pre-built binaries reduce compilation time
- Sandboxing: Builds cannot access the network or modify files outside their directory
If a dependency’s content doesn’t match the expected hash, the build fails rather than accepting a potentially compromised package.
Build Isolation
The Sandbox
Nix builds run in isolated environments that:
- Have no network access
- See only explicitly declared dependencies
- Start with an empty filesystem (except the source)
- Cannot write outside their output directory
This isolation catches missing dependencies that would work on your laptop (where you have tools installed) but fail in CI.
Pure Functions
Builds are pure functions—they depend only on their inputs:
buildPackage(source, dependencies, buildScript) => output
The same inputs always produce the same output, enabling:
- Caching: If inputs haven’t changed, reuse previous output
- Sharing: Multiple users can share the same built package
- Verification: Rebuild and verify outputs match expectations
Development Workflow
Entering the Environment
When you run nix develop, Nix:
- Evaluates the devShell expression
- Builds any missing tools
- Sets up environment variables
- Spawns a new shell with modified PATH
- Runs shell hooks (e.g., setting FLAKE_ROOT)
The resulting shell has exactly the tools needed—no more, no less.
Incremental Builds
During development, Nix provides:
- Incremental compilation: Only changed files rebuild
- Development shells: Different shells for different tasks
- Direnv integration: Automatically enter devShell when entering project directory
Testing Changes
The nix flake check command runs the full CI pipeline locally:
- Builds all packages (backend, frontend, docs)
- Runs unit and integration tests
- Checks formatting compliance
- Validates NixOS configurations
- Runs VM-based integration tests
This means “works on my machine” is actually meaningful—the exact same checks run locally and in CI.
Deployment Architecture
NixOS Systems
NixOS is a Linux distribution where everything is configured through Nix expressions:
- System packages: Installed via Nix, not apt/yum
- System services: Defined as systemd units in Nix
- Configuration files: Generated by Nix templates
- Users and groups: Declared in Nix, not useradd
A NixOS machine is built by evaluating a Nix expression that returns a complete system configuration.
Deploy-rs
Deploy-rs is the deployment tool that:
- Builds the system configuration locally
- Copies closure (package + dependencies) to remote machine
- Activates the new system configuration
- If activation fails, automatically rolls back
- Confirms success or triggers rollback
This means failed deployments are atomic—the system either fully activates or reverts to the previous state.
Secrets Management
Secrets are managed separately from configuration:
- Encrypted at rest: Secrets stored encrypted in Git
- Decrypted at activation: Age/ragenix decrypts on target machine
- Available as files: Services read secrets from filesystem
- Never in Nix store: Unencrypted secrets never touch the world-readable store
This separation means configuration can be public while secrets remain encrypted.
Networking and Infrastructure
Tailscale Mesh
The infrastructure uses Tailscale for private networking:
- Mesh topology: Every node connects to every other node directly
- WireGuard encryption: All traffic encrypted with modern crypto
- Headscale control: Self-hosted coordination server
- MagicDNS: Private DNS resolution for internal services
This architecture means services communicate over encrypted tunnels without public IPs or complex firewall rules.
Service Discovery
Services find each other through:
- DNS names: headscale provides internal DNS
- Static IPs: Tailscale assigns stable IPs in the 100.x.x.x range
- NixOS module coordination: Services configured to know about each other
No load balancers or service meshes required—just direct encrypted connections.
CI/CD Integration
The Check Pipeline
nix flake check is the universal CI command:
- Build verification: All packages compile successfully
- Test execution: Unit, integration, and property-based tests
- Formatting validation: All code follows project standards
- Linting: Static analysis catches potential issues
- VM tests: Full system integration tests in VMs
Caching Strategy
Nix provides multiple caching layers:
- Local store: Already-built packages on your machine
- Binary cache: Shared cache (Garnix, Cachix) for common packages
- Build cache: CI artifacts reused between builds
This means builds are incremental—you only rebuild what changed, not the world.
Troubleshooting and Debugging
Build Failures
When builds fail, Nix provides:
- Complete build logs with all commands executed
- Environment variable dumps
- Option to keep failed build directory for inspection
--show-tracefor detailed evaluation traces
Development Mode
For debugging build issues:
nix developenters the build environmentgenericBuildruns the build phases interactively- Failed phases can be re-run with modifications
Why Reproducibility Matters
Reproducibility isn’t just a nice property—it enables:
- Bisecting: Git bisect works because old commits still build
- Security auditing: Rebuild and verify package contents
- Disaster recovery: Infrastructure rebuilt from Git in minutes
- Team consistency: Everyone uses exact same tools
- CI confidence: Local build success predicts CI success
When to Use Nix
Nix excels when you need:
- Reproducible builds across environments
- Declarative configuration that can be versioned
- Hermetic builds that don’t depend on system state
- Atomic upgrades with rollback capability
- Cross-language projects with unified tooling
Nix adds complexity when:
- Simple projects with few dependencies
- Teams unfamiliar with functional programming
- Need for rapid iteration over reproducibility
- Integration with non-Nix build systems
For this project, the complexity is justified by the multi-language nature (Java + TypeScript + Nix) and the production deployment requirements.