8 Minutes read

Using Nix to provide reproducible development environments for enterprise teams and accelerate developer onboarding.

Context

A new developer joins your team. You send them the onboarding doc. A few hours later, they’re still stuck because their machine has a slightly different setup and something broke. You get the Slack message: ”Hey, I’m getting an error… did this work for anyone else?

You’ve seen this before. Every new hire loses time to environment setup, and your senior devs lose time helping them debug it.

At ekino, our Node.js team hit this wall enough times that we started looking for a better way. A few of us had been using Nix for personal setups, and the stability was hard to ignore. We decided to test whether it could scale to a full team without making everyone’s life harder.

Nix is not a new tool. Eelco Dolstra started it in 2003, and NixOS was first built by Armijn Hemel in 2006. For years it had a reputation for being hard to learn, with a strange language and a steep curve that felt aimed at Linux experts. But things have changed.

Nixpkgs now has over 120,000 packages. Flakes fixed the project structure story. And modern AI tooling makes writing config files much less painful. Nix is quickly becoming the most reliable way to manage team environments across both macOS and Linux (our team at ekino runs on Apple Silicon Macs, so the examples here reflect that experience).

We’re still in the experimentation phase. Nix is a vast ecosystem, and there’s plenty we haven’t explored yet. But we’ve found a path that solves our immediate problems: unpredictable onboarding, environment drift between projects, and the constant “did you install this?” back-and-forth.

Here is how we structured Nix for our Node.js team, what actually worked, and the edge cases we hit along the way.

Why Docker and Homebrew are not enough

Before Nix, our Node.js team at ekino used the standard setup: Homebrew for CLI tools, Docker for project isolation. It worked until it didn’t.

The pain points were small but constant. Someone runs brew upgrade and suddenly a tool version changed. Docker fixes consistency but file syncing is slow on Mac, and your editor can't easily reach tools inside the container. We had developers juggling multiple projects with different tool requirements, and no clean way to guarantee everyone had the same setup without conflicts.

Nix gave us per-project isolation without the VM overhead. Each project gets exactly the tools it declares, nothing more, nothing less.

The Architecture: project vs. machine level

Nix draws a hard boundary between your projects and your machine.

Your global machine setup and your project environments never interfere with each other. Upgrading Node.js or Python in one project won’t affect anything else on your laptop.

The project level

The easiest way to introduce Nix to a team is at the project level. You lock down the project’s tools without touching the rest of the developer’s laptop. This is where we started at ekino.

We handle this using flakes alongside direnv. A flake.nix file goes into the root of your project, acting like a package.json for system packages. Here’s the structure we landed on:

{
description = "Node.js/TypeScript project with Nix flakes and direnv";

inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
flake-utils.url = "github:numtide/flake-utils";
};

outputs = { nixpkgs, flake-utils, ... }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
baseTools = with pkgs; [ fish nodePackages.pnpm esbuild fnm ];
in {
devShells = {
default = pkgs.mkShell {
buildInputs = baseTools;
# ... shell hooks
};

database = pkgs.mkShell {
buildInputs = baseTools ++ [ pkgs.redis ];
# ... shell hooks
};

# testing, devops shells...
};
}
);
}

Full example in the demo-nix-shell repo

The key insight: multiple devShells instead of one bloated environment. A backend developer runs nix develop and gets pnpm, esbuild, and fnm. A DevOps engineer runs nix develop .#devops and gets Kubernetes, Helm, and Docker on top. Everyone gets exactly what they need, nothing more.

flake-utils.lib.eachDefaultSystem handles cross-platform support automatically, so the same flake works on our M-series Macs and on Ubuntu CI runners with no extra configuration.

Auto-activating with direnv

Typing nix develop every time you open a project gets old fast. Pair the flake with a .envrc file at the project root:

# .envrc file
use flake

That’s all it takes. From here, direnv handles everything automatically:

  • Enter the folder → your dev tools become available
  • Leave the folder → they disappear
  • Open your editor from here → it sees the same tools, so autocomplete and LSP work out of the box

