Nix Flakes for Development Shells

This is a quick tour of Nix as a language and package manager.

I’ve been using Nix for six months now, and it’s really been a plus to me when it comes to managing configurations and dev/build environments.

We’ll cover the following:

Installing Nix

The idiomatic way to get Nix is through the Determinate Systems’ Nix Installer:

curl \
  --proto '=https' \
  --tlsv1.2 \
  -sSf \
  -L https://install.determinate.systems/nix \
  | sh -s -- install

If you’re skeptical (like me the first time I tried it), we can do it in a Docker container:

# Run an Ubuntu container with color terminal support:
docker run \
  --rm \
  -ite \
  TERM=xterm-256color \
  --entrypoint=/bin/sh \
  ubuntu -c "exec bash --rcfile /etc/skel/.bashrc"

# Install curl and systemd:
export DEBIAN_FRONTEND=noninteractive
apt-get update -y -qq >/dev/null 2>&1
apt-get install -y -qq curl systemd >/dev/null 2>&1

# Run the Nix Installer script (see previous code block).

You should see a similar prompt after running the installer script:

info: downloading installer https://install.determinate.systems/...
Nix install plan (v0.20.1)
Planner: linux (with default settings)

Planned actions:
* Create directory `/nix`
* Extract the bundled Nix (...)
* Create a directory tree in `/nix`
* Move the downloaded Nix into `/nix`
* Create build users (UID 30001-30032) and group (GID 30000)
* Setup the default Nix profile
* Place the Nix configuration in `/etc/nix/nix.conf`
* Configure the shell profiles
* Configure Nix daemon related settings with systemd
* Remove directory `/nix/temp-install-dir`


Proceed? ([Y]es/[n]o/[e]xplain):

A successful install will output the following (break-lines mine):

Nix was installed successfully!

To get started using Nix,
open a new shell or run
`. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh`

You can source nix-daemon.sh, or open a new shell with exec $SHELL -li.

Alternative: A container with Nix installed

If you just want to try Nix in docker (and not install it on your system after), use the official docker image:

docker run --rm -ite TERM=xterm-256color nixos/nix

Inside the container, run the following commands:

mkdir -p ~/.config/nix
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf

Using nix

Let’s try running a binary we don’t have installed:

$ command -V cowsay
cowsay not found

Now, we’ll use nix to install the binary to /nix/store, a special directory for Nix packages.

Nix does not install packages to /usr/bin or your home directory.

$ nix shell "nixpkgs#cowsay"
$ command -V cowsay
cowsay is /nix/store/yqz388r6bjfkp3736inwzc5j4v9n4zba-cowsay-3.7.0/bin/cowsay

$ cowsay hello world
 _____________ 
< hello world >
 ------------- 
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

Once we’re done, we can exit the ephemeral shell that has cowsay in our PATH:

$ exit
exit

$ command -V cowsay
cowsay not found

What’s happening here?

$PATH env

Directories listed in PATH are where common executables are stored:

$ printf "$PATH" | tr ':' '\n'
/usr/local/sbin
/usr/local/bin
/usr/sbin
/usr/bin
/sbin

If we unset PATH, we can’t run a lot of things, excluding built-in shell commands:

$ command -V ls
ls is a tracked alias for /usr/bin/ls

$ unset PATH
$ ls
bash: ls: No such file or directory

$ command -V cd
cd is a shell builtin

$ command -V pwd
pwd is a shell builtin

$ cd /etc
$ pwd
/etc

Nix adds the .../bin directories of its packages to our PATH when we run nix shell, as seen here:

$ nix shell "nixpkgs#cowsay"
$ printf "$PATH" | tr ':' '\n'
/nix/store/k88sdsg1fj9sf4qz3raihqkxrj1yxbyk-cowsay-3.7.0/bin
/nix/store/3iprssz8zh2sq7mlnjzlfb2a1rdx3lbf-cowsay-3.7.0-man/bin
/usr/local/sbin
/usr/local/bin
/usr/sbin
/usr/bin
/sbin

This is really useful if we have Python 3.12 installed, but we need Python 3.9 for a project:

$ python --version
Python 3.12.4

$ nix shell "nixpkgs#python39"
$ python --version
Python 3.9.19

$ printf "$PATH" | tr ':' '\n'
/nix/store/i390rzjpc4dzw7biykbrfz2546zn3h9k-python3-3.9.19/bin
/usr/local/sbin
/usr/local/bin
/usr/sbin
/usr/bin
/sbin

There are several (and arguably better) ways to switch between different Python versions effectively, but the Nix approach can be used for a variety of programming languages, frameworks, and tools.

Shareable Configurations

tl;dr: You’ll need a file called flake.nix, in your repository root:

{
  description = "A devShell with GCC 4.9 and Python 3.9 for x86_64-linux";
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
  outputs = { self, nixpkgs }:
    let
      system = "x86_64-linux"; 
      #        ^^^^^^^^^^^^^^ can be any of the following:
      # x86_64-linux
      # x86_64-darwin
      # aarch64-linux
      # aarch64-darwin
      pkgs = nixpkgs.legacyPackages.${system};
    in
    {
      devShells.${system}.default = pkgs.mkShell {
        packages = [
          pkgs.gcc49
          pkgs.python39
        ];
      };
    };
}

Then, cd into the repository root and run nix develop.

Altogether, this is my output:

$ gcc --version
bash: command not found: gcc

$ python --version
Python 3.12.4

$ pwd
/tmp/nix-flake-example

$ ls -a
.  ..

$ nano flake.nix # Paste the preceding code block.
$ ls -a
.  ..  flake.nix

