Build your nodejs project with nix in 10 minutes or less

!!! Warning: dream2nix is unstable software. While simple UX is one of our main focus points, the APIs are still under development. Do expect changes that will break your setup.

This guide takes you step-by-step through setting up a nodejs reproducible build and development environment using nix (the build system) and dream2nix (bridge between external package managers and nix).

This setup will allow for the same environment to be reproduced on different machines and CI systems with high accuracy - thus avoiding many pitfalls of distributed software development.

Outline

  1. Install nix with flakes enabled
  2. Navigate to your nodejs project
  3. Initialize the dream2nix flake
  4. Define target platform(s)
  5. Explore the outputs
  6. Build the project
  7. Development shell
  8. FAQ

Install nix

If you don't have nix already, check out nixos.org/download.html on how to install it.

Enable the nix flakes feature

For internal dependency management dream2nix requires the experimental nix feature flakes being enabled.

export NIX_CONFIG="extras-experimental-features = flakes nix-command"

If you find yourself using dream2nix regularly, you can permanently save these settings by adding the following line to your /etc/nix/nix.conf:

experimental-features = flakes nix-command

For this guide we will use the fun cowsay nodejs project. It is simply a talking cow for your console. This project is a nodejs port from the original perl version. Feel free to use any other project, if you do and hit a roadblock, please consult the FAQ at the end of this article for solutions to some common issues.

We start by cloning the project:

> git clone https://github.com/piuccio/cowsay /tmp/my_project
> cd /tmp/my_project

Initialize the dream2nix flake

We have our repository cloned and ready. Now we will create the flake.

The flake is a standalone description of the project, it will define the inputs and outputs of our project, the build steps - in this case handled by dream2nix, and the development environment. The flake is a fully standalone and complete configuration for nix to build a software package.

We use a dream2nix flake template:

> nix flake init -t github:nix-community/dream2nix#simple
wrote: /tmp/my_project/flake.nix

to create a flake.nix:

{
  inputs.dream2nix.url = "github:nix-community/dream2nix";
  outputs = inp:
    inp.dream2nix.lib.makeFlakeOutputs {
      systemsFromFile = ./nix_systems;
      config.projectRoot = ./.;
      source = ./.;
      projects = ./projects.toml;
    };
}

This file configures our build and development environment using the dream2nix framework to bridge the nodejs ecosystem into nix. This let's nix read and understand package.json and how to install and link nodejs packages - to avoid duplication of dependency definitions and build steps.

Define target platform(s)

We have the flake setup, now we need to define the supported systems, this is necessary because nix can do multi platform and cross-platform builds so we need to tell it what can be built and where.

There are 2 ways to do this, either with a nix_systems file, or we can write the target platforms inline to our flake.nix.

nix_systems

We can create a nix_systems file with the current system:

> nix eval --impure --raw --expr 'builtins.currentSystem' > ./nix_systems
> git add ./nix_systems

The nix_systems file is simply a list of the supported systems, for example:

x86_64-linux

Remember to add the file ./nix_systems to git, or it won't be picked up by nix. If you want to support more platforms later, just add more lines to that file.

inline

Alternatively, we can define the targets in the flake.nix like so:

{
  inputs.dream2nix.url = "github:nix-community/dream2nix";
  outputs = inp:
    inp.dream2nix.lib.makeFlakeOutputs {
      systems = ["x86_64-linux"];         # <- This line.
      config.projectRoot = ./.;
      source = ./.;
      projects = ./projects.toml;
    };
}

This has the advantage of keeping all the configuration in a single file.

Populating projects.toml

dream2nix also needs to know things about the project(s) at hand. In the flake.nix file you can see it's expecting a ./projects.toml. The easiest way to create and populate this ./projects.toml is with the helper function

nix run github:nix-community/dream2nix#detect-projects . > projects.toml
git add projects.toml

Explore the outputs

We have setup the flake, defined our target system(s), now we are ready to use it. Let's start by listing out what is available to us (actual output may be different, this is a shortened version):

> nix flake show
warning: Git tree '/tmp/my_project' is dirty
warning: creating lock file '/tmp/my_project/flake.lock'
warning: Git tree '/tmp/my_project' is dirty
git+file:///tmp/my_project
├───devShell
│   └───x86_64-linux: development environment 'nix-shell'
├───devShells
│   └───x86_64-linux
│       ├───cowsay: development environment 'nix-shell'
│       └───default: development environment 'nix-shell'
└───packages
    └───x86_64-linux
        ├───cowsay: package 'cowsay-1.5.0'
        ├───default: package 'cowsay-1.5.0'
        └───resolveImpure: package 'resolve'

We can see that:

  1. warning: Git tree '/tmp/my_project' is dirty Our repository has uncommitted changes. Nix uses git commit hashes to version build artifacts, so this can result in some extra rebuilds. Since we are just setting up the project now, this is alright.
  2. warning: creating lock file '/tmp/my_project/flake.lock' Our flake itself has an input (external dependency), the dream2nix framework. When we first use the flake, like we just did, nix created a lock file with the exact version of the input (and its inputs). Commit this file to version control to ensure reproducible builds.
  3. Finally, we see the outputs of our flake. We see it outputs packages for the x86_64-linux systems: the cowsay package (our nodejs project) and a resolveImpure package (more about that in the next section). It also sets out cowsay package as the default package of this flake.