No need to run nix develop manually ever again.

A note on app dependencies

Nix manages your system-level tools: pnpm, fnm, redis. Your app’s own dependencies node_modules, lockfiles, and so on are still handled by native package managers, and that’s by design. Native tools are optimized for fast installs and tight editor integration. Nix simply ensures that every developer and every CI runner starts from the exact same version of external tools before any of that kicks in.

The system level

Project-level flakes solve the “works on my machine” problem for code.

But what about the machines themselves?

How do you make sure every developer on your team starts with the same CLI tools, the same shell config, and the same base setup without forcing everyone into an identical, rigid environment?

The answer isn’t to mandate one perfect laptop config. It’s to give your team a well-structured template they can actually own.

We provide a base nix-darwin setup that covers everything a backend developer needs out of the box. A new hire clones it, runs one command, and has a fully working environment in minutes. From there, they add their own tools, tweak their dotfiles, and make it theirs all in a language that is versioned, reproducible, and easy to share back with the team.

The structure

.
├── flake.lock
├── flake.nix # Entry point, kept minimal
├── modules/
│ └── shared/ # Shared across all platforms
│ ├── dotfiles.nix # Connects home-manager symlinks
│ ├── packages.nix # Core CLI tools
│ └── programs.nix # GUI apps
├── dotfiles/ # Raw config files (nvim, fish, git, ghostty...)
└── README.md

The modules/shared/ layer is the team’s common baseline, the tools everyone agrees belong in every setup. Personal additions live in each developer’s own config, separate from the shared modules.

Here’s the flake.nix that wires it all together:

{
description = "Nix darwin for backend dev";

inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
nix-darwin = { /* ... */ };
home-manager = { /* ... */ };
};

outputs = { nixpkgs, nix-darwin, home-manager, ... }:
let
mkDarwinSystem = { system, username }: nix-darwin.lib.darwinSystem {
# ... system config + home-manager integration
modules = [
./modules/platforms/darwin/system
home-manager.darwinModules.home-manager
# ...
];
};
in {
darwinConfigurations = {
aarch64-darwin = mkDarwinSystem {
system = "aarch64-darwin";
username = "your-username";
};
# ... other configurations
};
};
}

Full example in the demo-nix-darwin repo

The mkDarwinSystem helper keeps things DRY. Adding a new team member's machine is one new entry in darwinConfigurations, the shared modules stay untouched.

Nix never overwrites your existing setup. Every change creates a new generation while keeping the previous ones intact. If an update breaks something:

darwin-rebuild switch --rollback

You are back to the last working state in seconds, with no manual cleanup.

Nix handles CLI tools well, but on macOS, GUI apps are a different story. Some only exist in Homebrew, and Nix-installed apps don’t integrate properly with Spotlight or the Dock. So we let Homebrew handle GUI apps, but still declare them in nix-darwin, so installs stay reproducible and easy to share.

Handling dotfiles

Keeping ~/.config files in sync across machines usually means some combination of a Brewfile, GNU Stow, and a bash script. It works, but as your setup grows, maintaining those scripts becomes its own job.

home-manager handles this cleanly and since we’re already using it, there’s no need to pull in a separate tool like GNU Stow just for dotfiles.

Keep configs as raw files, not Nix records

home-manager lets you rewrite every config file into Nix syntax. It works, but it adds an unnecessary layer of abstraction: you can no longer copy-paste from official docs, you hit bugs when a Nix module lags behind upstream, and you lose syntax highlighting in your editor. A full rebuild just to change a terminal color is not a great experience.

Instead, we keep all config files in the dotfiles/ directory, plain Neovim, Fish, Ghostty, Claude, Opencode … and let home-manager link them.

mkOutOfStoreSymlink creates direct symlinks between the dotfiles/ directory and your config folders, keeping the files fully writable:

