Nix Instead of Docker: Rethinking Development Environments
The Two Faces of Docker
Docker has fundamentally changed the way we deploy software. In our post about Jib and container images, we described how we build container images for our Spring Boot services, and our production workloads naturally run as containers on Kubernetes. We are not changing that.
But Docker has a second face that has less to do with deployment: Docker Compose as a local development environment. Databases, message brokers, caches -- all spun up via docker compose up so you can develop locally. And the development tools themselves -- Java, Gradle, Node.js -- are often provided in devcontainers or Docker-based setups so that "everyone runs the same version."
We did this for years. And then we stopped.
The Problem with Docker Compose for Development
Docker Compose for external services -- a PostgreSQL database, a Redis, a RabbitMQ -- works well. We have nothing against that. The problem starts when Docker Compose becomes the universal answer to the question: "How do I ensure everyone on the team has the same development environment?"
In practice, this is what it looked like for us: A docker-compose.yml with all services, plus occasional devcontainer configurations or Dockerfiles for the build tooling. It worked, but had noticeable drawbacks.
Resource consumption. Docker Desktop on macOS is not lightweight. The Linux VM running in the background consumes RAM and CPU -- permanently, not just while running builds. With projects that have multiple services, this adds up quickly.
Startup time. docker compose up takes time. Images need to be pulled, containers need to start, health checks need to pass. With more complex setups, it easily takes 30 to 60 seconds before you can start working.
Daemon dependency. Nothing works without a running Docker daemon. Docker Desktop must be started, the daemon must be running. This sounds trivial, but it is yet another moving part in the setup.
Version pinning is cumbersome. Which Java version does this project need? Which Gradle version? Which Node version for the frontend? Mapping all of this in Docker Compose or devcontainers creates configuration overhead that has nothing to do with the actual project.
Nix devShells: A Different Answer
We have been using NixOS as the host operating system for our servers since 2022, as we described in our posts about NixOS as a server OS and reproducible server configurations with Flakes. Nix Flakes proved themselves there as a foundation for reproducible infrastructure. At some point, the next step was obvious: if Flakes can make server configurations reproducible, why not development environments too?
The answer is devShells. A Nix Flake can define development environments alongside server configurations -- a shell with exactly the tools and versions a project needs. No containers, no daemon, no VM. Just a shell where the right tools are available.
For a typical Java project at our company, the flake.nix looks like this:
{
description = "my-service - development environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
in
{
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
# Java toolchain
temurin-bin-21
gradle
# Additional tools
kubectl
k9s
jq
httpie
# Database client
postgresql_16
];
shellHook = ''
export JAVA_HOME=${pkgs.temurin-bin-21}/lib/openjdk
echo "Java: $(java --version | head -1)"
echo "Gradle: $(gradle --version | grep '^Gradle')"
'';
};
}
);
}
Running nix develop in this directory opens a shell with Java 21, Gradle, kubectl, and all other defined tools. The versions are pinned to an exact nixpkgs commit via the flake.lock. Everyone on the team who checks out the same commit gets exactly the same tools in exactly the same versions. Not similar -- identical.
direnv: Automatic Activation
Running nix develop manually every time you switch to a project directory would be cumbersome. This is where direnv comes in. direnv is a shell hook that automatically sets environment variables when entering a directory -- and with the nix-direnv extension, it automatically activates the matching Nix devShell.
The setup is minimal. A .envrc file in the project root is all you need:
# .envrc
use flake
Two words. When we cd into the project directory, direnv detects the .envrc, activates the devShell from flake.nix, and all tools are available. When leaving the directory, the environment is cleaned up again. No manual activation, no deactivation, no "do I have the right Java version active?"
The first invocation after a change to flake.nix or flake.lock takes some time because Nix evaluates the dependencies and downloads them if necessary. After that, activation is practically instant -- nix-direnv caches the result, and switching to the directory simply adds the tools to the PATH.
Comparison: Nix devShell vs. Docker Compose for Development
| Aspect | Docker Compose | Nix devShell + direnv |
|---|---|---|
| Daemon required | Yes (Docker Daemon) | No |
| Resource consumption | High (Linux VM on macOS) | Minimal (native processes) |
| Activation time | 30-60 seconds | < 1 second (after cache) |
| Version pinning | Per image tag, manual | Automatic via flake.lock |
| Reproducibility | Depends on image tags | Exact via nixpkgs commit |
| Project isolation | Per container | Per devShell / direnv |
| Cross-platform | Yes | Linux, macOS (native) |
| Learning curve | Low | Medium to high |
The table shows: for development tools -- compilers, build tools, CLI utilities -- Nix is lighter, faster, and more reproducible. Docker Compose still excels at services that need to run as processes: databases, message brokers, caches. In practice, we combine both: Nix for the tools, Docker Compose for external services that need a running server process.
Multiple Projects, Different Versions
The biggest everyday advantage becomes apparent when working on multiple projects simultaneously. Project A needs Java 17 and Gradle 7, Project B needs Java 21 and Gradle 8, a side project needs Node.js 20. Without Nix, this means sdkman, nvm, asdf, or similar version managers -- they work, but each has its own quirks and none of them are truly declarative.
With Nix, each project has its own flake.nix, its own .envrc, and its own pinned versions. A simple cd is enough, and the right environment is active. No sdk use java 17.0.9-tem, no nvm use 20. direnv handles it automatically, and reliably.
What We Don't Use It For
An important point we want to make explicitly: these Nix development environments do not replace our container-based deployment pipeline. Our applications run in production as Docker containers on Kubernetes. Jib builds the container images, Helm deploys them, K3s runs them. Nix changes nothing about that.
Nix also does not replace Docker Compose for running services. When we need a local PostgreSQL database or a Redis, we still start them via Docker Compose. Nix provides us with the psql client, but not the server process.
The dividing line is clear: Nix manages development tools -- everything that belongs in the PATH. Docker manages running services and production deployments. These are complementary tools, not competitors.
Getting the Team on Board
Introducing this to the team was easier than expected. The prerequisites are manageable: Nix needs to be installed (a one-liner), Flakes need to be enabled, and direnv needs to be set up in the shell. After that, it works the same for everyone -- which is the whole point.
The biggest resistance came, surprisingly, not from the Nix syntax but from the workflow change. Anyone who managed Java via sdkman for years needs to get used to the fact that the Java version now lives in the repository rather than on their own machine. But that is exactly the point: the development environment is part of the project, not an individual configuration on a developer's laptop.
Conclusion
Nix Flakes and direnv have significantly improved our local development experience. Faster activation, lower resource consumption, exact reproducibility of tool versions -- these are not abstract benefits but tangible improvements in daily work.
If you already have experience with Nix -- whether through NixOS on the server or through other touchpoints -- you should give devShells and direnv a try. The setup takes half an hour, and the value becomes apparent with the first project switch. The learning curve for Nix newcomers is steeper, but the investment pays off -- especially in teams where "it works on my machine" is the most common debugging phrase.
Docker remains an indispensable tool in our stack -- for production, for container images, for external services. But as a foundation for local development environments, Nix has replaced it for us. Lighter, faster, more reproducible.
Written by
Patrick HütterFounder & Software Architect
Software architect, engineer and entrepreneur. Patrick has been building products and platforms for over a decade — from enterprise backends and cloud-native infrastructure to AI-powered applications. As founder of encircle360, he combines deep technical expertise with entrepreneurial vision, driving open source projects that create real impact.
You might also like
From Manifest to Production: How ADL, A2A and the Inference Gateway Are Revolutionizing Agent Infrastructure
Jul 5, 2026 · 12 min read
Agent Orchestration with Java: Bringing LLM Agents to Production on the JVM
Jul 4, 2026 · 6 min read
Spring AI: How Java Developers Can Finally Integrate AI Features the Right Way
Mar 25, 2026 · 5 min read