My usage of Nix, and Lorri + Direnv
I recently wrote about me transitioning my main working environment to run on NixOS. I've used NixOS on my main workstation and Nix atop Ubuntu on WSL2 on my laptop for ~5 months now and I feel I've really gotten into the flow with it on a handful of client projects.
The coolest bit of the setup is Nix/NixOS agnostic: lorri
and direnv
. At face value, they're usecase is pretty basic and we will dive into how you're meant to use them on projects, but using them actually enables some pretty cool functionality I've been trying to implement for awhile now.
What is Lorri and Direnv?
Nix provides a really cool utility built in called nix-shell
. Basically, invoking nix-shell
starts a new interactive shell after evaluating a given Nix expression (which by default is assumed to be ./shell.nix
, relative to the CWD
). This means that you can write a Nix expression detailing what packages you want to install, what environment variables to set, and automatically set/build/enable them within the context of the interactive shell.
This is already extremely powerful as you can declarative manage software versions etc, but Lorri + Direnv make this even nicer to use!
direnv is a super neat project which provides a shell hook that allows your shell to automatically set environment variables defined within a .envrc
file (again, relative the the CWD
) whenever you change directories. A basic .envrc
is very simple and can look like the following:
export SOME_ENV_VARIABLE="test"
A major win here is that it's super fast, supports most popular shells (including some more esoteric ones), is language agnostic, and is pretty extensible.
lorri is essentially an extension of nix-shell
built atop direnv. As soon as you change directory, if the new CWD
has a shell.nix
and an appropriate .envrc
(autogenerated by running lorri init
), the shell.nix
is immediately evaluated. Lorri improves upon nix-shell
in a bunch of ways as well, w.r.t caching of built derivations so it is also super quick.
General usage
Once you have lorri
and direnv
set up, the most basic use case in my opinion is replacing something like asdf which does much of the same stuff:
- it enables you to document what packages are needed for a certain project (within a certain limited, but extensible list)
- it enables you to specify certain versions of said packages
- it will automatically build said packages by running
asdf install
- those packages will only be available if you're in a directory (or a child directory) with a
.tool-versions
file
Since lorri
is built with Nix however, we get the following instead:
- it enables you to document what packages are needed for a certain project (any package available for Nix, which is huge)
- it enables you to specify certain versions of said packages, override versions, build certain custom versions
- it will automatically build said packages by entering the directory if needed
- those packages will only be available if you're in a directory (or a child directory) with a
shell.nix
file - you can do anything Nix enables you to do, including file manipulation and describing environment variables
For example, here is an example shell.nix
I needed to bootstrap an Elixir project which set some private credentials and needed Docker, GCS utilities, a DB client, and Minikube installed to run:
let
pkgs = import <nixpkgs> {};
in
g_sec_user = <REDACTED>;
g_sec_password = <REDACTED>;
pkgs.mkShell {
buildInputs = [
pkgs.elixir_1_10
pkgs.nodejs-10_x
pkgs.yarn
pkgs.inotify-tools
pkgs.openssl
pkgs.kubectl
pkgs.jq
pkgs.google-cloud-sdk
pkgs.minikube
pkgs.kubernetes-helm
];
}
Once this file is created, everything automagically works when entering that directory. Changes to the shell.nix
are also automatically tracked so you don't need to do anything fancy: your shell.nix
is your single source of truth.
Another advantage is that once your project has a shell.nix
checked into it's repository, anyone running Nix/NixOS can start hacking away by just running shell.nix
, but if you they Direnv + Lorri, they just have to enter the directory and stuff just works
Usage for Nix unfriendly projects
I'm an Erlang/Elixir consultant, and as a result of this, I have the opportunity to work on a lot of projects for a lot of different clients. I also know that a relatively esoteric package manager (or worse, an entirely different Linux distribution) is a hard sell.
Thankfully though, even in environments where you can't get away with commiting any of the lorri/direnv artefacts into the core repository of a project, you can get away with using lorri + direnv!
Because this entire workflow is dependent on directory structure, my workstation typically has a structure like this:
/home/chris/git
├── esl
│ ├── shell.nix
│ ├── internal_project_1
│ └── internal_project_2
│ └── shell.nix
├── client_1
│ ├── shell.nix
│ ├── client_project_1
│ ├── client_project_2
│ ├── client_project_3
│ └── client_project_4
├── client_2
│ ├── shell.nix
│ └── client_project_1
└── vereis
├── shell.nix
├── blog
│ └── shell.nix
├── build_you_a_telemetry
│ └── shell.nix
├── horde
├── httpoison
└── nixos
If I'm not allowed to add direnv/lorri artefacts, I can simply put them a directory above the repository and I still get all of the benefits source_up
to any child .envrc
files in order to also execute their parent but otherwise it works as expected.
This means that for I can install packages / handle dependencies on a project by project basis, or a client by client bases. Because the effects of executing these Nix expressions is cumulative, you can even do it on a client by client basis, except for a given project for that client.
As Lorri/Direnv lets me install dependencies and set environment variables, this setup is also perfect for a seamless dev credential manager solution. For example, assuming a client has it's own npm registry for JavaScript dependencies, you can simple do the following:
let
pkgs = import <nixpkgs> {};
in
pkgs.mkShell {
buildInputs = [
pkgs.nodejs-10_x
];
npm_config_user_config = toString ./. + "/.npmrc";
}
Where /home/chris/client_1/.npmrc
contains:
registry=<REDACTED>
email=<REDACTED>
init.author.name="Chris Bailey"
init.author.email=<REDACTED>
always-auth=true
_auth=<REDACTED>
Notice that esl
, client_1
, client_2
, and vereis
all have a top level shell.nix
. This is also perhaps a slight abuse of direnv/lorri but the end effect is that I'm able to treat these different directories as completely separate development domains. Each of these top level shell.nix
files also set environment variables to set my git username and email. This means that I no longer need to faff around with setting up each and every git repo I clone: if I'm in /home/chris/git/vereis
, I'm going to be working as @vereis, if I'm in /home/chris/git/esl/
I'll be @cbaileyesl etc.
Conclusion
I hope this small write-up has been helpful nix-shell
, lorri
, and direnv
are super helpful in streamlining the development process.
Of course, tools like asdf
, dotenv
and others exist which allow you to emulate all of what I've described, but the true advantage of Nix/NixOS is that the configuration is very declarative and built into the core experience. Any nix user can do exactly what I've described, even if they're not running lorri
or direnv
simply by virtue that it's all built upon the first-class nix-shell
.
I hope if you're not a nix user, this has piqued your interests a little bit, and if you are a nix user, this will help you jump a few of the small roadbumps when trying to approach development leveraging all the niceties nix provides you.