Elixir Dev Environment With Nix

In a previous article, I explained how to set up Nix on MacOS. This article shows the way I set up a development environment for an Elixir project with Nix.

The instructions in this article assume that you have a working Nix installation on your machine.

Initialize Niv

On my system, I’m usually following the latest stable version of Nixpkgs. In a development environment specific to a project, it is necessary to install specific versions of the build dependencies or languages. Niv can help managing those dependencies independently of your global configuration.

To initialize Niv, go to your project folder and run:

nix-shell -p niv --run 'niv init'

This will create the folder nix with two files: sources.json contains the references and versions of your dependencies, and sources.nix defines an object with your sources, which you can import in your scripts.

default.nix

Create the file nix/default.nix. This is where I define the main dependencies. A basic version might look like this:

{ sources ? import ./sources.nix
, pkgs ? import sources.nixpkgs { }
}:

with pkgs;

buildEnv {
  name = "builder";
  paths = [
    elixir
    nodejs-14_x
    postgresql_12
  ];
}

Let’s start from the top. First, we import sources.nix to make the dependencies we added with Niv available, and with with pkgs, we load the packages into the current namespace. Finally, we define the packages we need in our environment. I usually have PostgreSQL running in a Docker container and only add it here to make psql and pg_dump available (e.g. to run mix ecto.dump).

shell.nix

We still need to configure the Nix shell to use the dependencies we defined. Create the file shell.nix in the root folder of the project.

{ sources ? import ./nix/sources.nix
, pkgs ? import <nixpkgs> { }
}:

with pkgs;
let
  inherit (lib) optional optionals;
in

mkShell {
  buildInputs = [
    (import ./nix/default.nix { inherit pkgs; })
    niv
  ] ++ optional stdenv.isLinux inotify-tools
  ++ optional stdenv.isDarwin terminal-notifier
  ++ optionals stdenv.isDarwin (with darwin.apple_sdk.frameworks; [
    CoreFoundation
    CoreServices
  ]);
}

Here we import the build environment from default.nix as well as niv and add some packages specific to the development environment and operating system, as opposed to the packages we need to build the project itself. This split of the dependencies also allows us to reuse default.nix when building Docker images with dockerTools without installing dependencies only relevant for the dev environment, but this is out of scope of this article.

The optional packages defined here are taken straight from the article Using Nix in Elixir projects.

Direnv

With the setup above, we can get into a Nix shell with the configured dependencies by running nix-shell from the root of the project. However, you probably would prefer to use the customized shell of your choice instead of being dropped into a bare bash shell. To do this, you can use direnv.

Update .gitignore and create the file .envrc in the root of the project:

echo ".direnv/" >> .gitignore
echo "use_nix" > .envrc

You will see a notice that .envrc is blocked. Do what the notice tells you and run:

direnv allow

From now on, whenever you change to the project directory, the Nix shell configuration will be loaded into your environment.

Updating Niv sources

Let’s have a look back at nix/sources.nix. The referenced nixpkgs branch may not refer to the latest version, or the version you need. With niv being available now, you can run:

niv update nixpkgs -b nixos-21.05

This command will update the nixpkgs reference in sources.json. Refer to the Niv documentation for more usage examples.

Overrides

Earlier we set elixir as a dependency. This is less than ideal, since this will point to whatever OTP and Elixir version the nix channel you are following is pointing to. It is better to define concrete Erlang/Elixir versions by using beam.packages.*:

buildEnv {
  paths = [
    beam.packages.erlangR23.elixir_1_11
    ...
  ];
}

You can see the available Erlang and Elixir versions here. Switch to the branch you are following in nix/sources.json.

However, sometimes you may want to use a version that hasn’t been packaged yet, or you may require a specific patch version. In that case, you can use overrides.

For example, let’s say you need Erlang 23 and Elixir 1.12.2. Change nix/default.nix to:

{ sources ? import ./sources.nix
, pkgs ? import sources.nixpkgs { }
}:

with pkgs;
let
  elixir = beam.packages.erlangR23.elixir.override {
    version = "1.12.2";
    sha256 = "1rwmwnqxhjcdx9niva9ardx90p1qi4axxh72nw9k15hhlh2jy29x";
  };
in

buildEnv {
  name = "builder";
  paths = [
    elixir
    nodejs-14_x
    postgresql_12
  ];
}

Here we override the arguments of the existing Erlang 23 / Elixir derivation. You can get the sha256 value with:

nix-prefetch-url --unpack https://github.com/elixir-lang/elixir/archive/v1.12.2.tar.gz

Sometimes you may also need to override the Erlang derivation. For example, as of this writing, the packaged ErlangR24 derivation does not build on an Apple M1, because the Erlang JIT does not support ARM64 processors yet. To get around that, you can disable JIT with a flag and base your Elixir derivation on that Erlang override.

{ sources ? import ./sources.nix
, pkgs ? import sources.nixpkgs { }
}:

with pkgs;
let
  erlang = erlangR24.override {
    version = "24.0.5";
    sha256 = "153kg6351yrkilr4gwg1jh7ifxpz9ar664mz7vdax9sy31q9i771";
    configureFlags = [ "--disable-jit" ];
  };

  beamPkg = pkgs.beam.packagesWith erlang;

  elixir = beamPkg.elixir.override {
    version = "1.12.2";
    sha256 = "1rwmwnqxhjcdx9niva9ardx90p1qi4axxh72nw9k15hhlh2jy29x";
  };
in

buildEnv {
  name = "builder";
  paths = [
    elixir
    nodejs-14_x
    postgresql_12
  ];
}

You can get the sha256 value for Erlang with:

nix-prefetch-url --unpack https://github.com/erlang/otp/archive/OTP-24.0.5.tar.gz

Overlays

I didn’t have to use any overlays in my setup yet, but if you need them, I’d suggest to put them in a file called nix/overlays.nix.

You can then use them in nix/default.nix:

{ sources ? import ./sources.nix
, pkgs ? import sources.nixpkgs {
    overlays = [ (import ./overlays.nix) ];
  }
}:

Or in shell.nix:

{ sources ? import ./nix/sources.nix
, pkgs ? import sources.nixpkgs {
    overlays = [ (import ./nix/overlays.nix) ];
  }
}:

Code

You can find the code on Github.