🚀 Executive Summary
TL;DR: Node.js applications face significant challenges on NixOS due to Node’s reliance on a standard Filesystem Hierarchy Standard (FHS) environment clashing with NixOS’s hermetic, declarative package management. This article outlines three primary solutions: creating an FHS-compatible bubble, embracing Nix-idiomatic builds with `dream2nix`, or containerizing the application for pragmatic isolation.
🎯 Key Takeaways
- Node.js’s `npm install` and `node-gyp` expect an FHS environment (e.g., `/usr/bin`, `/usr/lib`), which is absent in NixOS’s hermetic `/nix/store` structure, leading to dependency resolution failures.
- `buildFHSUserEnv` provides a “FHS Bubble” (`shell.nix`) to quickly resolve Node.js dependency issues by presenting expected build tools (like `gcc`, `python3`) in a familiar `/usr/bin` layout.
- For fully reproducible Node.js builds, `dream2nix` translates `package.json` and `package-lock.json` into Nix derivations, allowing Nix to manage and cache `node_modules` entirely.
Struggling to deploy your Node.js app on NixOS? A Senior DevOps Engineer shares a war story and walks through three real-world solutions to bridge the gap between Node’s dependency chaos and Nix’s declarative purity.
Node.js on NixOS: A DevOps War Story and A Guide to Sanity
It was 2 AM. Of course, it was 2 AM. The pager was screaming about our primary authentication service, `ms-user-auth`, being down on the staging cluster. I squinted at the deploy logs. A junior dev had pushed a “simple” security patch for a dependency. CI passed, but the deployment to our NixOS-based `stg-api-cluster` was a dumpster fire. The error was maddeningly familiar to anyone who’s fought this fight: `node-gyp` was screaming about a missing `libwhatever.so.2`.
My first thought wasn’t about the code. It was, “Dammit, it worked on their machine, didn’t it?” And it did. On their Ubuntu laptop, `npm install` worked like a charm. But on the pure, pristine, and infuriatingly logical world of NixOS, it fell apart. This isn’t a bug; it’s a clash of philosophies. And if you’re trying to run Node on NixOS, you’re standing right in the middle of it.
The “Why”: A Tale of Two Philosophies
So, what’s actually going on here? Why does a setup that works everywhere else break on NixOS? It boils down to one core conflict:
- Node.js & npm assume a “normal” world. They expect a Filesystem Hierarchy Standard (FHS) environment. They believe they can find tools like `python` or `gcc` in `/usr/bin/` and system libraries in `/usr/lib/`. They love to download scripts and binaries from the internet and run them imperatively, creating a massive, stateful `node_modules` directory.
- NixOS provides a “hermetic” world. There is no `/usr/bin` or `/usr/lib`. Every single package, library, and binary lives in its own isolated directory inside `/nix/store`, identified by a unique hash. Everything is meant to be declared upfront and built reproducibly. It hates side-effects and arbitrary network access during a build.
When `npm install` tries to run `node-gyp` to build a native addon, it’s like a tourist in a foreign country asking for directions to a landmark that doesn’t exist. It panics, and your deployment dies. The good news is, we have ways to be a better tour guide.
The Fixes: From Duct Tape to Doctrine
After that 2 AM incident, we standardized our approach. Here are the three paths we take, depending on the project, the timeline, and how much coffee we’ve had.
Solution 1: The FHS Bubble (The “Get It Done by Friday” Fix)
Sometimes, you just need to get the thing working. You don’t have time to refactor your entire build process to be “Nix-idiomatic.” For this, we create a “bubble” — a temporary FHS-compatible environment where Node and npm can feel right at home.
We use `buildFHSUserEnv` to create a `shell.nix` file that pulls in all the typical dependencies (`gcc`, `python3`, `pkg-config`) and presents them in the familiar `/usr/bin` layout that `node-gyp` expects. It’s impure, it’s a bit of a hack, but it’s incredibly effective for unblocking legacy projects.
Here’s what a simple `shell.nix` for this might look like:
{ pkgs ? import <nixpkgs> {} }:
let
# A NodeJS version you need
nodejs = pkgs.nodejs-18_x;
# Create the FHS environment
fhs = pkgs.buildFHSUserEnv {
name = "node-dev-env";
targetPkgs = pkgs: [
# Essential build tools for node-gyp
pkgs.gcc
pkgs.gnumake
pkgs.python3
pkgs.pkg-config
# Your Node.js runtime
nodejs
];
# This command runs inside the FHS bubble
runScript = "bash";
};
in
pkgs.mkShell {
# This just gets the 'fhs' environment into our main shell
buildInputs = [ fhs ];
}
You run `nix-shell`, and you’re dropped into a shell where you can run `npm install` and `npm start` like you would on any other Linux distro. The build artifacts are still messy, but the server is back online.
Warning: This is a crutch, not a long-term solution. You lose many of the benefits of Nix’s reproducibility because `npm` is still fetching things from the internet imperatively. Use it to meet a deadline, then plan to move to a better solution.
Solution 2: The Nix Way (The “We’re All In” Fix)
This is the path to true enlightenment and reproducible builds. Instead of fighting Nix, you embrace it. You let Nix itself handle the entire `node_modules` directory. The modern tool for this is `dream2nix`.
It works by reading your `package.json` and `package-lock.json` and translating them into a set of Nix derivations. Each npm package becomes a tiny, cached, reproducible Nix package. The final `node_modules` directory is assembled by Nix from these pieces. It’s fast, it’s cacheable, and it’s 100% reproducible.
Here’s a highly simplified `flake.nix` to give you the flavor:
{
description = "A reproducible Node.js project with dream2nix";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
dream2nix.url = "github:nix-community/dream2nix";
};
outputs = { self, nixpkgs, dream2nix }:
let
pkgs = nixpkgs.legacyPackages.x86_64-linux;
in
{
packages.x86_64-linux.default = dream2nix.lib.makeFlakeOutputs {
source = ./.;
# Tell dream2nix this is a node project
projectType = "nodejs";
# Let dream2nix do its magic!
systems = [ "x86_64-linux" ];
inject = [ ];
};
};
}
With this, you run `nix build` and get a `result` symlink containing your fully-built application, including the Nix-managed `node_modules`. No `npm install` needed. This is the goal. It integrates perfectly with CI/CD and NixOS deployments.
Solution 3: The Container Liferaft (The “Pragmatist’s” Fix)
What if your team isn’t ready to go all-in on Nix, but you still want to run your services on a declarative NixOS host? The answer is the one we use for half our services: containers.
NixOS is a phenomenal container host. You can sidestep the entire Node vs. Nix problem by building a standard Docker/OCI image and running it. The Node app lives happily inside its own little Debian or Alpine world, and NixOS just worries about running the container declaratively.
You can even use Nix to build the container image for maximum reproducibility! Here’s how you might define a container image build using `dockerTools.buildImage` in your NixOS configuration:
{ pkgs, ... }:
let
appSrc = /path/to/your/app;
# A simple Dockerfile-like build in Nix
node-app-image = pkgs.dockerTools.buildImage {
name = "ms-user-auth";
tag = "latest";
copyToRoot = pkgs.buildEnv {
name = "image-root";
paths = [ appSrc ];
};
config = {
Cmd = [ "${pkgs.nodejs-18_x}/bin/node", "/app/index.js" ];
WorkingDir = "/app";
ExposedPorts = { "3000/tcp" = {}; };
};
};
in {
# Then, define your service on the NixOS host
virtualisation.oci-containers.containers.ms-user-auth = {
image = "ms-user-auth:latest";
# Before running the service, load the locally built image
preStart = ''
${pkgs.docker}/bin/docker load < ${node-app-image}
'';
ports = [ "8080:3000" ];
};
}
This gives you the best of both worlds: a pristine, manageable host OS and a conventional, easy-to-understand environment for your application developers.
Choosing Your Path
So, which one is right for you? Here’s how I break it down for my team:
| Solution | Best For | Reproducibility | Learning Curve |
|---|---|---|---|
| 1. FHS Bubble | Legacy projects, emergencies, quick fixes. | Low (relies on npm) | Low |
| 2. The Nix Way | New projects, teams committed to Nix. | Very High (the goal!) | High |
| 3. Container Liferaft | Mixed-paradigm teams, pragmatic isolation. | High (image is reproducible) | Medium |
There’s no shame in starting with the FHS bubble to stop the bleeding or using containers because your team is more comfortable with Dockerfiles. The goal is stable, reliable systems. NixOS gives you powerful tools to achieve that, but it’s okay to choose the one that fits the problem—and the people—you have right now.
🤖 Frequently Asked Questions
âť“ Why is Node.js deployment on NixOS challenging?
Node.js and npm assume a standard Filesystem Hierarchy Standard (FHS) environment, expecting tools and libraries in `/usr/bin` or `/usr/lib`. NixOS, however, uses a hermetic `/nix/store` where every package is isolated, causing `node-gyp` and other build processes to fail when they can’t find expected dependencies.
âť“ How do the three solutions for Node.js on NixOS compare?
The FHS Bubble (`buildFHSUserEnv`) is a low-reproducibility, low-learning-curve fix for emergencies. The Nix Way (`dream2nix`) offers very high reproducibility with a high learning curve, ideal for new projects. The Container Liferaft (`dockerTools.buildImage`) provides high reproducibility with a medium learning curve, suitable for mixed teams needing pragmatic isolation.
âť“ What is a common implementation pitfall when building Node.js native addons on NixOS?
A common pitfall is `node-gyp` failing due to missing `libwhatever.so.2` or other build tools like `gcc` or `python`. This occurs because `node-gyp` expects these in standard FHS locations (`/usr/bin`, `/usr/lib`), which are not present in NixOS’s isolated `/nix/store`. The solution involves providing an FHS-compatible environment (e.g., via `buildFHSUserEnv`) or using Nix-idiomatic build tools like `dream2nix`.
Leave a Reply