Tim Lester

How I Nix

Did I mention that I really like Nix? It’s a powerful tool for managing development environments and packaging software. Some of the hurdles people who are new to using nix face are scattered, possibly outdated documentation, and wrapping one’s head around the different ways to structure nix code. The latter applies to personal dotfile repos (nixosConfigurations etc.), projects for nix tooling, and any other software project you want to package with nix.

This article will demonstrate how I structure nix code in new repos, and some commentary on why I think my approach is easier to generalize, easier to reason about than some other approaches, and why I’m more confident than ever in using nix to package software.

Preamble: the Nix Ecosystem

The core parts of the Nix ecosystem, from my persective, are the language itself, the nixpkgs package repository, and the NixOS modules defined within that repo. These repos ship more than enough for someone to follow the tutorials at nix.dev, where you might define a development shell using a hand-pinned copy of nixpkgs in order to achieve reproducibility.

Outside of these core parts, there are tons of projects which are either packaged with nix, or otherwise provide nix code like NixOS modules or helper functions. The simplest way to interact with any code out there is to fetch it, pin it, and then use it similarly to how one may use nixpkgs.

Enter: Flakes

I am not going to spend too much time on flakes. They provide a schema for managing inputs and outputs in a git repo with nix code. Generally, I find them to be inflexible and that they obscure some of the important machinery (namely, the transforms that happen to the inputs). Now, this isn’t to say I don’t use projects that define flakes to manage their nix code–I do, and a “third-party” tool is how I do it.

“Third-party” Tools

In language ecosystems like Golang and Rust, it is common to target some git repository out there as an input to your project and no one thinks anything of it. So I use the term “third-party” here mostly to distinguish tooling from that supported upstream, i.e., anything you can’t do with just a nix implementation.

Most of the tools that come to mind for me in this space have a high quality bar, and are usually “upstreamed” to nixpkgs within a few weeks of introduction to the community, at least in my last year of being around the nix community.

How I Nix

This is the basic structure of the toplevel of any repository I plan on using nix in:

.tack/          # tack-managed inputs
default.nix     # entrypoint; i.e., `nix repl -f ./.`
inputs.nix      # inputs transforms or pass-through
outputs.nix     # output transforms
shell.nix       # development shell definition

and invariably the contents of default.nix are import ./outputs.nix (import ./inputs.nix).

I think it should be fairly obvious if you’ve used flakes, or another pinning tool like npins, that the heavy lifting on the inputs is being done by tack. This is a relatively new tool by a reputable group of nix folks who like making fast, thorough tools for nix. “All” it does is provide a nix helper function to lazily fetch inputs based on a toml file enumerating the inputs. This makes for a very uninteresting, and perhaps seemingly bloated setup for this website, so I will show some examples from my NixOS config/dotfiles to solidify the concepts.

My NixOS config/dotfiles

Any code I share in this section is from this revision of my NixOS config.

You can bootstrap tack against a recent nixpkgs-unstable revision with a command like:

TACK_DIR="./.tack" nix run nixpkgs#tack -- init

…after which you will likely want to initialize and activate a dev shell in the repo with tack.

Here’s my .tack/pins.toml:

[all_follow]
nixpkgs = "nixpkgs"

[inputs]
[inputs.nixpkgs]
url = "github:NixOS/nixpkgs/nixpkgs-unstable"

[inputs.hjem]
url = "github:feel-co/hjem"

[inputs.nix-darwin]
url = "github:nix-darwin/nix-darwin"

[inputs.claude]
url = "github:sadjow/claude-code-nix"

[inputs.ncro]
url = "github:manic-systems/ncro"

You can either manually edit this file, or use tack commands like tack add to configure your inputs.

The all_follow section simply forces all pins to use the singular pinned copy of nixpkgs as their nixpkgs input, if they have one. This is usually correct if none of your inputs are specifically meant to build against a particular nixpkgs revision or otherwise are cached against a particular revision.

Next we need to consume the inputs, which is as simple as importing the tack directory in nix. Here’s my inputs.nix:

let
  inputs = import ./.tack;
  inherit (inputs)
    nixpkgs
    hjem
    claude
    ncro
    nix-darwin
    ;
  mkNixosSystem =
    {
      system,
      modules,
      packages,
    }:
    nixpkgs.lib.nixosSystem {
      inherit system modules;
      specialArgs = {
        inherit nixpkgs;
        pkgs' = packages.${system};
      };
    };
  mkDarwinSystem =
    {
      system,
      modules,
      packages,
    }:
    nix-darwin.lib.darwinSystem {
      inherit system modules;
      specialArgs = {
        inherit nixpkgs;
        pkgs' = packages.${system};
      };
    };
