It's Monday morning. A new engineer joins the team. By Thursday, they're still fighting their dev environment. The Dockerfile the previous engineer wrote pulls from ubuntu:22.04, runs apt-get install without pinned versions, and "works on the CI runner." On the new engineer's M2 Mac it builds fine but the binary crashes at runtime because they're running a different glibc. You've seen this. We all have.
The thing is: Docker was supposed to fix this. It didn't. It just moved the problem. Not in production -- in development environments.
The Problem with Dev Environments
Dev environments fail in predictable ways:
- Dependency drift: your local toolchain diverges from CI over weeks. Nobody notices until a build breaks in production but passes locally.
-
Version manager sprawl:
nvm,pyenv,rbenv,sdkman-- one per language, none of them talk to each other, all of them break on OS upgrades. - Platform gaps: macOS developers running Linux containers via a VM, dealing with volume mount performance issues and file permission mismatches.
-
Onboarding friction: a
README.mdwith fifteen manual steps that's six months out of date.
The underlying issue is that most tooling in this space solves isolation but not reproducibility. Those are different problems.
What Docker Actually Solves Well
Docker is genuinely good at a few things:
- Runtime isolation: your service runs in a predictable Linux environment regardless of the host OS.
- Distribution: package a runtime with its dependencies, ship it anywhere. This is Docker's strongest use case.
- Standardized CI/CD: every major CI platform speaks Docker natively. The ecosystem around it is massive.
-
Service dependencies: spinning up Postgres, Redis, and Kafka locally with
docker compose upis a legitimately good workflow.
For running services, Docker is the right tool. It's the dev environment use case where things get complicated.
Where Docker Hits Its Limits
The Dockerfile model is procedural, not declarative:
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y \
cmake \
ninja-build \
gcc \
python3
This fetches whatever happens to be current at build time. You're not building a stable environment. You're replaying a moving target -- without even knowing what changed -- and taking a dependency on the state of the internet that day. Build the same Dockerfile six months later: different packages, different behavior, zero indication anything changed.
The usual response is to pin the image digest:
FROM ubuntu:22.04@sha256:58b87898c82e...
RUN apt-get update && apt-get install -y cmake ninja-build gcc python3
Better -- but the packages inside still drift. You've pinned the base layer, not the build. And now you own the update cycle manually. So even "best practices" don't fully solve the problem.
More practically:
- macOS and Windows performance: Docker on macOS runs inside a Linux VM. On Windows, you're going through WSL2 -- and while WSL2 is functional, cross-filesystem I/O (your code lives on the Windows side, the build runs on the Linux side) kills performance. Large C++ or Go builds inside a container on either platform are noticeably slower than native Linux.
- DevContainers + Kubernetes: if your team runs DevContainers against a remote Kubernetes cluster, you've already hit the wall. VSCode remote development, build performance, port forwarding, image rebuild cycles -- it adds friction at every layer.
- Build caching is fragile: Docker layer cache depends on instruction order and content. It breaks in non-obvious ways and is hard to share across machines without a registry.
- DinD is a mess: if your CI or dev workflow needs Docker inside Docker, you're fighting containerd, socket mounts, and privilege escalation from day one.
Snapshot vs. Derivation
This is the core difference.
A Docker image is a snapshot. A Nix build is a derivation.
A snapshot tells you what existed. A derivation tells you how to get there.
That difference matters when things break -- because with a snapshot you can restore, but you can't reason. With a derivation you can reproduce, audit, and understand exactly what changed and why.
How Nix Approaches the Problem
Nix takes a different angle. Instead of "here's a script that builds an environment," it asks: given a precise description of inputs, what are the outputs?
Every package in Nix is identified by a cryptographic hash of its inputs -- source code, dependencies, build flags, compiler version. If the inputs are identical, the output is identical. Always. This is what reproducible builds actually means. And because the entire build graph is explicit, cross-compilation becomes a first-class concern: you can target ARM from an x86 machine without custom Docker images or fragile toolchain scripts.
For dev environments, nix develop gives you a shell with an exact toolchain:
# flake.nix
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
outputs = { self, nixpkgs }:
let
forAllSystems = nixpkgs.lib.genAttrs [
"x86_64-linux"
"aarch64-linux"
"aarch64-darwin" # M1/M2 Mac
];
in {
devShells = forAllSystems (system:
let pkgs = nixpkgs.legacyPackages.${system};
in {
default = pkgs.mkShell {
packages = [
pkgs.cmake
pkgs.ninja
pkgs.gcc13
pkgs.python311
];
shellHook = ''
echo "toolchain: $(cmake --version | head -1)"
'';
};
}
);
};
}
flake.lock pins the exact nixpkgs revision. A colleague on macOS, a CI runner on Linux, and you on your laptop all get the exact same cmake binary -- same source, same build flags, same hash. Not "the same version" -- the same build.
No global installs. No version managers. nix develop drops you into the environment, exit leaves it. Your system stays clean.
Combine with direnv and it gets even better. Add a .envrc with a single line:
use flake
Now cd into the project directory and the environment activates automatically. Leave the directory, it's gone. No manual nix develop, no global state, no version managers fighting each other. Your shell just has the right tools when you need them.
Full example with CMake project and direnv setup: github.com/charemma/blog/tree/main/nix/devshell-cmake
Key Differences
| Docker | Nix | |
|---|---|---|
| Reproducibility | Image digest is reproducible; Dockerfile build is not | Content-addressed; build graph is fully deterministic |
| Dev environment | Container with volume mounts | Native shell, no overhead |
| macOS / Windows | VM overhead, slow volume mounts, WSL2 I/O issues | Native, no VM |
| CI integration | Native, widely supported | Requires setup; but identical to local |
| Learning curve | Low-medium | High |
| Partial adoption | All-or-nothing per container | Per-project devShell, gradual |
| Binary caching | Registry-based | Nix substituters (cachix, self-hosted) |
| Package availability | Anything you can apt-get
|
nixpkgs is large but has gaps |
Where Nix is Stronger
Toolchain-heavy projects: C++, embedded, cross-compilation. When your build depends on a specific GCC version, exact sysroot, and a custom linker script, Nix handles this cleanly. Cross-compilation is where Nix really shines. You declare host and target -- Nix handles the rest. Docker can technically do it too, but cross-compilation setups in containers are notoriously fragile.
Cross-platform consistency: the same devShell works on macOS and Linux. No "but it works in the container" conversations.
CI parity: the gap between local and CI closes because they run the same command against the same environment:
# locally
nix develop
cmake --build .
# GitHub Actions
- name: Build
run: nix develop --command cmake --build .
No "install dependencies" step in CI that diverges from what you ran locally. Same flake, same lock file, same result.
Incremental adoption: you don't need to Nix-ify your entire infrastructure (though once you've been running NixOS for a while, you'll want to). A flake.nix in a repo gives everyone a consistent dev environment without touching your deployment pipeline.
Where Nix Falls Short
This section matters more than the strengths.
The learning curve is real. Nix the language is functional, lazy, and unlike anything most engineers have written. Error messages are often cryptic. The mental model takes weeks to build, not hours.
Documentation is fragmented. The official docs are improving but still incomplete in places. A lot of knowledge lives in GitHub issues, forum posts, and other people's flakes. You will spend time reading source code.
Flakes are "experimental." Pragmatically, everyone uses them and they're stable enough. But the official experimental label is real friction in corporate environments where "experimental" means "not approved."
Cold builds are slow. On a cache miss, Nix builds from source. For packages with heavy build times (LLVM, Qt, anything Rust-based) this can be painful. Cachix or a self-hosted binary cache solves this, but it's infrastructure you need to set up.
Some tooling doesn't play well. JetBrains IDEs, some commercial SDKs, proprietary build tools -- Nix support is incomplete or requires workarounds. If your stack is heavily JetBrains-based, expect friction.
Hard to justify organizationally. "We're switching to a functional package manager written in a language nobody knows" is a tough pitch to a team already stretched thin.
When to Use Which
Reach for Docker when:
- You need runtime isolation for services (this is Docker's home turf)
- Your CI/CD is Kubernetes-native and you're building images anyway
- The team has no appetite for learning new tooling
- You need to distribute a pre-built environment to non-engineers
Reach for Nix when:
- You have a complex toolchain where exact versions matter (C++, embedded, cross-compilation -- and if you're building an embedded OS, Nix can replace Yocto entirely. But that's a story for another post.)
- You've been burned enough times by local/CI divergence
- macOS or Windows developers on the team are tired of Docker Desktop / WSL2 performance
- You want onboarding to be
nix developand nothing else
The common pattern that actually works:
Use Nix for the dev environment (the toolchain, the build tools, the local shell) and Docker for runtime (building and running the actual service images). They're not competing -- they solve different layers of the same problem.
Conclusion
Docker solved distribution. It didn't solve reproducibility -- it just moved the non-determinism from your local machine to a Dockerfile that nobody updates.
Nix solves reproducibility. It doesn't replace Docker for what Docker is actually good at.
The question isn't "Docker or Nix." It's: where is your actual pain? If your team is fighting with environment drift, onboarding friction, or local/CI divergence -- that's a reproducibility problem and Nix addresses it directly. If you need consistent runtime packaging for services running in Kubernetes, that's Docker's job and it's good at it.
Use boring tools for boring problems. Reach for Nix when the boring tools have stopped working.
The mental shift
Docker teaches you to think in environments.
Nix forces you to think in inputs and outputs.
Once you see that difference, it's hard to unsee it.
If you're dealing with similar problems, I'd be curious how you're solving them -- drop a comment below.
It's Monday morning. A new engineer joins the team. By Thursday, they're still fighting their dev environment. The Dockerfile the previous engineer wrote pulls from ubuntu:22.04, runs apt-get install without pinned versions, and "works on the CI runner." On the new engineer's M2 Mac it builds fine but the binary crashes at runtime because they're running a different glibc. You've seen this. We all have.
The thing is: Docker was supposed to fix this. It didn't. It just moved the problem. Not in production -- in development environments.
The Problem with Dev Environments
Dev environments fail in predictable ways:
- Dependency drift: your local toolchain diverges from CI over weeks. Nobody notices until a build breaks in production but passes locally.
- Version manager sprawl: nvm, pyenv, rbenv,sdkman -- one per language, none of them talk to each other, all of them break on OS upgrades.
- Platform gaps: macOS developers running Linux containers via a VM, dealing with volume mount performance issues and file permission mismatches.
- Onboarding friction: a
README.mdwith fifteen manual steps that's six months out of date.
The underlying issue is that most tooling in this space solves isolation but not reproducibility. Those are different problems.
Top comments (0)