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.

$ 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.