Getting Started Using Nix Flakes As An Elixir Development Environment

Never is a project started from 'just' the init. You have to take care of packages you use, CI tools for builds you make, database hookups, development tooling, and countless other parts. All of this takes time. With nix flakes, you may be able to start with all the main components you need immediately. Giving way to actually developing that app you been itching to build, without the days/weeks adventure getting everything you need just right.

Now it doesn't mean that immediately reading this starter guide, you will have everything under the sun set up with Nix Flakes for your development need. But at least, you won't have to worry about setting up asdf, your weird hacks you need for your machine and the other tiny little things to get elixir started with elixir-ls.

Background

A little background. Nix, for the uninitiated, is a purely functional language for package management. What makes Nix interesting is you can use the purely functional aspect to build out artifacts which are entirely idempotent. Meaning, no matter how many times you run the nix expressions you have, the end result will always be the same regardless of external state of a machine. Its build structure as a package manager has evolved the language to build out guaranteed result for all sorts of software. Yet, Nix itself isn't exactly easy to learn. Due in part to the ambitions of the project and difficulties, which arose from those ambitions, complexities crept in. Nix Flakes is an answer to some of these complexities and more.

With Nix Flakes, you have the ability to have a very well defined package for a project. Written and using Nix in a way that takes all the learning from its years. Making it easier to define what you want from nix; the build result for a package.

Getting flakes enabled

So how do you get started with Nix Flakes? The first part that you should probably have is nix already installed and some familiarity with the nix language. Run through the guide to your platform needs.

Next is the settings to use nix with flakes. Since flakes is still in development (but relatively stable), you do need to enable the feature on nix. You can do so by enabling the experimental features of both nix-command and flakes through the nix.conf file.

# make nix config path if not existent
mkdir ~/.config/nix/ -p
# add the settings to the file on config path
echo "experimental-features = nix-command flakes" | tee ~/.config/nix/nix.conf -a

Once the settings are applied, you should be able to validate by running show-config

nix show-config | grep experimental

If all is successful, you should see an output for the same experimental features you wrote on the nix.conf file.

The flake.nix file

So now to get to use nix flake comes the heart of the project, the flake.nix file. The file itself needs three defined keys on the set, description, inputs, and outputs. Each one of them plays a different role in how to define your package you want to build.

description key

The description key is a one liner description of what the flake project is. Helps in giving what the flake is for quick review after you have a few hundred of these built out...

# flake.nix, ignoring input and output
{
  description = "A description of some kind";
}

inputs key

The inputs is how you can import external sources of other flakes into the flake project you have. In other words, any project you may need or tools required to get started, this is where you will define their source. Example below is using the standard nixpkgs and a tool called flake-utils, which provides a set of functions to make flake nix packages simpler to set up without external dependencies.

# flake.nix, ignoring description and output
{
  inputs = {
    # using unstable branch for the latest packages of nixpkgs
    nixpkgs = { url = "github:NixOS/nixpkgs/nixpkgs-unstable"; }; 
    flake-utils = { url = "github:numtide/flake-utils"; };
  };
}

outputs key

The outputs is where the bulk of your logic for what you will build with flakes. It has quite a numerous of options, but in this use case, we only care of the devShell key, which is what will be used to populate the development environment. While the following may all look like a lot for the output, it's also everything that would be needed for either building out a nix flake package or use for a development environment:

# flake.nix, ignoreing description and input
{
  outputs = { self, nixpkgs, flake-utils }:
   flake-utils.lib.eachDefaultSystem (system:
      let
        pkgs = import nixpkgs { inherit system; };

        elixir = pkgs.beam.packages.erlang.elixir;
        elixir-ls = pkgs.beam.packages.erlang.elixir_ls;
        locales = pkgs.glibcLocales;
      in
      {
        devShell = pkgs.mkShell {
          buildInputs = [
            elixir
            locales
          ]
        }
      });
}

The output key is actually a function which takes the inputs defined on inputs. Hence the set { self, nixpkgs, flake-utils }. All the inputs on the function were defined on the input with self being the flake.nix file itself. The next portion is using a simple but powerful flake-utils function called eachDefaultSystem. What the function provides is actually build the development environment for all available platforms currently available for nix as a default. You can see the list by running nix flake show (after the file is fully written) and you will be provided an output like the following:

└───devShell
    ├───aarch64-darwin: development environment 'nix-shell'
    ├───aarch64-linux: development environment 'nix-shell'
    ├───i686-linux: development environment 'nix-shell'
    ├───x86_64-darwin: development environment 'nix-shell'
    └───x86_64-linux: development environment 'nix-shell'

Meaning, you do not have to worry about what OS you using, as long as it's linux or macos. You write your nix flake with the ability to use it with all the supported platforms from the start for your environment. In other words, Write once, run on all the machines. No more of that 'it runs on my machine' debacle.

The last section is a let .. in pair to both declare what you will use in the system you are building and devShell itself. The packages use here are simply elixir, elixir-ls for sanity, and locales to make sure elixir is able to use the proper locale settings of the shell environment produced by the nix flake.

Finally, the devShell in this case is the buildInputs wanted for the shell environment and nothing more:

      {
        devShell = pkgs.mkShell {
          buildInputs = [
            elixir
            locales
          ]
      }

Notice while elixir-ls package isn't directly declared in the mkShell buildInput, it is part of the output on let. Allowing to still have linked access to its packages.

The full nix flake development environment

Finally, putting it all together, getting a nix flake project started for your development environment, all falls down to what packages you need on the devShell. With the flake.nix file on the root of your project, you have the ability to have the packages you need and ready for you to work with.

{
    description = "Development environment";

  inputs = {
      nixpkgs = { url = "github:NixOS/nixpkgs/nixpkgs-unstable"; };
    flake-utils = { url = "github:numtide/flake-utils"; };
  };

  outputs = { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        inherit (nixpkgs.lib) optional;
        pkgs = import nixpkgs { inherit system; };

        elixir = pkgs.beam.packages.erlang.elixir;
        elixir-ls = pkgs.beam.packages.erlang.elixir_ls;
        locales = pkgs.glibcLocales;
      in
      {
          devShell = pkgs.mkShell
          {
              buildInputs = [
                elixir
              locales
            ];
          };
      }
    );
}

#elixir #development #nix #flakes

Bonus: Limiting by platform of choice your nix flake

Glad you made it this far. Well friend, besides the default system listing on flake-utils, there is another route you can go in setting up you development environment. You may have noticed that the run of nix flake show showed multiple platforms and architectures available by default. However, it may be you don't need to use all those platforms and architecture. You may like to target only a set of architectures or platforms you need and that's it. Speeding up your build process and saving on some storage if you don't want those extra platforms.

flake-utils has the option with the flake-utils.lib.eachSystem function. The function itself takes an array of systems you want the flake to build out. To use the function, you have to use a let .. in expression to define the array and then it's use. For my use, I only target aarch64-linux and x86_64-linux since those are the only platforms I work with. So I define them on an array.

supportedSystems = [ "x86_64-linux" "aarch64-linux" ];

Then I use that defined array to apply to the the function flake-utils.lib.eachSystem

flake-utils.lib.eachSystem supportedSystems (system: 
  # same as blocks before with `flake-utils.lib.eachDefaultSystem`
)

With all of it put together below, you can see how to use with let .. in expression:

{
  description = "Development environment";

  inputs = {
    nixpkgs = { url = "github:NixOS/nixpkgs/nixpkgs-unstable"; };
    flake-utils = { url = "github:numtide/flake-utils"; };
  };

  outputs = { self, nixpkgs, flake-utils }:
    let supportedSystems = [ "x86_64-linux" "aarch64-linux" ];
    in
    flake-utils.lib.eachSystem supportedSystems (system:
      let
        inherit (nixpkgs.lib) optional;
        pkgs = import nixpkgs { inherit system; };

        elixir = pkgs.beam.packages.erlang.elixir;
        elixir-ls = pkgs.beam.packages.erlang.elixir_ls;
        locales = pkgs.glibcLocales;
      in
      {
        devShell = pkgs.mkShell
          {
            buildInputs = [
              elixir
              locales
            ];
          };
      }
    );
}

Now when you run nix flake show the output should be only the platforms you defined:

    ├───aarch64-linux: development environment 'nix-shell'
    └───x86_64-linux: development environment 'nix-shell'