# modules/shared/dotfiles.nix
{ config, ... }:
let
dotfileDir = "${config.home.homeDirectory}/projects/nix-darwin/dotfiles";
inherit (config.lib.file) mkOutOfStoreSymlink;
in {
home.file = {
".config/nvim".source = mkOutOfStoreSymlink "${dotfileDir}/nvim";
".config/ghostty".source = mkOutOfStoreSymlink "${dotfileDir}/ghostty";
# ...
};
}

Edit your config, save, and the change is live instantly, no rebuild needed.

The full template is available here: demo-nix-darwin

CI/CD and Docker: theory vs. practice

We haven’t taken Nix all the way to production yet. The theory is compelling: Nix locks every dependency to a specific version, so CI only rebuilds what actually changed. With Nix’s built-in binary caching, one developer building an environment means everyone else, including the CI runner, can download the cached result instead of rebuilding from scratch.

For Docker builds, there’s a growing pattern of using Nix directly inside Dockerfiles instead of maintaining fragile apt-get commands and layer caching tricks. Mitchell Hashimoto (founder of HashiCorp) has a particularly clean example: use Nix to build the exact dependencies in one stage, then copy just the /nix/store paths your app needs into a minimal final image. The result is reproducible builds with tiny output images, no multi-stage complexity. His writeup shows the approach clearly: Nix with Dockerfiles.

You can also use pkgs.dockerTools to build production images directly from Nix, skipping Dockerfiles entirely. The same flake.nix your developers use locally drives dev, CI, and production, one source of truth throughout.

Bringing Nix to the team

The adoption wasn’t a hard sell, but it wasn’t instant either.

Some developers at ekino were already using Nix for personal setups. That gave us working examples to start from. With open-source dotfiles and AI tooling filling the gaps, the exploration took a couple of weeks, not months.

The first skeptics weren’t anti-Nix, they were just tired of learning yet another tool. What convinced them was the demo: cd into a project, watch direnv pull in the exact versions of Node, pnpm, and Redis without touching the rest of their system. No conflicts, no "uninstall this first." It just worked.

The key insight: Nix doesn’t force anyone to change. You can add a flake.nix to a project, and developers who want to use it benefit immediately. Those who prefer their existing setup? It still works exactly as before. The two systems run in parallel. This flexibility made adoption feel safe, not disruptive.

We started with project-level setup first, then gradually introduced the system-level template once people saw the value. No one was forced to adopt both at once.

Nix and AI: why we’re excited about what’s next

As our team at ekino starts building more AI-powered applications, we’re already seeing how Nix could be critical. AI projects multiply the environment problem: you’re not just managing tools anymore, you’re managing model runtimes, GPU drivers, vector databases, and a dozen other moving pieces that all need to work together. Nix’s approach to reproducibility feels like it was built for exactly this kind of complexity.

What really excites us is the idea of having the same environment locally, in CI, and in AI agent sandboxes. Imagine using Claude Code, Codex or Cursor to develop, and the AI sees exactly the same tool versions, the same environment, the same constraints you do. No more “it worked in the AI sandbox but broke when I ran it locally.” That’s the promise, and we think Nix is one of the few tools that can actually deliver it.

We’re also looking forward to seeing how Nix evolves alongside AI tooling. As teams build faster with AI assistants, the need for stable, consistent, reproducible environments only grows. The faster you move, the more critical it is that your foundation doesn’t shift under you.

We’re still early in our Nix journey. There’s plenty we haven’t touched yet — CI/CD integration, Docker optimizations, production AI workflows. But we’ve solved what we set out to solve: onboarding is faster, environments are consistent, and developers have the flexibility to adopt at their own pace.

If you’re working with Nix, especially at the intersection of team adoption or AI workflows, we’d love to hear what you’re learning.

Thanks to Pebie for collaborating on this article and helping shape the ideas. Thanks to our Node.js and communication team at ekino for reviewing drafts, and giving honest feedback throughout the process. Thanks to ekino for giving us the room to dig into this properly.

Resources & further reading

If you want to dig deeper into the tools mentioned here, check out the official docs:

Our setup demos:

Official documentations:


Exploring Nix for enterprise teams was originally published in ekino-france on Medium, where people are continuing the conversation by highlighting and responding to this story.