in
{
  inherit
    nixpkgs
    hjem
    claude
    ncro
    mkNixosSystem
    mkDarwinSystem
    ;
}

Arguably the two mk functions could go elsewhere, but these are simple transforms on *.lib.{darwin,nixos}System which one may reasonably use to define multiple systems (not me though). As you can see, tack simply provides an attr set of the inputs. That’s super simple, and transparent to me as to how it fetched them. No need to maintain a separate fetcher FOD for each input, or treat inputs specially if they are a flake or a non-flake.

Since default.nix simply imports the outputs with the inputs as inputs, these attrs get passed on and I then define each system, which I won’t show here. Realistically, though, you can put any attr set you want for your outputs. You don’t need to think about the flake schema of outputs.{nixosConfigurations,packages,shells}, etc.

shell.nix similarly consumes our inputs. To simplify things, I usually make it consume inputs.nix rather than import ./.tack.

let
  inputs = import ./inputs.nix;
  system = builtins.currentSystem;
  pkgs = inputs.pkgs.${system};
in
pkgs.mkShell {
  name = "nixos-config";
  TACK_DIR = "./.tack";
  packages = [
    pkgs.just
    pkgs.tack
    pkgs.treefmt
    pkgs.nixfmt
    pkgs.taplo
    (pkgs.callPackage ./packages/jqfmt.nix { })
    pkgs.deadnix
    pkgs.statix
    pkgs.nixf-diagnose
  ];
}

Note the impure builtins.currentSystem. This is a devshell, so you could take the flake approach of defining the shells over a generator of system architectures, but I choose not to as it is essentially pointless and makes shell.nix unusable. I use direnv with use nix directives so having a working shell.nix is important.

Just put a derivation in outputs.nix

Ok, my NixOS config example is helpful for understanding how this stuff works for a NixOS config. Obviously if I have an attr like nixosConfigurations.valhalla, I can use nixos-rebuild or nh to target that attr and transform my system into it. But what about packages?

Just put a derivation in outputs.nix. Seriously. Anywhere in the output works, but in the case of the derivation which builds this website in GitHub Actions, I put it in package.default.

{ pkgs, ... }: {
  package = {
    default = pkgs.stdenvNoCC.mkDerivation {
      name = "tfrancisl.github.io";
      # Include .git/ so Hugo can populate per-page .GitInfo in the sandbox.
      # Nix copies this to the build dir (owned by the build user), so git works.
      src = builtins.path {
        path = ./.;
        name = "tfrancisl.github.io-src";
        filter =
          path: _type:
          let
            base = baseNameOf path;
          in
          base != "public" && base != "result" && base != ".direnv";
      };
      nativeBuildInputs = [
        pkgs.hugo
        pkgs.git
        pkgs.typst # for rendered documents
      ];
      buildPhase = ''
        typst compile documents/resume.typ content/resume.pdf
        hugo build --gc --minify
      '';
      installPhase = "cp -r public $out";
    };
  };
}

How do you work with this? It’s pretty simple; if I wanted, for some reason, to include a build of my website in my system closure, I would simply add the repo as a tack input, import it, and then add website.package.default to environment.systemPackages or the like. Or, if I want to look at the current build, do as I do in CI: nix build -f . package. The disadvantage with this particular output is that I have fixed the pkg instance to x86_64-linux so I can’t use this on my mac. Oh well: if you need to define over multiple architectures, use the same schema flakes do of packages.${system}.${pkg-name}.

Conclusion

I think the takeaway I want to leave people with is that you should tinker, and you should try to understand how things really work. I know the examples I’ve shared here are pretty simple for someone who has been using nix for a long time, but for my first several months I felt trapped by flakes and unable to understand how to make things work without them. I didn’t need tack to come along to set me free (in fact I was using npins for a few weeks while tack was in development), but I was certainly inspired by how easy it is to set up nix projects without flakes.

I will likely continue to evolve the “schema” I use for the toplevel nix files in a project, especially as I try to take on more serious projects in my free time. I think there’s potential to distribute cache information and possibly alternative builds of software in this way, such that upstreaming to nixpkgs or relying on flakes are not necessary for some tasks.