We need Erlang and Elixir installed, which might sound simple but there are tradeoffs to consider when setting up a development environment such that it can be shared across a team. And because most projects need to persist data we'll also add a PostgreSQL database so our explorations can stay relevant to real-world complexities.
Let's try some different approaches first, and then discuss their pros and cons at the end.
Table of Contents
Just… Install the Dependencies?
Why not just install the dependencies as each tool's website suggests? Why make this any more complicated?
I’m on MacOS and erlang.org, elixir-lang.org, and postgresql.org all suggest installation via Homebrew, which is a very popular package manager for MacOS.
So, let's first install Homebrew:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
And then we install our tools:
$ brew install erlang elixir postgresql@15
And… we’re done!?
But there are critical problems with this approach:
- No versioning - There's no control over what versions just got installed, so depending on when a person runs the install command they'll end up with whatever unpredictable mix of versions are available at that time. This alone is enough to entirely discard this approach.
- Globally installed - The tools all got globally installed, which means all other projects are impacted as well. This is a recipe for disaster as we need to be in control of versions per project.
So “just installing” isn’t viable: We need to fully specify our environment such that the exact same versions are installed on all our machines. Thankfully there are entire projects made specifically to solve this, so let's go explore those.
asdf
asdf is a version manager with a lot of plugins (for our needs: Erlang, Elixir, and Postgres). The asdf installation guide says to first install some system dependencies via Homebrew and then clone the asdf repository:
$ brew install coreutils curl git
…
$ git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.13.1
$ echo '. "$HOME/.asdf/asdf.sh"' >> ~/.zshrc
ℹ️ BTW I find it quite strange to install asdf via
git clone
. And asdf can actually be installed directly via Homebrew, but according to the asdf installation guide it’s “highly recommended” to use the Git method so that’s what I’m sticking with here 🤷
And then in a fresh terminal we add plugins:
$ asdf plugin add erlang https://github.com/asdf-vm/asdf-erlang.git
$ asdf plugin-add elixir https://github.com/asdf-vm/asdf-elixir.git
$ asdf plugin-add postgres
And then we need to go to each plugin’s project page and note down which dependencies and configuration options they need, which aggregates into these two lines:
$ brew install autoconf openssl@1.1 openssl libxslt fop gcc readline zlib curl ossp-uuid
$ echo 'export KERL_CONFIGURE_OPTIONS="--without-javac --with-ssl=$(brew --prefix openssl@1.1)"' >> ~/.zshrc
I don't really know (or care to know) why those exact dependencies are needed, and I surely won't notice if a plugin suddenly changes its requirements… but we'll just push onwards and now create a .tool-versions
file which is how asdf is configured to install certain tools:
$ cat << EOF >> .tool-versions
erlang 26.2.1
elixir 1.16.0
postgres 15.5
EOF
And now we just have to invoke asdf install
to install everything:
$ asdf install
...
$ which erl
/Users/cloud/.asdf/shims/erl
$ which elixir
/Users/cloud/.asdf/shims/elixir
$ which psql
/Users/cloud/.asdf/shims/psql
We now have all our tools installed 🎉
direnv
Next let's think on how we can keep our environment in sync across the team… i.e. if we update .tool-versions
how will anyone know to re-run asdf install
?
One common solution is to handle this automatically via direnv which is made for running certain commands upon entering a folder.
Direnv even comes in a special asdf flavor, which we'll install by adding it to the .tool-versions
file, then activate it for our shell, and finally create an .envrc
file (that's how direnv gets configured to execute a command every time the folder is entered):
$ asdf plugin-add direnv
$ echo direnv 2.30.0 >> .tool-versions
$ asdf install
$ asdf direnv setup --shell zsh --version 2.30.0
$ asdf direnv local
$ echo "use asdf" > .envrc
If we now simulate a change to .tools-versions
by changing the required erlang version, we'll see a prompt to re-install asdf dependencies. This way the whole team will keep our environments synchronized:
$ cd perfect-elixir/
direnv: loading ~/perfect-elixir/.envrc
direnv: using asdf
direnv: Creating env file /Users/cloud/.cache/asdf-direnv/env/1510633598-1931737049-390094659-716574907
direnv: erlang 26.2.2 not installed. Run 'asdf direnv install' to install.
direnv: referenced does not exist
$ asdf direnv install
Downloading 26.2.2 to /Users/cloud/.asdf/downloads/erlang/26.2.2...
...
$ which erl
/Users/cloud/.asdf/installs/erlang/26.2.2/bin/erl
With this we have the essence of a workflow where asdf helps us keep our environments synced 🎉
We could keep improving this solution, but I'm not sure it's worth it compared to exploring the other popular tools that are available.
Nix
Nix is a tool "for reproducible and declarative configuration management", which is a fancy way of saying it'll manage environments. It's available for MacOS and Linux, and it's full of packages and a very active community. Let’s give it a try!
First step, as always, is to install:
sh <(curl -L https://nixos.org/nix/install)
ℹ️ BTW the installer requires
sudo
, and it does some exotic things so be aware of that before running. E.g. it creates a new “Nix Store” drive-volume, and also creates 32 hidden new users 🤷
Nix is based around a custom pseudo programming language that allows for programmatically specifying dependencies. It took me a while to even get the most basic thing going (various articles all seem to point in different directions, and there's some complicated language to understand), but I'm pretty sure we're supposed to put Nix code into a file called flake.nix
because "Flakes" are how Nix deals with reproducible environments. The nix
CLI suggests an example we can follow:
$ nix --help
...
Examples
· Create a new flake:
| # nix flake new hello
| # cd hello
...
Before creating a new flake file we must first enable some "experimental features" because I guess flakes are still new:
$ mkdir -p ~/.config/nix && echo "experimental-features = nix-command flakes" > ~/.config/nix/nix.conf
And then we can generate a flake file:
$ nix flake new .
wrote: /Users/cloud/Documents/nix/flake.nix
$ cat flake.nix
{
description = "A very basic flake";
outputs = { self, nixpkgs }: {
packages.x86_64-linux.hello = nixpkgs.legacyPackages.x86_64-linux.hello;
packages.x86_64-linux.default = self.packages.x86_64-linux.hello;
};
}
I don't know why that generated file references "legacyPackages", but err… let's just ignore that. And I don't know why it picked the "x86_64-linux" flavor when my platform is "x86_64-darwin", but we can edit our way out of that. And while we're editing let's also specify the dependencies we want, so the file looks like this:
$ cat flake.nix
{
description = "A flake";
outputs = { self, nixpkgs }: {
devShells.x86_64-darwin = {
default = nixpkgs.legacyPackages.x86_64-darwin.mkShell {
buildInputs = [
nixpkgs.legacyPackages.x86_64-darwin.erlangR26
nixpkgs.legacyPackages.x86_64-darwin.elixir_1_16
nixpkgs.legacyPackages.x86_64-darwin.postgresql_15
];
};
};
};
}
ℹ️ BTW I found what versions are available by running the command
nix search nixpkgs darwin.erlang
, for each of the tools.
And now we can activate this flake by running nix develop
, which starts a new shell that has all our dependencies available:
$ nix develop
...
macOS-14:perfect-elixir cloud$ which erl
/nix/store/49qw7cw30wszrfn3sa23qnlskyvbnbhi-erlang-26.2.2/bin/erl
macOS-14:perfect-elixir cloud$ which elixir
/nix/store/rr6immch9mp8dphv1jvgxym35za4b7jy-elixir-1.16.1/bin/elixir
macOS-14:perfect-elixir cloud$ which psql
/nix/store/v5ym92k3kss1af7n1788653vis1d6qsc-postgresql-15.5/bin/psql
Cool!
And if we exit the shell the tools are no longer available:
macOS-14:perfect-elixir cloud$ which erl
/nix/store/49qw7cw30wszrfn3sa23qnlskyvbnbhi-erlang-26.2.2/bin/erl
macOS-14:perfect-elixir cloud$ exit
exit
$ which erl
erl not found
So now we have this safe, reproducible specification of our environment that is guaranteed to be the same across any computer. Nice!
direnv
Starting a new shell to do any work is a bit cumbersome though, can we somehow automate all this so we can "just" sit down to develop and trust the environment-details are taken care of?
Let's once again turn to direnv
.
First we need to install it, which can be done directly via Nix:
$ nix-env -iA nixpkgs.direnv;
$ echo 'eval "$(direnv hook zsh)"' >> ~/.zshrc
$ source ~/.zshrc
ℹ️ BTW this isn't done via the
flake.nix
file because we needdirenv
to be available globally, not just when the flake is active. You might also notice this command doesn't specify a version (in fact Nix doesn't track versions ofdirenv
at all), but it's very unlikely this imprecision will become a problem because direnv is very unlikely to change its basic interface.
And with direnv
installed we can now automatically activate the flake:
$ echo "use flake" > .envrc
$ direnv allow
direnv: loading ~/Documents/nix/.envrc
direnv: using flake
…
$ which erl
/nix/store/rp1c50s0w039grl22q086h0dyrygk0p2-erlang-26.2.1/bin/erl
$ which elixir
/nix/store/66f9b1d1c4fmhz6bd3fpcny6brjm0fk7-elixir-1.16.0/bin/elixir
$ which psql
/nix/store/zhk6mf2y5c07zqf519zjkm3fm2nazmvj-postgresql-15.5/bin/psql
So now whenever we go into the folder our environment will automatically be activated, and because we can trust nix to track the versions our entire team will now have the same replicated environment. Awesome! 🎉
ℹ️ BTW
direnv
insists we rundirenv allow
whenever the.envrc
file changes because it's a vector for malicious code. Always audit what has changed in.envrc
before re-allowing it.
Bootstrapping
At this point there's just one more aspect to consider: How does someone "hook into" all this, when they come in with a new computer? We need some kind of bootstrap script that installs Nix and makes direnv
available to get people into our ecosystem.
A simple first incarnation of such a script could be something like this, which installs and configures nix and direnv if they aren't already installed:
$ cat install.sh
#!/bin/zsh
set +euo pipefail
# If nix isn't installed, install it
command -v nix-build &> /dev/null || {
echo "Installing Nix..."; sh <(curl -L https://nixos.org/nix/install);
echo "Nix installed, please restart terminal & re-run this script"
exit 1
}
# Configure nix
mkdir -p ~/.config/nix && echo "experimental-features = nix-command flakes" > ~/.config/nix/nix.conf
# If direnv isn't installed, install it
command -v direnv &> /dev/null || {
echo "Installing direnv..."; nix-env -iA nixpkgs.direnv;
}
# Ensure direnv is activated in the shell
grep -q 'eval "\$(direnv hook zsh)"' ~/.zshrc || {
echo 'eval "$(direnv hook zsh)"' >> ~/.zshrc
echo "Hook added to .zshrc, please restart terminal & re-run this script"
exit 1;
}
echo "Setup complete"
That is a simple script, and just for illustration. It only works in zsh shells, and makes other questionable assumptions, but only you know your context so only you can write the script that fits your requirements. But this simple script at least illustrates how we can now have a workflow where new joiners can onboard themselves, and everyone the same identical environment made available to them. Great! 🎉
ℹ️ BTW there is a package called nix-direnv which I intended to use. But its readme recommends installation via something called Home Manager using Nix, which describes itself as a way to "Manage a user environment using Nix". Unfortunately Home Manager's readme offers words of warning that says it's a difficult system that can possible mess up your home directory. So… I just installed
direnv
directly via Nix 👀
pgkx
pkgx has the tagline "RUN ANYTHING", and it's not kidding around: pkgx node --help
is all it takes to run Node.JS, and pkgx also promises to have support for development environments. And it's by the people behind Homebrew so they surely know a thing or two about installing tools. Let's give it a try!
First we install and activate pkgx:
brew install pkgxdev/made/pkgx
…
eval "$(pkgx integrate)"
Then we write a .pkgx.yml
file to specify the dependencies we want:
$ cat .pkgx.yml
dependencies:
erlang.org: =26.2.1
elixir-lang.org: =1.16.0
postgresql.org: =15.2.0
And then the docs say we just have to run the built-in companion-command called dev
to activate everything:
$ dev
env +erlang.org=26.2.1 +elixir-lang.org=1.16.0 +postgresql.org=15.2.0
$ which erl
/Users/cloud/.pkgx/erlang.org/v26.2.1/bin/erl
And… that’s it?! All our tools are now available. And they even disappear when we leave the folder:
$ cd ..
env -erlang.org=26.2.1 -elixir-lang.org=1.16.0 -postgresql.org=15.2.0
$ which erl
erl not found
$ cd perfect-elixir
env +erlang.org=26.2.1 +elixir-lang.org=1.16.0 +postgresql.org=15.2.0
$ which erl
/Users/cloud/.pkgx/erlang.org/v26.2.1/bin/erl
That is really as simple and magic as it gets! 🤯
Bootstrapping
Let's wrap that up in a nice install-script, to make it easy for others to get started:
$ cat install
#!/bin/zsh
set +euo pipefail
command -v brew &>/dev/null || /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
command -v pkgx &>/dev/null || brew install pkgxdev/made/pkgx
command -v dev &>/dev/null || eval "$(pkgx integrate)"
command -v erl &>/dev/null || dev
echo "Setup complete."
$ source install # we source it to let it fully activate pkgx in our current shell, so we don't have to restart the terminal
Again we've written a very simple bootstrapping script here, and you'll have to imagine all the ways we could modify it to be more resilient and flexible. But it conveys the essence of easily onboarding into our shared environment, because all a new joiner has to do is source that script. Doesn't get much easier than that!
In Conclusion
asdf Thoughts
I think asdf is a pretty popular choice, but I must say I didn't have much joy working my way through this chapter. Maybe it's a personality thing, but I feel the installation was awkward right out the gate with the system dependencies and git cloning, and then having to cater to each plugin's unique requirements nosedived my joy.
It's not a bad tool at all, but I end up feeling it's a bit cumbersome or old-fashioned, if that makes sense?
If you think I used it wrong or have simpler ways of using asdf please reach out in the comments, I'm happy to learn!
Nix Thoughts
Wow, it was both really pleasing to get Nix working and a stress-test of my patience with the rough edges I encountered. The more I read the more I started feeling nix is like an entire lifestyle I can invest my life into. It's a really impressive solution for being 100% in charge of dependencies, I can see how it must be amazing for setting up an entire operating system and creating ultra-super-flexible installation definitions where every detail of every dependency is controllable. Nix will definitely support any level of parameterized and reusable sub-trees of dependencies that can be combined and aggregated to perfectly express any ultra-sophisticated environments, and I really am genuinely impressed by all of it.
But it's also not at all simple. And I sure didn't feel joy working through it. I'm not even sure I'm using Nix correctly at all, and part of me fears getting flamed in the comments by whatever obvious things I've missed.
Just to illustrate with an example, this is the kind of experience I had getting into Nix where it almost seems helpful but then doesn't follow through:
$ nix-env
error: no operation specified
Try 'nix-env --help' for more information.
$ nix-env --help
Unknown locale, assuming C
No manual entry for nix-env
But even with the rough edges aside, it also seems like quite a lot of effort to get some system dependencies installed. I mean, it's an entire new domain to learn, with a whole custom pseudo programming language, and backed by a pretty invasive sudo-based installation. It's possible I'll end up needing the flexibility of Nix one day and then I won't hesitate to pick it up, but for now I'll politely move on.
Please do comment if you have alternative Nix solutions though, I'm happy to learn!
pkgx Thoughts
I am quite frankly blown away at how little effort it took to learn and use pkgx: A simple non-sudo installation, immediately straight-forward specification of dependencies, and an incredibly easy way to get it all activated. I'm just so fond of how pkgx feels: It does exactly what it needs for dependencies, and gets out of the way for anything else. Each distinct use case feels clearly expressed, and that dev
command is almost too good to be true right?? What a joy to use!
And we didn’t even cover how pgkx also supports adding one-off dependencies to individual scripts, here's a small example that uses the GitHub CLI:
$ cat gh-script
#!/bin/zsh
eval "$(pkgx --shellcode)"
# ^^ integrates `pkgx` during this script execution
env +cli.github.com
gh --help
Running it, it transparently makes the GitHub CLI available inside the script, in this case showing its help text:
$ ./gh-script
✓ ~/.pkgx/cli.github.com/v2.44.1
Work seamlessly with GitHub from the command line.
USAGE
gh <command> <subcommand> [flags]
It's a great technique to prevent the root .pkgx.yml
from having to be a superset of all possible dependencies the project needs, because that makes it difficult to upgrade anything because unknown amount of code could break as a result. Instead when a script specifies its own dependencies they can be upgraded piecemeal and as needed. Quite cool!
Of course everything is a tradeoff, and pkgx is quite new so it doesn’t have the largest set of packages available yet (even if it is growing fast). Nothing time won't solve, but right now there are some shades being on the "cutting edge" by adopting it, which isn't for everyone.
A the end of the day I'm not here to declare "winners" in any objective sense, because only you know your own context well enough to decide what tradeoffs are worth it. But I'll say for my own preferences and needs I clearly feel drawn towards pkgx, so that's what I'll be going with.
Top comments (3)
Great breakdown of the contemporary options. I use asdf with direnv and know of nix, but its configuration seemed pretty daunting and just another rabbit hole to go down. Thanks for introducing pkgx, looks very sleek and will definitely try it out.
I wonder what the journey is like with nix and pkgx to install erlang and elixir with compiled source docs?
In asdf, erlang docs are installed by setting some env variables, and the elixir plugin will compile any github version tag or sha ref from scratch with e.g.
asdf install elixir ref:v1.17.0-dev
. This way you can navigate to any erlang or elixir function's source from your own codebase in an IDE.Thanks for the feedback, and yeah I also wonder about that journey. We can see the pkgx Erlang package page here which leads to the actual pkgx package.yml definition here, and in that we can spot the familiar environment flags of
--without-javac
& friends. I guess we could make a PR that adds a configuration variant?Very interesting to hear about the ability to navigate to built-in source, I can't quite find a link that describes this so if you have any resources (or can write a blog about it 😅) I'd be very happy to receive.
Yes absolutely, you're very welcome to write about it! Wish I had the time... 🫠
I posted a reply on the Elixir forum about this and as luck would have it the link was in my notifications as a recent popular link.
Please link your blog post to the form thread when you're done, I'm sure people would want to read it, too.