Using Nix in Elixir projects
This article was originally published on Medium.
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 theerlangRxx
;- the
beam.packages.erlang*
modules, which group packages built on top of a given Erlang/OTP release; - the
elixir*
derivation as aliases to thebeam.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 tobeam.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:
let 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:
let 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:
let 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:
{ pkgs ? import <nixpkgs> {} }:
with pkgs;
let
# I like to define variables for derivations that have
# a specific version and are subject to change over time.
elixir = beam.packages.erlangR21.elixir_1_7;
in
mkShell {
buildInputs = [ elixir git ];
}
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:
{ pkgs ? import <nixpkgs> {} }:
with pkgs;
let
inherit (lib) optional optionals;
elixir = beam.packages.erlangR21.elixir_1_7;
in
mkShell {
buildInputs = [ elixir git ]
++ optional stdenv.isLinux libnotify # For ExUnit Notifier on Linux.
++ optional stdenv.isLinux inotify-tools # For file_system on Linux.
++ optional stdenv.isDarwin terminal-notifier # For ExUnit Notifier on macOS.
++ optionals stdenv.isDarwin (with darwin.apple_sdk.frameworks; [
# For file_system on macOS.
CoreFoundation
CoreServices
]);
}
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:
{ pkgs ? import <nixpkgs> {} }:
with pkgs;
let
inherit (lib) optional optionals;
elixir = beam.packages.erlangR21.elixir_1_7;
in
mkShell {
buildInputs = [ elixir git fwup squashfsTools file ]
++ optional stdenv.isDarwin coreutils-prefixed # For Nerves on macOS.
++ optional stdenv.isLinux x11_ssh_askpass; # For Nerves on Linux.
# This hook is needed on Linux to make Nerves use the correct ssh_askpass.
shellHooks = optional stdenv.isLinux ''
export SUDO_ASKPASS=${x11_ssh_askpass}/libexec/x11-ssh-askpass
'';
}
With this one, you should be able to build and burn your firmware from a Nix shell.
Edit: in a previous version, this article stated:
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 userPATH
. It will change in the future as I have made a patch in Nerves to use anyfwup
available when runningmix firmware.burn
.
The patch has since landed in Nerves 1.3.1.
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:
{ pkgs ? import <nixpkgs> {} }:
with pkgs;
let
inherit (lib) optional optionals;
elixir = beam.packages.erlangR21.elixir_1_7;
nodejs = nodejs-10_x;
postgresql = postgresql100;
in
mkShell {
buildInputs = [ elixir nodejs git postgresql ]
++ optional stdenv.isLinux inotify-tools # For file_system on Linux.
++ optionals stdenv.isDarwin (with darwin.apple_sdk.frameworks; [
# For file_system on macOS.
CoreFoundation
CoreServices
]);
# Put the PostgreSQL databases in the project diretory.
shellHook = ''
export PGDATA="$PWD/db"
'';
}
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 shellHook
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!