How to Set Up Nix Shell for Development Environments

Nix is known for its robust package management and reproducibility. One of its standout features is the Nix Shell, which allows you to create isolated, reproducible environments for your development projects. Whether working on a small script or a full-fledged application, setting up a Nix Shell ensures consistency across different environments.

Why Use Nix Shell?

Setting up consistent environments for team members when developing both front-end and back-end systems was a real pain back in the day. We often juggled multiple Node.js versions and managed countless dependencies across projects. Our full-stack engineers would spend about 10 minutes each time they switched from front-end to back-end to ensure no conflicting versions of dependencies. With Nix Shell, this problem is eliminated thanks to its ability to create project-specific environments, ensuring consistency and saving time.

Before diving into the setup, here’s a quick look at why you’d want to use Nix Shell:

  • Isolated Development Environments: Nix Shell allows you to create environments that won’t interfere with your system’s global packages.
  • Reproducibility: Anyone using your project can easily set up the same environment, ensuring consistency.
  • Portability: You can easily define development environments that are project-specific, making it easy to collaborate on code. All of them are stored inside one single file.

Now, let’s get started with the setup.

Step 1: Install Nix

If you haven't already installed Nix, you'll first need to set it up. Follow these steps:

  1. Open your terminal.

Run the following command to install Nix:

$ curl -L https://nixos.org/nix/install | sh
  1. After installation, source your environment:
$ . ~/.nix-profile/etc/profile.d/nix.sh

You can also follow the official installation guide if you prefer a more detailed approach.

Step 2: Create a shell.nix File

The shell.nix file defines the environment and dependencies for your project.
Let's create one for a simple Deno + Typescript project.

  1. Inside your project directory, create a new file named shell.nix
$ touch shell.nix
  1. Edit the file with your preferred text editor and define your environment.
    Below is an example of a Typescript-based project:
{ pkgs ? import <nixpkgs> {} }:

pkgs.mkShell {
  name = "Typescript Project";
  buildInputs = with pkgs; [
    nodejs
    typescript
    deno
  ];
  # Optionally, define environment variables
  shellHook = ''
    FOO=BAR
    echo "This is my Typescript Project."
  '';
}

In this example, I'm importing the Nix package set (nixpkgs) and specifying nodejs as our environment, along with typescript and deno. The shellHook runs a command every time you enter the shell; it's a good idea you can source your project's .env file here without leaking your credentials.

Step 3: Enter the Nix Shell

Once I've defined the environment in shell.nix, entering the shell is as simple as one command:

$ nix-shell

This command will download the necessary packages and drop me into the isolated environment defined in shell.nix. You'll see the message from the shellHook if you set one. You can work with your project from here, knowing that the environment is isolated and all dependencies are correctly set up.

Step 4: Working with Development Shells

While nix-shell is perfect for creating temporary environments, sometimes you may want to ensure the environment is more tailored for development purposes.

This is where devShells in a flake.nix file comes into play.

Here is a quick way to turn your environment into a reproducible development shell using Flakes:

  1. Ensure your nix command can work with flakes by enabling them in your configuration.
$ nix --experimental-features 'nix-command flakes'
  1. Create a flake.nix File into the project root:
{
  description = "My development shell";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
  };

  outputs = { self, nixpkgs }: {
    devShells.default = nixpkgs.lib.mkShell {
      buildInputs = [
        nixpkgs.nodejs
        nixpkgs.typescript
        nixpkgs.deno
      ];
    };
  };
}
  1. Now, you can enter the development shell with the following:
$ nix develop

This approach ensures that everyone using the project has the same environment by referencing the same flake.nix file.

Step 5: Customising Your Nix Shell

We can further customise our Nix shell to fit our project's needs:

  • Add more dependencies: In the buildInputs section, you can add other languages, libraries, and tools like nodejs, git, docker, etc...
  • Environment variables: You can set up project-specific environment variables inside shellHook.
  • Pre-run scripts: The shellHook can also execute scripts such as initialising databases or setting up cache directories.

Step 6: Exiting the Shell

When you're done working in your environment, type exit to leave the Nix shell. It doesn't affect your global system environment, so cleaning up afterwards is unnecessary.

Conclusion

Nix Shell provides a flexible, isolated development environment that ensures consistency across teams and systems by setting up a shell.nix or flake.nix, you can define precisely how your project's environment should look, making onboarding new developers easier and ensuring that everyone works with the same tools and dependencies.

With just a few steps, you can have a fully reproducible development environment that can be shared across teams and systems.

This post is based on verified information from the Nix manual. If you need more details, the documentation provides in-depth coverage of nix-shell and Flakes.

Bonus

Multiple System Architecture flake.nix for Project

Managing environments can be a hassle when working on cross-platform projects with developers using different operating systems. Nix simplifies this by letting you define shared dependencies and OS-specific configurations in a single flake.nix file, reducing duplication and complexity. This method ensures each developer’s setup remains consistent, whether on macOS, Linux, or Windows via WSL. Here's a streamlined example:

{
  description = "Cross-platform project for macOS and Windows (WSL) with shared dependencies";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";  # Specify the Nixpkgs source
  };

  outputs = { self, nixpkgs }: let
    # Function to define the development shell for each system
    mkDevShell = system: pkgs: pkgs.mkShell {
      buildInputs = [
        pkgs.python310           # Python 3.10
        pkgs.python310Packages.pip
        pkgs.nodejs-18_x         # Node.js 18.x
        pkgs.yarn                # Yarn for Node.js package management
      ];

      shellHook = ''
        echo "Welcome to the ${system} dev environment!"
        echo "Python version: $(python --version)"
        echo "Node.js version: $(node --version)"
      '';
    };

    # Define packages for each system
    darwinPkgs = import nixpkgs { system = "aarch64-darwin"; };  # macOS (ARM)
    linuxPkgs = import nixpkgs { system = "x86_64-linux"; };     # Windows 11 via WSL (Linux x86_64)

  in {
    # Use mkDevShell to define the environments for both macOS and WSL
    devShells.aarch64-darwin.default = mkDevShell "macOS" darwinPkgs;
    devShells.x86_64-linux.default = mkDevShell "Windows (WSL)" linuxPkgs;
  };
}

This approach keeps multi-platform development simple and ensures consistency across different environments.