Nix vs Docker: Why We Chose Nix-First
Decision Date: 2026-02-15 Status: Active Related ADR: ADR-0037 (Nix-First Kubernetes Orchestration)
TL;DRβ
SPECTRE uses Nix to build container images, not Docker.
- β
Nix builds OCI images via
dockerTools.buildLayeredImage - β Kubernetes runs containers (containerd/CRI-O)
- β docker-compose runs infrastructure services (NATS, Jaeger, etc.)
- β No Dockerfile for application builds
- β No
docker buildin CI/CD
The Problem with Traditional Dockerβ
1. Non-Reproducible Buildsβ
FROM rust:bookworm AS builder
RUN cargo build --release # β Different timestamps = different image
Issue: Two builds from same source can produce different binaries:
- Layer timestamps vary
- Network fetches can get different versions
- Base image updates break reproducibility
2. Build Tool Duplicationβ
# Before: THREE build systems
- Cargo builds Rust binaries
- Docker builds containers
- Helm/Kustomize builds K8s manifests
# After: ONE build system
- Nix builds everything
3. Docker Daemon Requirementβ
# Traditional CI
- Install Docker daemon (privileged)
- docker build (needs daemon)
- docker push (needs daemon)
# Nix CI
- nix build .#spectre-proxy-image # No daemon
- skopeo copy (no daemon)
CI/CD Impact: Docker-in-Docker is complex, slow, and insecure.
The Nix Solutionβ
1. Hash-Based Reproducibilityβ
# nix/images/spectre-proxy.nix
pkgs.dockerTools.buildLayeredImage {
name = "spectre-proxy";
tag = "nix-${builtins.substring 0 8 (self.rev or "dev")}";
contents = [ cacert bashInteractive coreutils ];
config.Cmd = [ "${spectre-proxy}/bin/spectre-proxy" ];
}
Guarantee: Same source hash β Same binary β Same image (bit-for-bit).
2. Unified Build Systemβ
# One flake.nix rules them all:
nix build .#spectre-proxy # Rust binary
nix build .#spectre-proxy-image # OCI image
nix build .#kubernetes-manifests-dev # K8s YAML
nix develop # Dev shell
3. No Docker Daemon Neededβ
# Build image
nix build .#spectre-proxy-image
# β /nix/store/abc123-docker-image-spectre-proxy.tar.gz
# Load to daemon (optional, for testing)
docker load < result
# Push to registry (no daemon)
skopeo copy docker-archive:result docker://registry/spectre:latest
Comparison Tableβ
| Feature | Docker | Nix |
|---|---|---|
| Reproducibility | β Non-deterministic | β Hash-guaranteed |
| Build Caching | Layer-based (fragile) | Content-addressable (robust) |
| Daemon Required | β Yes (privileged) | β No |
| Layer Deduplication | Manual (COPY --from) | Automatic (Nix store) |
| Cross-compilation | β QEMU emulation | β Native (Nix cross) |
| Offline Builds | β Needs network | β Fully hermetic |
| Binary Size | ~30MB (Distroless) | ~30MB (Nix minimal) |
| CI/CD Complexity | High (Docker-in-Docker) | Low (single binary) |
| Team Learning Curve | Low (widespread) | Medium (Nix syntax) |
What We Keptβ
docker-compose.yml (Infrastructure Only)β
# Used ONLY for local development infrastructure
services:
nats: # Message bus
timescaledb: # Metrics storage
neo4j: # Graph database
jaeger: # Tracing
prometheus: # Metrics collection
grafana: # Dashboards
# NO application services built here
Why keep it?
- Mature ecosystem for service orchestration
- Easy
docker-compose up -dfor dev environment - Not used in production (Kubernetes is)
Docker as Container Runtimeβ
# Kubernetes uses containerd/CRI-O (not Docker)
# But locally you can still use Docker:
docker load < $(nix build .#spectre-proxy-image --print-out-paths)
docker run -p 3000:3000 spectre-proxy:nix-dev
Docker's role: Container runtime, NOT build tool.
Migration Path (What Changed)β
Before (Dockerfile)β
# Dockerfile
FROM rust:bookworm AS builder
WORKDIR /build
COPY Cargo.* ./
RUN cargo build --release
FROM gcr.io/distroless/cc-debian12
COPY --from=builder /build/target/release/spectre-proxy /
ENTRYPOINT ["/spectre-proxy"]
After (Nix)β
# nix/images/spectre-proxy.nix
{ lib, dockerTools, cacert, spectre-proxy, ... }:
dockerTools.buildLayeredImage {
name = "spectre-proxy";
tag = "nix-${version}";
contents = [ cacert bashInteractive coreutils ];
config = {
Cmd = [ "${spectre-proxy}/bin/spectre-proxy" ];
User = "1000:1000";
ExposedPorts = { "3000/tcp" = {}; };
};
maxLayers = 100; # Automatic deduplication
}
CI/CD Changesβ
# Before: Job 8 - Docker Build (REMOVED)
docker:
- docker build -t spectre-proxy .
- docker push
# After: Job 9 - Nix Image
nix-image:
- nix build .#spectre-proxy-image
- skopeo copy (if pushing)
Trade-offs Acceptedβ
β Gainsβ
- Reproducibility: Build once, deploy anywhere (guaranteed)
- Simplicity: One build system for everything
- Security: No privileged Docker daemon in CI
- Speed: Nix cache >> Docker layer cache
- Offline: Hermetic builds work without network
β Costsβ
- Learning Curve: Team must learn Nix language
- Community Size: Fewer Nix+K8s examples than Dockerfile+K8s
- Tooling: Some tools expect Dockerfile (rare, workarounds exist)
Decision: Reproducibility and simplicity outweigh learning curve.
Common Questionsβ
"Why not just use Distroless with Docker?"β
Distroless solves image size, not reproducibility. You still get:
- Non-deterministic builds (timestamps)
- Docker daemon requirement
- Build tool duplication
"What about Docker Buildx multi-platform?"β
Nix cross-compilation is more robust:
# Nix cross-compile to ARM64
nix build .#packages.aarch64-linux.spectre-proxy-image
# Docker uses QEMU emulation (slow, can break)
docker buildx build --platform linux/arm64
"How do I debug containers without shell?"β
# Nix includes bashInteractive in contents
docker run -it spectre-proxy:nix-dev bash
# Or use kubectl debug (ephemeral containers)
kubectl debug pod/spectre-proxy-xxx -it --image=busybox
"What if I need to use Dockerfile later?"β
Nix can generate Dockerfiles from derivations:
# Generate Dockerfile from Nix build
dockerTools.buildImageWithNixDb # Includes /nix/store
But you probably won't need to.
Implementation Guideβ
Building Imagesβ
# Build
nix build .#spectre-proxy-image
# Inspect
tar -tzf result | head -20
# Load to Docker (optional)
docker load < result
# Push to registry (no Docker daemon)
skopeo copy \
docker-archive:result \
docker://ghcr.io/yourorg/spectre-proxy:latest
CI/CD Integrationβ
# .github/workflows/ci.yml
nix-image:
runs-on: ubuntu-latest
steps:
- uses: cachix/install-nix-action@v22
- run: nix build .#spectre-proxy-image
- run: skopeo copy docker-archive:$(readlink result) docker://registry/image
Kubernetes Deploymentβ
# Manifests reference Nix-built images
nix build .#kubernetes-manifests-dev
kubectl apply -f result
# Or use deploy app
nix run .#deploy-dev
Future Considerationsβ
When to Re-evaluateβ
- If Nix becomes unmaintained (unlikely, CNCF interest growing)
- If Docker BuildKit adds content-addressable guarantees (monitoring)
- If Team size grows significantly and Nix training becomes bottleneck
Evolution Pathβ
- Current (2026-02): Nix-only container builds
- Future (2026-06+): Nix for entire NixOS-based K8s cluster?
- Advanced: NixOS containers (systemd, declarative services)
Referencesβ
- ADR-0037: Nix-First Kubernetes Orchestration
- Nix Pills: https://nixos.org/guides/nix-pills/
- dockerTools docs: https://nixos.org/manual/nixpkgs/stable/#sec-pkgs-dockerTools
- Reproducible Builds: https://reproducible-builds.org/
Conclusionβ
SPECTRE's philosophy: One build system, maximum reproducibility.
Docker remains useful as a container runtime (locally and via Kubernetes), but Nix is the build tool of record. This decision eliminates complexity, ensures reproducibility, and aligns with SPECTRE's commitment to production-grade infrastructure.
"Build once, run anywhere" β but actually.