Using Nix in Elixir projects

Nix is a purely functional package manager that makes possible to create reproducible setups to share between developers. I have written a rather long article about it recently and I want to continue here with some specific instructions for Elixir projects in a much more concise way. I assume you know a bit about Nix in general — if it is not the case, you can read my previous article or search the web for information.

Elixir and Erlang derivations

Before to look at what should go in the shell.nix for different Elixir projects, let’s study how to make Elixir available.

Top-level Elixir and Erlang derivations

The most obvious derivation for Erlang is erlang. It is an Erlang version considered stable, and it may not be the last one. For instance, at the time I am writing this article, the last Erlang/OTP version is 21.1, when erlang is at 20.3.8.9. To get a specific Erlang/OTP major version release, other derivation exist: currently erlangR18 to erlangR21. They install the last available version for each corresponding major release.

It is the same for Elixir: elixir installs an Elixir release considered stable, which may or may not be the last one available. elixir_1_3 to elixir_1_7 exist to let you install the last patch version for each minor release.

Please be aware of one thing: global Elixir derivations are built on top of the default erlang. As all is isolated in Nix, if you install for instance erlangR21 and elixir_1_7, running erl will get you to an Erlang shell on OTP 21 while running iex will get you to an IEx 1.7 shell running on OTP 20. This is because elixir_1_7 uses internally the erlang derivation, which is on OTP 20. Notice that you generally do not need to make Erlang directly available in Elixir projects, unless you have some Erlang source code or escripts. This differs from, say, asdf.

nixpkgs offers some niceties to work with BEAM languages. It takes the form of beam.* modules. Let’s talk about them.

beam.interpreters.*

The erlang* and elixir* derivations are inherited from beam.interpreters. For instance, erlangR21 is in fact beam.interpreters.erlangR21. The same goes for Elixir.

This module defines a few things:

  • the erlangRxx derivations;
  • erlang as an alias to one of the erlangRxx;
  • the beam.packages.erlang* modules, which group packages built on top of a given Erlang/OTP release;
  • the elixir* derivation as aliases to the beam.packages.erlang.elixir* ones — as you can see, they are built on to the erlang derivation.

You generally do not need to use derivation from this module as they are aliased in the top-level definitions. It is mainly there for internal organisation.

beam.packages.*

The beam.packages.* modules define a set of derivation built on top of each erlang release. For instance:

  • beam.packages.erlang.elixir is the default Elixir version built on top of the default Erlang version. elixir is aliased to it, and it is itself in fact currently an alias to beam.packages.erlang.elixir_1_7;
  • beam.packages.erlangR19.elixir_1_6 is the last Elixir 1.6 version built on top of the last Erlang/OTP 19 version;
  • beam.packages.erlangR21.rebar is the last rebar built on the last Erlang/OTP 21 version.

Building custom derivations

Sometimes you may need a non-standard Erlang or Elixir build. To enable this, built-in Erlang and Elixir derivations are overridable. For instance, if you want to build Erlang/OTP without HiPE, you can create a custom derivation:

erlangR21_noHipe = erlangR21.override { enableHipe = false; };

Once you have a custom Erlang derivation, you can build a module like beam.packages.erlang* using beam.packagesWith. This way, if you want to derive elixir_1_7 on top of this custom derivation, you would do:

# Instead of beam.packages.erlangR21.elixir_1_7, do:
elixir = (beam.packagesWith erlangR21_noHipe).elixir_1_7;

You can also specify a custom source or revision. For instance, to build the current Elixir master over our custom Erlang:

elixir = (beam.packagesWith erlangR21_noHipe).elixir.override {
version = "1.8.0-dev";
rev = "eb069dd43ba98958f4161b070d111f952b1c656c";
sha256 = "0kwqdy75x0xkld1gpzz355h9yw57c6jpq1b7lz7pkn5kxywxn9qb";
};

version can be any version. If you do not specify rev, it automatically defaults to v${version}. If you specify rev, it can be an arbitrary value. I have used here 1.8.0-dev to match the current real version on master. Please note that I have precised a commit hash in rev instead of "master": if I had set rev = "master";, it would have work immediately. However, when the master branch would change, the sha256 would not match anymore, thus broking the derivation.

If you are curious about more options, you can take a look at the Erlang and Elixir generic builders.

