A woman in front of a bunch of pillars

Today, I was asked what the canonical way to use Nix is.

After a brief pause, I responded with the old joke about asking 100 Nix users how to do something and getting 200 solutions back 🤷.

A short chuckle later, I went on to describe my canonical way of using Nix.

It’s been nearly a year since I wrote a retrospective about my first experiences with Nix and NixOS. Since then, I’ve used Nix a lot. In that time, I’ve developed my preferred way of using it, and that’s what the rest of this blog post is about.

Trigger Warning

I’m going to talk about Flakes. If you believe Flakes to be an abomination before God and all of Creation, then please do not continue reading.

By opening this post, you have given me what I crave most: a bump in my visitors count in Plausible. I am grateful for that 🙏.

If you, or anyone you know, has been harmed by Flakes, counselling services are available at The Virus Lounge. Tell @flokli I sent you 😉

The Four Pillars

Whether it’s a new or existing project, when introducing Nix for the first time, I always start with nix flake init.

From there, I start bringing in what I consider the Four Pillars of My Nix Workflow™:

Flake Parts

I think it was PicNoir who once said, “It’s only a matter of time before someone brings in the NixOS module system”. And that’s precisely what Flake Parts does for flakes.

In the early days, we all had to write flake outputs using little utility functions like this:

    outputs = { nixpkgs, ... }: {
        packages = 
            [ "x86_64-linux" "aarch64-linux" ] 
            ({ system: 

Then, one day, zimbatm had enough of copying and pasting between projects and created flake-utils. The Nix community was content- or at least cumulatively less grumpy.

Then, in keeping with the finest of Nix traditions, the NixOS module system was introduced in the form of Flake Parts.

Since I was first introduced to it, Flake Parts has become the de-facto way I structure my flakes, not just because it allows me to break up my flake config and co-locate things with source code where appropriate.

It’s also because other projects can expose flake modules, which make it easy to drop in things like formatters or process managers.

Which brings me to the next pillar.


As the name indicates, Treefmt is one formatter to rule them all.

It doesn’t matter if you have a mixture of markdown, javascript, shell scripts, etc. You can configure Treefmt to use whatever formatter is appropriate, and in one command, you can format your whole source tree.

{inputs, ...}: {
  imports = [
  perSystem = {
  }: {
    treefmt.config = {
      inherit (config.flake-root) projectRootFile;
      package = pkgs.treefmt;

      programs = {
        alejandra.enable = true;
        deadnix.enable = true;
        gofumpt.enable = true;
        prettier.enable = true;
        statix.enable = true;

      settings.formatter.prettier.options = ["--tab-width" "4"];

    formatter = config.treefmt.build.wrapper;

Better yet, with treefmt-nix, you get a flake module that allows you to quickly add Treefmt to your flake. It’ll even mix in a flake check that will let you know if there are formatting errors, with a nice diff explaining why.

❯ nix flake check
error: builder for '/nix/store/gl2cjjb9y1wjh35iq5k82fyghy922jz8-treefmt-check.drv' failed with exit code 1;
       last 24 log lines:
       > Initialized empty Git repository in /build/project/.git/
       > treefmt 0.6.0
       > 2 files changed in 36ms (found 68, matched 15, cache misses 15)
       > On branch main
       > Changes not staged for commit:
       >   (use "git add <file>..." to update what will be committed)
       >   (use "git restore <file>..." to discard changes in working directory)
       >        modified:   flake.nix
       > no changes added to commit (use "git add" and/or "git commit -a")
       > diff --git a/flake.nix b/flake.nix
       > index aa8cd77..0ae1c34 100644
       > --- a/flake.nix
       > +++ b/flake.nix
       > @@ -29,7 +29,6 @@
       >    outputs = inputs @ {flake-parts, ...}:
       >      (flake-parts.lib.evalFlakeModule
       >        {
       > -
       >          inherit inputs;
       >        }
       >        {
       For full logs, run 'nix log /nix/store/gl2cjjb9y1wjh35iq5k82fyghy922jz8-treefmt-check.drv'.


Next on the list is devshell. It’s an alternative to pkgs.mkShell that allows creating dev shells that don’t start out polluting your shell environment with a C compiler or whatever environment variables your dev packages export.

It takes more of an opt-in approach to constructing dev shells, which may be annoying for some people, but I prefer to be explicit rather than implicit. I’m happy enough to set GOROOT myself, for example.

}: {
  imports = [

  config.perSystem = {
  }: let
    inherit (pkgs.stdenv) isLinux isDarwin;
  in {
    config.devshells.default = {
      env = [
          name = "GOROOT";
          value = pkgs.go + "/share/go";
          name = "LD_LIBRARY_PATH";
          value = "$DEVSHELL_DIR/lib";

      packages = with lib;
        mkMerge [
            # golang
          # platform dependent CGO dependencies
          (mkIf isLinux [
          (mkIf isDarwin [

      commands = [
          category = "development";
          package = pkgs.evans;
          category = "development";
          package = inputs.gomod2nix.packages.${system}.default;

In addition, it comes with an excellent little task runner and a setup hooks system for running things on shell init. I often use it to initialise state directories for services I need to run, like a NATS server or a Postgres server.

Process Compose

Which brings me to the fourth and final pillar: Process Compose.

For a long time, this would have been Docker Compose, but when you have Nix at your disposal, Docker just seems unnecessary.

And so it has been for most of 2023 that whenever I need to run some local dev services, I reach for Process Compose, or more specifically, process-compose-flake. It lets me do things like this:

    config.process-compose = {
      dev.settings.processes = {
        nats-server = {
          working_dir = "$NATS_HOME";
          command = ''${lib.getExe pkgs.nats-server} -c ./nats.conf -sd ./'';
          readiness_probe = {
            http_get = {
              host = "";
              port = 8222;
              path = "/healthz";
            initial_delay_seconds = 2;

        nsc-push = {
          depends_on = {
            nats-server.condition = "process_healthy";
          environment = {
            XDG_CONFIG_HOME = "$PRJ_DATA_DIR";
          command = pkgs.writeShellApplication {
            name = "nsc-push";
            runtimeInputs = [nscWrapped];
            text = ''nsc push'';

And when I want to run my dev services, I get a lovely TUI:

A gif demonstrating the process compose tui


That’s it. That’s my workflow… for now.

This post has covered what I consider essential when working with Nix on a new or an existing project. However, I would stress that these are my essentials, not yours.

This post is not titled “Nix: the best workflow”.

If you’re looking for more examples of how I use these tools in practice, have a look at my recent projects on Github.


Devshell and Treefmt are Numtide projects.

Since I was first introduced to Nix by Numtide, and am currently part of Numtide, you could argue it was only natural I would tend towards Numtide tools.

Take from that what you will.