Perfect Elixir: Environment Setup
Tags: elixir, tutorial, development, webdev
Published: 2024-03-18 13:05:25

We need **Erlang** and **Elixir** installed, which might sound simple, but there are trade-offs to consider for a shared team environment. We'll also add a **PostgreSQL** database to keep our explorations relevant to real-world scenarios.

Let's explore different approaches and discuss their pros and cons.

**Table of Contents**
* [Just… Install the Dependencies?](#just-install-the-dependencies)
* [asdf](#asdf)
* [Nix](#nix)
* [pkgx](#pkgx)
* [mise](#mise)
* [In Conclusion](#in-conclusion)
  * [asdf Thoughts](#asdf-thoughts)
  * [Nix Thoughts](#nix-thoughts)
  * [pkgx Thoughts](#pkgx-thoughts)
  * [mise Thoughts](#mise-thoughts)

## Just… Install the Dependencies?
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wckqpbq3ipaoyjh5h2ar.png)

Why not just install the dependencies as suggested by each tool's website? 

I’m on macOS and [erlang.org](https://erlang.org), [elixir-lang.org](https://elixir-lang.org), and [postgresql.org](https://postgresql.org) all recommend installation via [Homebrew](https://brew.sh).

First, let's install Homebrew:
```sh
$ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
```

> {% details 🖥️ Terminal %}
> <!-- 🎬 just_install_homebrew -->
> ![Install Homebrew](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/lb19xkyhr9zp54f6w75v.gif)
> {% enddetails %}

Then install our tools:
```sh
$ brew install erlang elixir postgresql@15
```
> {% details 🖥️ Terminal %}
> <!-- 🎬 just_brew_install_erlang_elixir_postgresql -->
> ![Brew install erlang, elixir, and postgresql](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/flmb3rzn57ofl1w74bqc.gif)
> {% enddetails %}

And… we’re done!?

But there are critical issues with this approach:

* **No versioning** - There's no control over what versions get installed, leading to an unpredictable mix of versions.
* **Globally installed** - The tools are globally installed, affecting all other projects.

So “just installing” isn’t viable. We need to ensure the exact same versions are installed on all our machines. Let's explore some tools designed for this.

---

## asdf
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/b9ynjn9gz71i3er12st3.png)

[asdf is a version manager](https://asdf-vm.com) with plugins for [Erlang](https://github.com/asdf-vm/asdf-erlang), [Elixir](https://github.com/asdf-vm/asdf-elixir), and [Postgres](https://github.com/smashedtoatoms/asdf-postgres). The installation guide suggests installing some system dependencies via Homebrew and then cloning the asdf repository:

```sh
$ 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
```
> {% details 🖥️ Terminal %}
> <!-- 🎬 asdf_brew_install_coreutils_curl_git -->
> ![Brew install coreutils, curl, and git](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/risa5e72umf5msntpcfg.gif)
> {% enddetails %}

> ℹ️ _BTW I find it odd to install asdf via `git clone`. Although it can be installed via Homebrew, the asdf guide recommends using Git._

Next, add plugins:

```sh
$ 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
```
> {% details 🖥️ Terminal %}
> <!-- 🎬 asdf_plugin_add -->
> ![Add plugins to asdf](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/v7svqp05bn3rocshccdl.gif)
> {% enddetails %}

Now install additional dependencies:

```sh
$ 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
```
> {% details 🖥️ Terminal %}
> <!-- 🎬 asdf_brew_install_autoconf_openssl_libxslt_fop_gcc_readline_and_set_KERL_CONFIGURE_OPTIONS -->
> ![Add plugin dependencies](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6j8l02c8aukzntmq5lcg.gif)
> {% enddetails %}

Create a `.tool-versions` file to specify the tools and versions:

```sh
$ cat << EOF >> .tool-versions
erlang 26.2.1
elixir 1.16.0
postgres 15.5
EOF
```

Install the specified versions:

```sh
$ asdf install
…
$ which erl
/Users/cloud/.asdf/shims/erl
$ which elixir
/Users/cloud/.asdf/shims/elixir
$ which psql
/Users/cloud/.asdf/shims/psql
```
> {% details 🖥️ Terminal %}
> <!-- 🎬 asdf_install -->
> ![Run asdf install](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/y03wy5vjydiflf0147f9.gif)
> {% enddetails %}

We now have all our tools installed 🎉

### `direnv`
To keep our environment in sync, we can use [direnv](https://direnv.net) to automate updates when `.tool-versions` changes.

First, install and configure direnv:

```sh 
$ 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
```
> {% details 🖥️ Terminal %}
> <!-- 🎬 asdf_direnv -->
> ![Add asdf direnv](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/jntubso4wem25kyrd5n1.gif)
> {% enddetails %}

Simulate a change to `.tools-versions` by updating the Erlang version, and see how direnv prompts to re-install dependencies:

```sh 
$ 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
```
> {% details 🖥️ Terminal %}
> <!-- 🎬 asdf_direnv_on_entering_folder -->
> ![Show how asdf direnv automatically checks for dependencies on entering the folder](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/p6mrtnhxq2wyluy5n2m7.gif)
> {% enddetails %}

We now have a workflow where asdf helps keep our environments in sync 🎉

---

## Nix
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/eondla6o7l8p1r1sz5n5.png)

Nix is a tool ["for reproducible and declarative configuration management"](https://nixos.org), available for macOS and Linux. Let’s give it a try!

First, install Nix:

```sh
$ sh <(curl -L https://nixos.org/nix/install)
```
> {% details 🖥️ Terminal %}
> <!-- 🎬 nix_install -->
> ![Installing nix](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3apwbxd05h08n9uwk5w9.gif)
> {% enddetails %}

> ℹ️ _BTW The installer requires `sudo`, creates a new “Nix Store” drive-volume, and 32 hidden new users._

Nix uses a custom pseudo programming language for specifying dependencies. Let's enable experimental features and create a `flake.nix` file, as suggested by the `nix` CLI:

Enable experimental features:

```sh
$ mkdir -p ~/.config/nix && echo "experimental-features = nix-command flakes" > ~/.config/nix/nix.conf
```

Generate a flake file:

```sh
$ 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;
  };
}
```

Edit the file to specify our dependencies:

```sh
$ 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
        ];
      };
    };
  };
}
```

Activate the flake by running `nix develop`:

```sh
$ nix develop
...
$ which erl
/nix/store/49qw7cw30wszrfn3sa23qnlskyvbnbhi-erlang-26.2.2/bin/erl
$ which elixir
/nix/store/rr6immch9mp8dphv1jvgxym35za4b7jy-elixir-1.16.1/bin/elixir
$ which psql
/nix/store/v5ym92k3kss1af7n1788653vis1d6qsc-postgresql-15.5/bin/psql
```
> {% details 🖥️ Terminal %}
> <!-- 🎬 nix_build_run_develop -->
> ![Run nix develop](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mt6sntwpwwbo8wzsjj93.gif)
> {% enddetails %}

If we exit the shell, the tools are no longer available:

```sh
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
```
> {% details 🖥️ Terminal %}
> <!-- 🎬 nix_exit_no_tools -->
> ![Exiting the Nix shell makes tools unavailable again](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/w5zbkhcaafayjr5f52n6.gif)
> {% enddetails %}

We now have a reproducible specification of our environment that is consistent across any computer. Nice!

### `direnv`
Starting a new shell each time is cumbersome. Let's automate it with `direnv`.

First, install `direnv` via Nix:

```sh
$ nix-env -iA nixpkgs.direnv;
$ echo 'eval "$(direnv hook zsh)"' >> ~/.zshrc
$ source ~/.zshrc
```
> {% details 🖥️ Terminal %}
> <!-- 🎬 nix_install_direnv -->
> ![Install direnv via Nix](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fe5f85jb5srtzsde5wnl.gif)
> {% enddetails %}

Activate the flake with `direnv`:

```sh
$ 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
```
> {% details 🖥️ Terminal %}
> <!-- 🎬 nix_activate_via_direnv -->
> ![Activate Nix Flake via Direnv](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bn4z3j2xjrhdxtm3yoei.gif)
{% enddetails %}

Now, our environment will automatically activate when we enter the folder, ensuring everyone has the same environment. Awesome! 🎉

> ℹ️ _BTW `direnv` requires running `direnv allow` whenever the `.envrc` file changes to prevent malicious code._

---

## pkgx
![](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/2fzig8dwbgjaee8ko6ov.png)
[pkgx has the tagline "RUN ANYTHING"](https://pkgx.sh), which sounds promising. Let's try it out.

First, install and activate pkgx:

```sh
$ brew install pkgxdev/made/pkgx
$ eval "$(pkgx integrate)"
```
> {% details 🖥️ Terminal %}
> <!-- 🎬 pkgx_install -->
> ![Install pkgx via Homebrew, then activate pkgx](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rfinm8iywt6ia0dnlr96.gif)
> {% enddetails %}

Create a [`.pkgx.yml`](https://docs.pkgx.sh/using-dev/dev#pkgx.yaml) file to specify dependencies:

```sh
$ cat .pkgx.yml
dependencies:
  erlang.org: =26.2.1
  elixir-lang.org: =1.16.0
  postgresql.org: =15.2.0
```

Activate the dependencies:

```sh 
$ 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
```
> {% details 🖥️ Terminal %}
> <!-- 🎬 pkgs_dev -->
> ![Running dev command](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/qd56exnp9zd7j2sejbeo.gif)
> {% enddetails %}

And… that’s it?! The tools are automatically made available when inside the folder, and disappear when not:

```sh
$ 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
```
> {% details 🖥️ Terminal %}
> <!-- 🎬 pkgx_leave_folder -->
> ![Leaving folder disables pkgx-provided tools](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/age904utsihb2jpaa98i.gif)
> {% enddetails %}

---

## mise
![](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/uz2j1ub8mwmh63mg93ce.png) **[Mise](https://mise.jdx.dev)** is a new replacement for asdf, leveraging asdf plugins but simplifying the process. Let's check it out.

Install Mise via Homebrew:

```sh
$ brew install mise
```

Activate it for your shell (assuming zsh):

```sh
$ echo 'eval "$(mise activate zsh)"' >> "${ZDOTDIR-$HOME}/.zshrc"
$ source ~/.zshrc
```

Create a `.mise.toml` file to specify dependencies:

```sh
$ cat .mise.toml
[tools]
erlang = '26.2.1'
elixir = '1.16.0'
postgres = '15.5'
```

Install the dependencies:

```sh
$ mise install
mise ⚠️ postgres is a community-developed plugin – https://github.com/smashedtoatoms/asdf-postgres
Would you like to install postgres? Yes
…
mise elixir@1.16.0 ✓ installed  
```
> {% details 🖥️ Terminal %}
> <!-- 🎬 mise_install -->
> ![Running mise install command](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cj2nekuwxm5bpuv0zyx3.gif)
> {% enddetails %}

And now everything is available:

```sh
$ which erl
/Users/cloud/.local/share/mise/installs/erlang/26.2.1/bin/erl

$ which elixir
/Users/cloud/.local/share/mise/installs/elixir/1.16.0/bin/elixir

$ which psql
/Users/cloud/.local/share/mise/installs/postgres/15.5/bin/psql
```

And those tools are only active whilst inside the folder:

```sh
$ cd ..
$ which erl
erl not found

$ cd perfect-elixir
$ which erl
/Users/cloud/.local/share/mise/installs/erlang/26.2.1/bin/erl
```

---

## In Conclusion
### asdf Thoughts
![](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/p2a0jlhuwl4laoh8r3x5.png) I think asdf is a popular choice, but I found it quite cumbersome and old-fashioned. The installation and configuration, especially for plugins, was awkward. I would not recommend.

---

### Nix Thoughts
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/regzxv0ao4vtmdr67z44.png) Nix is clearly powerful, but also seems very complex. It certainly offers unmatched control over dependencies, but it also requires a significant learning curve and felt completely overwhelming for our needs of getting some system tools installed. It's probably a great tool if your needs are very sophisticated (e.g. specifying dependencies for an entire operating system).

---

### pkgx Thoughts
![](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/l5jmha2g3wvm568zav5c.png) pkgx is **impressively simple** and efficient: Easy to install, easy to configure, and easy to use. Right from the start it doesn't require `sudo`, and then the installer figures out how to integrate with your shell, and then the `dev` command is enormously convenient in how it "just works".

Despite being a new tool the pkgx registry already includes all kinds of system tools, including core tools such as `bash` and `grep` which is very important for when we start writing scripts where we want to rely on the whole team running with the same versions (to avoid script errors due to system tool differences).


---

### mise Thoughts
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ug2zc70yoszmue8l9fvt.png) Mise dramatically simplifies the asdf experience and includes some great features. However, it limits itself to not support system tools such as `bash`, `grep`, `jq`, etc. Not having those aligned is a very common source of script errors (because e.g. MacOS `grep` is quite different from GNU `grep`). mise-based projects should probably consider also providing a list of Homebrew dependencies that developers should install (although Homebrew dependencies are globally installed and so can conflict w. other projects, so beware).

---

Ultimately, the best tool depends on your specific needs and preferences. For me, **pkgx** stands out as the most user-friendly and comprehensive option, so I'll be using that going forward.