Build the project

We have setup our flake for the nodejs project and identified the output we want to build.

To build the output, we run:

> nix build .#cowsay

(The . means the flake in the current directory, # is a special character separating the flake name and the package name, and cowsay is the name of the package we want to build. Since we want to build, nix will look under packages first, and it knows our current platform (x86_64-linux in this case), so it will actually build the output .#packages.x86_64-linux.cowsay.)

Since, cowsay is the default package, we could also simply run:

> nix build

To build the .#packages.x86_64-linux.default output. (In our case these are the same.)

This creates our ./result directory with all our final build artifacts.

> ./result/bin/cowsay 'hello dream2nix'
 _________________
< hello dream2nix >
 -----------------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

Nix was able to build this project, because it has a package-lock.json file pinning the exact dependency versions. If we did not have this file, the nix build would fail with error: unresolved impurities.

> git rm package-lock.json
> nix build .#cowsay
warning: Git tree '/tmp/my_project' is dirty
error: The nodejs package cowsay contains unresolved impurities.
       Resolve by running the .resolve attribute of this derivation
       or by resolving all impure projects by running the `resolveImpure` package

We can fix this by generating a language specific lockfile (package-lock.json or yarn.lock for nodejs), or let dream2nix generate a universal dream-lock.json.

> nix run .#resolveImpure
warning: Git tree '/tmp/my_project' is dirty
Resolving:: Name: cowsay; Subsystem: nodejs; relPath:
translating in temp dir: /tmp/tmp.SwBFt0WcH4

up to date, audited 172 packages in 9s

57 packages are looking for funding
  run `npm fund` for details

3 high severity vulnerabilities

To address all issues (including breaking changes), run:
  npm audit fix --force

Run `npm audit` for details.
===[SUCCESS]===(dream2nix-packages/cowsay/dream-lock.json)===
adding file to git: dream2nix-packages/cowsay/dream-lock.json

This runs npm behind the scenes and resolves dependencies for all packages inside the flake.

There is no difference between using an external lockfile or dream-lock.json, all they do is pin dependency version and are completely interchangeable.

Development shell

We were able to build our nodejs project with nix, however our build artifacts under ./result are read-only and we do not have node and npm in PATH. To be able to work in this project we will need those.

Nix provides us with devShells for exactly this.

> nix flake show
warning: Git tree '/tmp/my_project' is dirty
warning: creating lock file '/tmp/my_project/flake.lock'
warning: Git tree '/tmp/my_project' is dirty
git+file:///tmp/my_project
├───devShell
│   └───x86_64-linux: development environment 'nix-shell'
├───devShells
│   └───x86_64-linux
│       ├───cowsay: development environment 'nix-shell'
│       └───default: development environment 'nix-shell'
└───packages
    └───x86_64-linux
        ├───cowsay: package 'cowsay-1.5.0'
        ├───default: package 'cowsay-1.5.0'
        └───resolveImpure: package 'resolve'

When we enter the cowsay development shell, we will get node in our PATH, together with all the binaries from our dependencies packages. And nix will copy over node_modules for us to save us from having to npm install everything over again. To get in the shell, simply run:

> nix develop -c $SHELL

(The -c $SHELL part is only necessary if you use a different shell than bash and would like to bring that shell with you into the dev environment.)

From here on it's the same as using a normal installation of nodejs. However, if we do imperative changes to node_modules and later re-enter the nix development shell, nix will overwrite the node_modules with the pinned versions of the dependencies from our lockfile.

FAQ

Refusing to overwrite existing file on flake init.

When initializing a flake it needs to write some files, if these already exists, the initialization will fail. Since our repository is under version control, we can delete the conflicting files, let flake init create them and then check the diff and merge the changes manually.

Getting status of flake.nix: no such file or directory.

The flake build does not happen inside the directory. Nix copies your repository to a temporary location and builds there; only files under version control are used. To resolve this run git add flake.nix and all other missing files.

Warning: Git tree is dirty

This is just a warning, nix is using the git revision for build artifact versioning. Having a dirty git tree - meaning uncommitted changes - can lead to some extra rebuilds, for simple projects this should not be a major concern.

error: The package contains unresolved impurities. Resolve all impure projects by running the resolveImpure package.

This happens when dream2nix cannot resolve exact package versions. We can define a dependency like something@^2.1, but it is not obvious if we actually want 2.1.1 or 2.1.2 or maybe 2.1.1-alpha. There are 2 ways to resolve this error: either by running nix run .#resolveImpure and letting dream2nix resolve the most up-to-date versions of all dependencies, or using an external package manager to generate a lock file, which can be later read by dream2nix. (In case of Node.js, both package-lock.json and yarn.lock are supported.)