Nix and Direnv with Flakes

Last year I wrote about nix and direnv as I explored the potential convenience of an isolated, project-specific environment. There were some interesting initial learnings about nix, but I didn’t really know what I was doing. Now, I still don’t know what I’m doing, but I’ve been doing it for longer. As an example, I’m going to walk through how I set up a flake-driven development environment for this blog with direnv.

My blog is built with Hugo. I also use Python to run content generation and extraction scripts, which require a few Python libraries. I needed to write a flake to use nix to install all these components.

There seems to be a lot of different ways to write flakes. I didn’t know which to pick, so created a flake.nix file and prompted gpt-4 via Cursor to

write a flake that provides a dev shell with hugo and python

It outputted

{
description = "A shell with Hugo and Python";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils, ... }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
};
in
{
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
pythonEnv
hugo
];
};
});
}

Next I prompted it to install the python library arrow and it gave me

{
description = "A shell with Hugo and Python";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils, ... }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
};
pythonEnv = pkgs.python3.withPackages (ps: with ps; [
arrow
]);
in
{
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
pythonEnv
hugo
];
};
});
}

This structure was enough to get where I needed for this project. It sounds like some folks believe the use of flake-utils is an anti-pattern. I also came across flake-parts while looking for ways to solve this problem.

As I iterated, after adding each new piece, I ran nix develop -c $SHELL (thanks Davis for this tip) to validate the flake would build and that the dependency worked within the environment (e.g. I would run python then import pytz to confirm the library had been installed). This is the final product:

{
description = "My Blog built with Hugo";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/release-23.11";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils, ... }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
};
pythonEnv = pkgs.python3.withPackages (ps: with ps; [
arrow
python-frontmatter
pytz
sqlite-utils
]);
in
{
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
pythonEnv
hugo
];
};
});
}

To wire up the auto-activation, I created an .envrc with the following content

use flake

Finally, I ran direnv allow within my blog folder. Now, when I cd into this folder, I’m immediately dropped into an environment containing all the dependencies defined in the flake (direnv also continues to use the same shell so I don’t need to worry about specifying it manually as before). When I cd out, these are all unloaded so they don’t clutter up my system.

Terminal window
$ cd blog
direnv: loading ~/dev/blog/.envrc
direnv: using flake
direnv: nix-direnv: using cached dev shell
direnv: export +AR +AS +CC +CONFIG_SHELL +CXX +HOST_PATH +IN_NIX_SHELL +LD +LD_DYLD_PATH +MACOSX_DEPLOYMENT_TARGET +NIX_BINTOOLS +NIX_BINTOOLS_WRAPPER_TARGET_HOST_aarch64_apple_darwin +NIX_BUILD_CORES +NIX_CC +NIX_CC_WRAPPER_TARGET_HOST_aarch64_apple_darwin +NIX_CFLAGS_COMPILE +NIX_DONT_SET_RPATH +NIX_DONT_SET_RPATH_FOR_BUILD +NIX_ENFORCE_NO_NATIVE +NIX_HARDENING_ENABLE +NIX_IGNORE_LD_THROUGH_GCC +NIX_LDFLAGS +NIX_NO_SELF_RPATH +NIX_STORE +NM +PATH_LOCALE +RANLIB +SIZE +SOURCE_DATE_EPOCH +STRINGS +STRIP +__darwinAllowLocalNetworking +__impureHostDeps +__propagatedImpureHostDeps +__propagatedSandboxProfile +__sandboxProfile +__structuredAttrs +buildInputs +buildPhase +builder +cmakeFlags +configureFlags +depsBuildBuild +depsBuildBuildPropagated +depsBuildTarget +depsBuildTargetPropagated +depsHostHost +depsHostHostPropagated +depsTargetTarget +depsTargetTargetPropagated +doCheck +doInstallCheck +dontAddDisableDepTrack +mesonFlags +name +nativeBuildInputs +out +outputs +patches +phases +preferLocalBuild +propagatedBuildInputs +propagatedNativeBuildInputs +shell +shellHook +stdenv +strictDeps +system ~PATH

It was nice to get this working end to end and get a perspective on what developer experience could look like with nix, but still somewhat unsatisfying to not have a consistent starting point for creating a flake for a project. I plan to continue researching flake-parts, flake-utils and flake templates to get a sense of best practices in this area.