Three levels of Nix
When using Nix to orchestrate a new project, I find myself choosing between these three levels:
Just a
shell.nix: This is good for starting on, it’s easy to sell to colleagues or project owners you’re not close with or already bought on the idea of Nix. It leaves a very small footprint in terms of lines of code: Every lines in this file is worth its own weight, since it usually just mentions packages to install. That also makes it very tangible. It could look like:{pkgs ? import <nixpkgs> {}}: pkgs.mkShell { packages = [ pkgs.go pkgs.gopls pkgs.gotools pkgs.go-tools ]; }You may want
pkgs.mkShellNoCCor, if/when you get flaky: numtide/devshellFor some projects you may instead want just a
default.nix: This was the case for Schemesh which is a compiled open-source project run mainly by one person who is not a NixOS user. While shell.nix provides a developer shell with all the necessary tools, open-source project may not necessarily have one set of tools, except for building, of course. Leaving multiple .nix files here felt like littering.Given the right tools, a
default.nixcan be extremely lean:{ pkgs ? import <nixpkgs> {}, ... }: pkgs.haskellPackages.callCabal2nix "evm-opcodes" ./. {}Experienced Nix users may frown at the
? import <nixpkgs> {}which falls back to whatevernixpkgsis available inNIX_PATH, e.g. the system installation. The outcome here is not deterministic. But it comes with two upsides: It works without explicitly pinning which version ofnixpkgsyou’re using inside the given .nix file, it doesn’t require learning about flakes or flake-free version pinning, it probably just works most of the time, andpkgswill get seeded once you import this file into aflake.nixwhich has explicit hash-pinning. Just so you know: It’s a tradeoff, and I think it’s a good one for this level of engagement.A
default.nix, ashell.nixand aflake.nix: At this level of investment you want to enable flakes, but make them entirely optional. Adefault.nixfor building the project and making it easy to package, ashell.nixfor providing the developer shell with all the necessary tools, and aflake.nixfor distributing it.Making flakes optional comes down to making
shell.nixanddefault.nixwork by themselves and referencing them inflake.nixso that it doesn’t become extremely big and overloaded. You do that simply by making them self-contained, e.g. by{ pkgs ? import <nixpkgs> {}, ... }: ...This inherits the
pkgsfrom thenixpkgsdistribution mentioned in the flake, and falls back to whatevernixpkgsis on your system. So adding, removing or disregarding the flake, it works.Here is an example of such a flake:
{ description = "hcloud-upload-image - Quickly upload any raw disk images into your Hetzner Cloud projects"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-parts.url = "github:hercules-ci/flake-parts"; }; outputs = inputs @ {flake-parts, ...}: flake-parts.lib.mkFlake {inherit inputs;} { systems = [ "x86_64-linux" "aarch64-darwin" # "aarch64-linux" # "x86_64-darwin" ]; perSystem = {pkgs, ...}: let pkg = pkgs.callPackage ./default.nix {}; app = { type = "app"; program = "${pkg}/bin/hcloud-upload-image"; }; in { packages.default = pkg; packages.hcloud-upload-image = pkg; apps.default = app; apps.hcloud-upload-image = app; devShells.default = pkgs.callPackage ./shell.nix {}; }; }; }Using
flake-partsis a conscious choice: While a medium engagement with Nix tooling would encourage a low dependency footprint, flake-parts carries its own weight well: It allows for all the flake extensibility you need, and it greatly simplifies the flake itself.On the one hand, flake-parts is the result of an evolution of flake utility libraries, with flake-utils being the most common one. You may like to read Why you don’t need flake-utils for a historic introduction to the design space.
The “apps” of flakes let you
nix run github:you/projectwithout installing.Only enabling flakes for systems you’ve verified / CI’ed adds credibility.
A dendritic flake with
flake-partsmodules.I’ll provide a proper write-up of these concepts.
Until then, you can either see some projects that emphasise this pattern:
- https://github.com/mightyiam/infra – an extensive personal system configuration
- https://github.com/sshine/nixos-ng – a minimal, reusable template for system configuration
- https://github.com/sshine/nix-terranix – a more elaborate project for remote server orchestration
Or you can read about Dendritic Nix here:
Going from optional flakes to all bells and whistles is perhaps a little daunting.
There are competing concepts that provide the same ergonomics and functionality but with different tradeoffs, e.g. [Victor Borja’s Dendritic Nix Ecosystem]. Vic is the author behind
import-treeand provides several appealing alternative paths than flakes. The reason I don’t yet encourage them over flake-parts is simply that I haven’t got around to experimenting with them yet.