Standard Elixir projects

The bare minimum dependencies for any Elixir project are Elixir and Git. I usually specify Erlang/OTP and Elixir versions to ensure updates are non-breaking:

If your project builds or uses an escript, you will need to make the escript executable available in your path by adding an Erlang derivation. Here it would be erlangR21 to match the one used for Elixir.

Some Hex packages may need additional dependencies to work. All standard Elixir projects I generate with xgen install file_system and ExUnit Notifier, which both need some external dependencies. I have come up with this shell.nix which works on both Linux and macOS:

Nerves projects

If you have ever set up an environment for a Nerves project, you can notice there are some packages to install. Instructions are given in the official documentation for macOS and Debian-like Linux, but not Fedora or other Linux distributions. Nix is here a perfect fit to make the setup easier by far, and shareable between developers. The minimum shell.nix I’ve found to work is the following:

With this one, you should be able to build your firmware from a Nix shell. You can also burn it on macOS natively. On Linux, as fwup requires to be run as root, you should run by hand:

$ sudo $(which fwup) _build/<target>/prod/nerves/images/<app>.fw

This is due to fwup not being available in the root user PATH. It will change in the future as I have made a patch in Nerves to use any fwup available when running mix firmware.burn.

Phoenix projects

Phoenix projects depend at least on file_system, which has some external dependencies as shown previously. You will also often use node.js to build your assets and a database like PostgreSQL. This is a shell.nix working for standard Phoenix projects:

With this shell.nix, PostgreSQL is available locally. This helps keeping all dependencies explicit and avoiding to mix different projects in a global PostgreSQL instance. The sheelHook sets the PGDATA variable which is automatically used by PostgreSQL to know how to store its data.

Working with a local PostgreSQL instance

Before to use a local PostgreSQL instance, ensure you have no other instance running. If you insist in having multiple PostgreSQL instances, ensure they all serve on a different port. I will not cover this use case.

PGDATA being set by the shellHook, PostgreSQL will use the db directory in your project whenever you run it in a Nix shell. You can then initialise the database by running:

$ initdb --no-locale --encoding=UTF-8

The --no-locale --encoding=UTF-8 part is optional. On macOS it works well without, but I’ve had some issues on Linux where some locales seems missing.

Then, to start your local instance:

$ pg_ctl -l "$PGDATA/server.log" start

Again, the -l "$PGDATA/server.log" argument is optional. Without it, you just get PostgreSQL logs in your console. Usually I don’t want them so I precise a log file.

For Phoenix projects to work out of the box with their default values, you need to create a postgres user with the CREATEDB permission:

$ createuser postgres --createdb

If it’s all good, you can setup your Ecto repo:

$ mix ecto.setup

Because I’m too lazy to run all these commands by hand each time I need to setup a project and because automation is good, I’ve written a setup script that does all these steps for you, only if necessary. You can commit it in your projects to help setup the environment, with some info in the README.md.

When you are not working on your project, don’t forget to stop your local PostgreSQL instance by running, still in the Nix shell:

$ pg_ctl stop

As I’m a lazy man — again — I’ve aliased the start and stop commands to pgst and pgsp respectively. Define aliases as your fingers need. Also, as I’m sometimes light-headed, I use direnv to automatically switch my environment to a Nix shell when I am in the project directory. I’ve added a little script that checks wether a PostgreSQL instance is running and emits a warning if it is not the local one. In the case you see such warning, want to switch to the local one but can’t remember the one running, you can do:

$ killall postgres && pg_ctl -l "$PGDATA/server.log" start

Which obviously I’ve aliased to pgswitch. I don’t do automatic switching since you can open another project without wanting to switch.

Conclusion

Nix and Elixir match really well when it comes to enhance the software development experience. They both tend to make complex tasks easier and are a joy to use. Being a Nix user for my user configuration workflow, I wanted to switch from asdf to use it also in my Elixir development workflow. I even pushed further by integrating all needed tools for different kind of projects, enabling to build them easily on any machine, with instruction as easy as installing Nix and running a Nix shell.

Getting to these shell.nix has taken me a few weeks, so I really wanted to share them with you to help the community to define better patterns. I hope you will enjoy them. If you have some suggestions or enhancements, especially in the way of handling local PostgreSQL instances, comments are really welcome. Have a nice and happy day!