$ nix develop
warning: creating lock file '/tmp/nix-flake-example/flake.lock'

In my case, my PS1 (terminal prompt) changed to user@hostname:/tmp/nix-flake-example$. The following code snippets will continue using $ for simplicity.

We’ll check if GCC 4.9 and Python 3.9 is installed:

$ ls -a
.  ..  flake.nix  flake.lock

$ gcc --version
gcc (GCC) 4.9.4
Copyright (C) 2015 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

$ python --version
Python 3.9.19

Sounds nice?

This post is a brief guide for you to try out Nix shells and development environments (flake.nix).

If you’re interested in learning more about Nix, I’ve put together a list of links and additional information for you to start off:

Search for packages:

You can find packages from search.nixos.org.

Idiomatic flake.nix file

The example above suffers from writing the same code to support multiple systems, like so:

{
  description = "A devShell with Python 3.9 and GCC 4.9";
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
  outputs = { self, nixpkgs }:
    {
      devShells."x86_64-linux".default = pkgs.mkShell {
        packages = [ pkgs.gcc49 pkgs.python39 ];
      };
      devShells."x86_64-darwin".default = pkgs.mkShell {
        packages = [ pkgs.gcc49 pkgs.python39 ];
      };
      devShells."aarch64-linux".default = pkgs.mkShell {
        packages = [ pkgs.gcc49 pkgs.python39 ];
      };
      devShells."aarch64-darwin".default = pkgs.mkShell {
        packages = [ pkgs.gcc49 pkgs.python39 ];
      };
    };
}

We can write a function (in the Nix language) that takes the list of systems, and output pkgs.mkShell for all of them…

{
  description = "A devShell with Python 3.9 and GCC 4.9";
  outputs = { self, nixpkgs }:
    let
      supportedSystems = [
        "x86_64-linux"
        "aarch64-linux"
        "x86_64-darwin"
        "aarch64-darwin"
      ];

      forAllSystems = f:
        nixpkgs.lib.genAttrs supportedSystems (system: f system);

      nixpkgsFor = forAllSystems (system:
        import nixpkgs {
          inherit system;
          overlays = [ ];
        }
      );
    in
    {
      devShells = forAllSystems (system:
        let
          pkgs = nixpkgsFor.${system};
        in
        {
          default = pkgs.mkShell {
            packages = [ pkgs.gcc49 pkgs.python39 ];
          };
        }
      );
    };
}

… but it’s better to rely on a commonly used utility, like flake-parts:

{
  description = "A devShell with Python 3.9 and GCC 4.9";
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    flake-parts = {
      url = "github:hercules-ci/flake-parts";
      inputs.nixpkgs-lib.follows = "nixpkgs";
    };
  };

  outputs =
    inputs@{ flake-parts, ... }:
    flake-parts.lib.mkFlake { inherit inputs; } {
      systems = [
        "x86_64-linux"
        "aarch64-linux"
        "aarch64-darwin"
        "x86_64-darwin"
      ];
      perSystem = { lib, config, self', inputs', pkgs, system, ... }:
        {
          devShells.default = pkgs.mkShell {
            NIX_CONFIG = "experimental-features = nix-command flakes";
            nativeBuildInputs = [ pkgs.gcc49 pkgs.python39 ];
          };
        };
    };
}

Who is using Nix

Learning Nix

My progress with Nix is as follows:

  1. Using Nix in containers and virtual machines
  2. Using Nix in my main machine, through nix command and flake.nix
  3. Running NixOS in a spare machine
  4. Running NixOS as a daily driver

NixOS (a Linux distribution leveraging the Nix language) might be overkill or unsuitable for your needs (or wants). I recommend using the Nix suite of CLI tools and flake.nix as a base case.

Documentation Resources

The Nix documentation team recommends starting with nix.dev, and proceed with other resources on the official list, i.e. nixos.org/learn.

Regardless, the list below are the resources which I’ve used the most:

Closing Notes

What about NixOS?

NixOS is great. Nix has a module system that can centralize configuration languages (.json, .ini, .toml) into the Nix language. For example, install/uninstalls for databases like postgres can be toggled with one line. You can leverage modules to easily build servers:

# configuration.nix
{
  services.postgresql = {
    enable = true;
    package = pkgs.postgresql_15;
    dataDir = "/var/lib/postgresql/15";
  };
  services.nginx = {
    enable = true;
  };
}

Nix is atomic

Earlier we saw how packages are installed to /nix/store. Traditional package managers would scatter files across system directories. Nix’s approach (isolation) prevents conflicts between different versions of packages, which means that system updates and configuration changes are atomic and reversible.

Nix is your single source of truth of what you need to build and run your software. You update this single source of truth and it generally just works everywhere. You don’t need to maintain multiple descriptions of how to build and run your software anymore.

Mitchell Hashimoto - Using Nix with Dockerfiles

Suffice it to say, I really like Nix because it offers me a reliable and reproducible system environment. I can manage multiple versions of software without risking system-wide instability or dependency conflicts.

Nix is versatile

You can…

But if you’re not into that, you can use Nix as a simple development shell manager.

Nix is a rabbithole

Nix Flakes are still considered an experimental feature by Nix, though it is the de-facto standard for working with Nix. Because of this, you will see tutorials covering the legacy and/or new commands:

I’d be remissed if I didn’t mention this distinction, but this is exactly the sort of dichotomy that leaves a user with more questions than answers. Other cons include support only for Linux and macOS (no Windows/*BSD).


Drawbacks aside, if you’re a poweruser or looking to improve your devops, I’d still recommend looking into Nix. Spend some time hacking away on Nix code, or maybe use nix as a “try-before-you-buy” tool.

Thanks for reading!