Nix: structuring Flakes with Blueprint

Table of Contents
The blueprint logo

I’m sitting here, in an Airbnb in Brussels, after a long weekend of talking tech and drinking beer during my first FOSSDEM. Seems like as good a time as any then to try and restart my regular posting schedule 😂.

That being said, given that my physical, mental and spiritual battery is running low 😫, I’m going to keep this one simple and to the point. Much like the topic of today’s blog post 😉.

Blueprint

From the mind behind flake-utils, blueprint is yet another way to organize your flake, with a heavy emphasis on convention and keeping things simple.

It isn’t intended to compete with the flexibility of solutions like flake-parts.

Instead, it sets out to restrict how you should do things, with a small set of conventions and minimal plumbing, to keep it easy to understand and reason about.

There’s no module system here, just a small library of vanilla Nix.

Getting started

At its core, everything is based around a relatively flat directory structure:

  • checks/ for flake checks.
  • devshells/ for devshells.
  • hosts/ for machine configurations.
  • hosts/*/users/ for Home Manager configurations.
  • lib/ for Nix functions.
  • modules/ for NixOS and other modules.
  • packages/ for packages.
  • templates/ for flake templates.
  • devshell.nix for the default devshell
  • formatter.nix for the default formatter
  • package.nix for the default package

We initialise blueprint as follows:

{
  inputs = {
    blueprint.url = "github:numtide/blueprint";
  };

  outputs = inputs:
    inputs.blueprint {
      inherit inputs;
    };
}

Your flake will be configured with a standard list of systems, but you can override that:

{
  outputs = inputs:
    inputs.blueprint {
        inherit inputs;
        systems = ["x86_64-linux" "aarch64-linux"];
    };
}

In addition, I tend to put everything in a nix/ folder, which can be done by specifying the prefix attribute:

{
  outputs = inputs:
    inputs.blueprint {
        inherit inputs;
        prefix = "nix/";
        systems = ["x86_64-linux" "aarch64-linux"];
    };
}

Configuring a formatter

If you create a file called formatter.nix, blueprint will wire up the package it defines to nix fmt.

I most humbly suggest using treefmt 🙏, but you aren’t restricted in what package you can use.

{
  pkgs,
  inputs,
  ...
}:
inputs.treefmt-nix.lib.mkWrapper pkgs {
  projectRootFile = "flake.nix";
  ####################
  # elided for brevity
  ####################
}

As you can see in the example above, blueprint is passing an instance of nixpkgs and the flake’s inputs in the function argument. There are a few other attributes available, but we’ll cover those in the next section.

Defining a package

All packages are defined inside the packages/ directory.

They can be defined inside a single file, packages/foo.nix, or a directory foo with a default.nix e.g. packages/foo/default.nix.

{
    pname,      # name of the file without the `.nix` suffix, or the name of the directory
    flake,      # maps to inputs.self
    inputs,     # maps to the flake's inputs
    perSystem,  # lets you write perSystem.gomod2nix.buildGoApplicaiton instead of inputs.gomod2nix.packages.${system}.buildGoApplication
    pkgs,       # a nixpkgs instance
}: let
  inherit (pkgs) lib;
in
  perSystem.gomod2nix.buildGoApplication rec {
    inherit pname;

    ####################
    # Elided for brevity
    ####################

  }

Devshells

By now you are probably getting a feel for how things work, and it will come as no surprise that devshells are defined in the devshells/ directory, with the default going in devshells/default.nix.

They work the same way as packages, and you can define them in devshells/foo.nix or devshells/foo/default.nix.

{
  pkgs,
  perSystem,
  ...
}:
pkgs.mkShellNoCC {
  env.GOROOT = "${pkgs.go}/share/go";

  packages =
    (with pkgs; [
      go
      goreleaser
      golangci-lint
      delve
      pprof
      graphviz
      cobra-cli
      enumer
      perSystem.gomod2nix.default
    ])
    ++ # include formatters for development and testing
    (import ../packages/treefmt/formatters.nix pkgs);
}

Modules

You can define modules for NixOS, nix-darwin and home-manager by placing the module definition in the correct subdirectory:

  • modules/nixos
  • modules/darwin
  • modules/home

As with packages, you can define the module in modules/nixos/foo.nix or modules/nixos/foo/default.nix.

One cool feature, which I didn’t know about until the time of writing, is this:

{
    flake,
    inputs,
    perSystem,
}: { pkgs, config, ... }: {

    config.services.foo = {
        enable = config.services.bar.enable;
        package = perSystem.self.foo;
    };

}

Before exposing this module in the flake outputs, blueprint will execute the outer function, passing the flake and so on in which the module is defined. This allows you to conveniently refer to packages and inputs defined within your flake.

DISCLAIMER: I’ve not had a chance to use with this new pattern yet

UPDATE: about 5 mins after posting, it was pointed out to me that this feature is a bit broken and likely to be removed in the near future 🤷 You can follow the discussion here.

Host configs

Host configurations can be placed in hosts/<name>, with NixOS configs using a hosts/foo/configuration.nix file and nix-darwin configs being placed in hosts/foo/darwin-configuration.nix.

As with everything else, blueprint will take care of providing inputs, flake and perSystem as module args:

{
    inputs,
    flake,
    perSystem
}: {

    imports = [ inputs.disko.nixosModules.disko ];

    config.nixpkgs.hostPlatform = "x86_64-linux";
    config.networking.hostName = "foo";
}

Flake checks

What blueprint does about checks is one of the main reasons I like using it so much.

Without any extra configuration, blueprint will do the following:

  • add a check for each devshell e.g. checks.<system>.devshell-<pname>
  • add a check for each package e.g. checks.<system>.pkgs-<pname>
  • in addition to package checks, it will also add all the packages define in a package’s passthru.tests e.g. checks.<system>.pkgs-<pname>-<test_name>
  • for each host configuration, it will add the system closure as a check e.g. checks.<system>.nixos-<hostname>

This means you can easily validate all your devshells, packages and so on with a simple nix flake check and no manual plumbing!

And if you want some additional checks, you can always put them in checks/ with the same approach as defining a package.

Summary

In this short post, I’ve covered a lot of what blueprint can do, focusing on how I’ve been mainly making use of it. If you want some examples, you can look at treefmt and data-mesher, or check out the docs.

I feel it strikes a nice balance between flexibility and simplicity, and lets me just get shit done.

Those who know me, know that I have often described how much I enjoy working with Go because it’s like “coding with crayons”, letting me focus on the task at hand rather than fancy language constructs.

Well, on a similar note, when compared with solutions like flake-parts, you could say blueprint is “flaking with crayons” 😉.