Practical Nix flake anatomy: a guided tour of flake.nix

Published on

Overview of the flake.nix file, its components and what they are used for.

About this post #

There is a quite widespread opinion that Nix documentation is very fragmented and hard to follow. In case of flakes there’s already a number of published materials that talk about how to get started with them and use them.

What I think is missing is a single-page description of the actual flake.nix file with answers to “why would I even need this part” that is aimed to help users who are newer to Nix to make flakes that they encounter in the wild less cryptic.

Since flakes are considered experimental, certain things might change after writing. At time of writing the Nix version is 2.21.0.

The code samples are intended to be as self-sufficient as possible and sometimes will be overly verbose on purpose.

Flake definition #

For the purposes of this post let’s treat a flake as a function that takes inputs and produces outputs. The inputs could be any valid references and the outputs are expected1 to follow a schema.

The outputs could be Linux packages, pieces of NixOS configuration or any other artifact written in nix.

Flake attributes #

Every heading in this section is mapped to a top-level attribute of flake.nix. “Attribute” in this context is used as in nix-the-language – a key in a dictionary.

Description #

Description is (typically) a short blurb about what is the point of the flake.

An example flake.nix could look like:

{
  description = "Some description";
  outputs = _: { };
}

For the sample flake above, the output is:

❯ nix flake metadata .           
Resolved URL:  path:/tmp/tmp.fe06VZ7KtP
Locked URL:    path:/tmp/tmp.fe06VZ7KtP?<boring>
Description:   Some description
Path:          /nix/store/cak4fgj5b3ricl962sqbxs2g2pbc2vyc-source
Last modified: 2024-03-30 <boring>

Contrary to the main doc, it does not have to be one line either but it looks weird in metadata output:

❯ nix flake metadata .
Resolved URL:  path:/tmp/tmp.fe06VZ7KtP
Locked URL:    path:/tmp/tmp.fe06VZ7KtP?<boring>
Description:   Some description.

There is a line break too!
Path:          /nix/store/6kd07z6hv04akbncqly3yvdc11wly7nm-source
Last modified: 2024-03-30 <boring>

Logical next question is – is it just a string or can I use nix language to calculate it? Spoiler: no.

{
  description = let a = "Some description"; in a;
  outputs = _: { };
}

Results in:

❯ nix flake metadata .
error: expected a string but got a thunk at /nix/store/31xvhc041yrh9by1605185sihbr9np60-source/flake.nix:2:3

Since nix cannot be evaluated at the top level of the flake; see nixpkgs#4945.

Strictly speaking, description is not needed. They are present in the output of nix flake metadata of course or could be used by downstream things like flake registry. Only outputs attribute is required.

Inputs #

Flakes could be viewed as functions. Functions can accept some inputs to produce something that would depend on the inputs. Flake inputs are references which are pointing to:

  1. Other flakes

or

  1. File paths

For the syntax of inputs, see the “Examples” section in the flake reference.

Most of the flakes in the wild take some bag of inputs (nixpkgs, some package sources, other flakes) and produce packages, NixOS configurations, WTFs per minute, etc. So most likely you would want to define some inputs.

Note that if an input is a well known one and comes from the public registry, it can be omitted in the inputs attribute.

{
  description = "Some description";
  # Note that no explicit inputs are provided
  outputs = { nixpkgs, self }: { packages.x86_64-linux.default = nixpkgs.legacyPackages.x86_64-linux.hello; };
}

Will happily produce:

❯ nix flake metadata .
Resolved URL:  path:/tmp/tmp.fe06VZ7KtP
Locked URL:    path:/tmp/tmp.fe06VZ7KtP?<boring>
Description:   Some description
Path:          /nix/store/q7hk3cqjp3b5vzbnhnqkpkqaf9liwkbn-source
Last modified: 2024-03-30 17:51:05
Inputs:
└───nixpkgs: github:NixOS/nixpkgs/807c549feabce7eddbf259dbdcec9e0600a0660d?narHash=sha256-9slQ609YqT9bT/MNX9%2B5k5jltL9zgpn36DpFB7TkttM%3D (2024-03-29 12:35:36)
❯ nix run .
Hello, world!

Note that as part of NixOS 24.05 the behavior of nixpkgs input has changed. Now it will point to the version of nixpkgs used to build the system, which will look like this in the metadata:

Inputs:
└───nixpkgs: path:/nix/store/2nnisw4pxbifisbkg58hrnis9ycs5ah1-source?lastModified=0&narHash=sha256-NGKVY4PjzwAa4upkGtAMz1npHGoRzWotlSnVlqI40mo%3D (1970-01-01 00:00:00)

This might break running the flake as-is on a system with different copy of nixpkgs.

On the other hand, flake without any inputs at all can still be useful. For example:

{
  outputs = _: { my-output = 1 + 1; };
}
❯ nix eval .#my-output
2

inputs.follows #

One important thing to note is that a flake’s consumer can override the flake’s transitive input by setting inputs,<flakeref>.inputs.<transitiveDep>.follows = "<anotherFlakeRef>". This is useful if, say, imported flake is pinned to an old version of nixpkgs but is otherwise perfectly usable.

For example, at time of writing home-manager depends on nixpkgs revision d8fe5e6. I would like it to depend on nixpkgs revision 807c549 which is the default nixpkgs version in flake registry at time of writing.

With a simple .follows, home-manager in this flake will use the specified version of nixpkgs:

{
  inputs.home-manager.url = "github:nix-community/home-manager";
  inputs.home-manager.inputs.nixpkgs.follows = "nixpkgs";
  outputs = _: {};
}

Flakes as flake inputs #

By default, an input is a flake reference. For example:

{
  description = "Some description";
  inputs.nixpkgs-unstable.url = "nixpkgs/nixos-unstable";
  outputs = _: {};
}

Then the input can be used in an outputs as a nix attribute set which follows a schema. The flake schema to be precise; case in point, a flake like:

{
  description = "Some description";
  inputs.nixpkgs-unstable.url = "nixpkgs/nixos-unstable";
  outputs =
    { nixpkgs-unstable, ... }: # Outputs' arguments are described in "frequently encountered code patterns"
    {
      packages.x86_64-linux.default = nixpkgs-unstable.legacyPackages.x86_64-linux.hello;
    };
}

Results in:

❯ nix flake metadata .
Resolved URL:  path:/tmp/tmp.fe06VZ7KtP
Locked URL:    path:/tmp/tmp.fe06VZ7KtP?lastModified=1711844267&narHash=sha256-Z6lN1WenLMaWzHTDzsrKwx/Ty%2BXxYZtuot%2Brp4
ye/A4%3D
Description:   Some description
Path:          /nix/store/8q8xak5yzbz5bx87xsp4lx83ggsmpc28-source
Last modified: 2024-03-30 17:17:47
Inputs:
└───nixpkgs-unstable: github:NixOS/nixpkgs/d8fe5e6c92d0d190646fb9f1056741a229980089?narHash=sha256-iMUFArF0WCatKK6RzfUJknjem0H9m4KgorO/p3Dopkk%3D (2024-03-29 09:07:56)
❯ nix run .                         
Hello, world!

Non-flakes as flake inputs #

Flakes can take arbitrary paths as inputs. This is mostly used to bring some package source into the flake and then pass to packages to produce a Nix package. For example:

{
  description = "Some description";
  inputs = {
    nonFlakeInput = {
      url = "path:///tmp/tmp.runi5XRjnC";
      flake = false;
    };
    nixpkgs-unstable.url = "nixpkgs/nixos-unstable";
  };
  outputs =
    {
      nonFlakeInput,
      nixpkgs-unstable,
      self,
    }:
    let
      pkgs = import nixpkgs-unstable { system = "x86_64-linux"; };
    in
    {
      packages.x86_64-linux.default = pkgs.runCommandLocal "example" { } ''
        mkdir -p $out/bin
        cp ${nonFlakeInput}/example $out/bin # "nonFlakeInput" will translate here to the /nix/store path where nonFlakeInput is placed during evaluation
        chmod +x $out/bin/example
      '';
    };
}

The directory that is passed as an input contains a single file:

#!/usr/bin/env bash

echo "Hello world!"

And this ultimately results in:

❯ nix run .
Hello world!

This way a random nonFlakeInput can be provided as the source to some language-specific builder to produce a package in that language.

Outputs #

Outputs are arguably the most useful part of the flake. They allow flake’s consumers to use the flake’s contents.

The outputs are defined broadly into two categories:

  1. System-specific outputs. In this context “system” denotes the architecture and type of the environment where nix is run: aarch64/x86_64; linux/darwin, etc.

    Such outputs often rely on platform specific components. Darwin might not have systemd; some packages may only be available for x86_64 architecture and so on.

  2. System-independent outputs. These outputs may be completely system-independent:

    {
      outputs = _: { my-output = 1 + 1; };
    }
    
    ❯ nix eval .#my-output
    2
    

    Or may handle dependency on the system in the code. Long-running services when defined in modules may have special provisions for MacOS (*-darwin) that replace systemd services with their launchd analogs.

Outputs-the-attribute in flake.nix needs to be a function that produces an attribute set. Simplest (but somewhat pointless) example is:

# Yes, this is a complete flake.nix
{ outputs = _: { }; }

And individual outputs are attributes in this set:

{ outputs = _: { my-output = 1 + 1; }; }

Every output is optional. However, a flake without outputs doesn’t have much purpose beyond an exercise in writing code.

System-specific outputs #

packages #

Arguably the single most commonly encountered output of a flake. packages attribute should contain an attribute set with values being derivations.

For a simple demonstration, let’s use a simple flake that exposes pkgs.hello as its default package:

{
  outputs =
    { nixpkgs, ... }:
    let
      pkgs = import nixpkgs { system = "x86_64-linux"; };
    in
    {
      packages.x86_64-linux.default = pkgs.hello;
    };
}

Side note: the “default” allows commands like nix run to omit specifying the output to run. And without a default specified for the output, nix flake check will complain.

These derivations may be used in a variety of ways:

  1. Run directly:

    # Assuming this is run in the same directory as flake.nix
    ❯ nix run
    Hello, world!
    
  2. In a development shell of the same flake (with a minor adjustment to the flake.nix of course)

    # same flake.nix
    {
      outputs =
        { nixpkgs, self }: # Note the use of `self` which allows reusing flake's outputs in itself
        let
          pkgs = import nixpkgs { system = "x86_64-linux"; };
        in
        {
          packages.x86_64-linux.default = pkgs.hello;
          devShells.x86_64-linux.default = pkgs.mkShell {
            packages = [ self.packages.x86_64-linux.default ];
          };
        };
    }
    
    ❯ which hello
    hello not found
    
    ❯ nix develop
    # <Development shell activates>
    
    ❯ which hello
    /nix/store/rnxji3jf6fb0nx2v0svdqpj9ml53gyqh-hello-2.12.1/bin/hello
    
  3. Installed by a consumer in a separate flake in a NixOS or a home-manager environment

    In a single flake:

    # consuming flake.nix
    {
      inputs.flake-with-package.url = "path:////tmp/tmp.fe06VZ7KtP";
    
      outputs =
        {
          self,
          nixpkgs,
          flake-with-package,
        }:
        {
          nixosConfigurations.my-machine = nixpkgs.lib.nixosSystem {
            system = "x86_64-linux";
            modules = [
              { environment.systemPackages = [ flake-with-package.packages.x86_64-linux.default ]; }
              ./configuration.nix # The rest of the system configuration
            ];
          };
        };
    }
    

    Or in a combination of flakes and modules; flake references can be passed to modules:

    # consuming flake.nix
    {
      inputs.flake-with-package.url = "path:////tmp/tmp.fe06VZ7KtP";
    
      outputs =
        {
          self,
          nixpkgs,
          flake-with-package,
        }:
        {
          nixosConfigurations.my-machine = nixpkgs.lib.nixosSystem {
            system = "x86_64-linux";
            specialArgs = { inherit flake-with-package; };
            modules = [
              ./configuration.nix # The rest of the system configuration
            ];
          };
        };
    }
    # configuration.nix
    { flake-with-package, pkgs, ... }: # specialArgs in the call to nixosSystem make flake-with-package available to be used inside the module
    {
      environment.systemPackages = [ flake-with-package.packages.${pkgs.system}.default ]; # pkgs.system is a common pattern to not repeat the system architecture
      fileSystems."/".device = "/dev/sda";
      boot.loader.grub.devices = [ "/" ];
    }
    

apps #

Apps are fairly well described in the NixOS manual. They are effectively the primary target of nix run. Do note that apps need to follow a specific schema:

{
  type = "app"; # Needs to be literal "app"
  program = "/nix/store/..." # Needs to be a path in store. Common way is to use lib.getExe, lib.getExe' or specify the path to an executable by hand.
}

legacyPackages #

legacyPackages output is very similar to packages but exists for a performance reason best outlined in nixpkgs flake.

nix run .#foo would try to look up “foo” in this order:

  1. apps
  2. packages
  3. legacyPackages

As evident by trying to run nix run .#foo in a flake like the one below and removing attributes one by one.

{
  outputs =
    { nixpkgs, self }:
    let
      pkgs = import nixpkgs { system = "x86_64-linux"; };
    in
    {
      packages.x86_64-linux.foo = pkgs.writeShellScriptBin "foo" "echo 'I am a package'";
      legacyPackages.x86_64-linux.foo = pkgs.writeShellScriptBin "foo" "echo 'I am a legacyPackage'";
      apps.x86_64-linux.foo = {
        type = "app";
        program = pkgs.lib.getExe (pkgs.writeShellScriptBin "foo" "echo 'I am an app'");
      };
    };
}

checks #

Checks are mainly executed when one runs nix flake check. Default behavior is to check the flake against the default schema and emit warnings if non-standard outputs are found or the outputs are not what they are supposed to be. Checks are also derivations and it’s possible to define custom ones. To that end they can be used as integration tests.

Let’s use the flake below as an example. It declares a package and a module as two outputs. The two additional checks packageTest and moduleTest are very simple integration tests that validate the output of the package

{
  outputs =
    { nixpkgs, self }:
    let
      pkgs = import nixpkgs { system = "x86_64-linux"; };
    in
    {
      packages.x86_64-linux.default = pkgs.writeShellScriptBin "foo" "echo -n 'I am a custom package'";
      nixosModules.default =
        { pkgs, ... }:
        {
          environment.systemPackages = [ self.packages.${pkgs.system}.default ];
        };

      checks.x86_64-linux = {

        # If for whatever reason the package definition does not contain integration tests
        packageTest = pkgs.stdenv.mkDerivation {
          name = "packageTest";

          dontUnpack = true;
          dontBuild = true;

          doCheck = true;

          nativeBuildInputs = [ self.packages.x86_64-linux.default ];

          checkPhase = ''
            if [ "$(foo)" = "I am a custom package" ]; then
              echo "OK"
            else
              echo "NOK"
              exit 1
            fi
          '';

          installPhase = "mkdir $out"; # "$out" needs to be produced by a derivation, otherwise check will not pass the check
        };

        # Check the module
        moduleTest = pkgs.testers.runNixOSTest {
          name = "moduleTest";
          nodes.machine1 = {
            imports = [ self.nixosModules.default ];
          };

          testScript =
            # See the devmanual for available python methods:
            # https://nixos.org/manual/nixos/stable/#ssec-machine-objects
            ''
              assert machine.execute("foo")[1] == "I am a custom package"
            '';
        };
      };
    };
}

Unlike nix run, nix flake check does not accept a path to a check; it executes all of them. However, the derivations inside checks can be executed by nix build. In the example above:

❯ nix build .#checks.x86_64-linux.packageTest # Equivalent to running a single check

Since runNixOSTest test a module and modules need the rest of the system, this check builds an actual VM and executes testScript. This VM can be run in an interactive mode:

❯ nix run .#checks.x86_64-linux.moduleTest.driverInteractive # Note the .driverInteractive part
...
# python console starts
>>> start_all()

which will bring up a interactive QEMU VM window with the VM where flake’s module is used. Note that runNixOSTest will helpfully fill in the rest of the system; we don’t need to specify things like fileSystems and boot loader.

devShells #

devShell is an environment that gets activated when nix develop <flakeref> is run. This is most useful to quickly bring in development-only dependencies that may be needed for work on the source code.

Typically it is constructed as a call to pkgs.mkShell which is a wrapper around mkDerivation with extra attributes.

{
  outputs =
    { nixpkgs, self }:
    let
      pkgs = import nixpkgs { system = "x86_64-linux"; };
    in
    {
      devShells.x86_64-linux.default = pkgs.mkShell {
        nativeBuildInputs = [ pkgs.hello ];
      };
    };
}
❯ which hello
hello not found
❯ hello
Hello, world!

There are quite a few flake-related tools that allow more flexibility than the standard call to mkShell:

As with packages, legacyPackages and apps, there can be a default devShell with gets executed by, well, default.

formatter #

The formatter is a single derivation that is run when nix fmt is executed. Most often, one of pkgs.alejandra, pkgs.nixfmt or pkgs.nixpkgs-fmt is used. The upcoming standard will be pkgs.nixfmt-rfc-style and I recommend checking out Overview of Nix formatters ecosystem for more information.

# Not very well formatted code.
# It's a single line with inconsistent spacing around stuff.
{ outputs = {nixpkgs, self }: {formatter.x86_64-linux=nixpkgs.legacyPackages.x86_64-linux.nixfmt-rfc-style;};}
❯ nix fmt
# Much better
{
  outputs =
    { nixpkgs, self }:
    {
      formatter.x86_64-linux = nixpkgs.legacyPackages.x86_64-linux.nixfmt-rfc-style;
    };
}

Note, however, that this attribute is not limited to formatting only nix code. It is possible to use a tool like treefmt to format code in arbitrary language when nix fmt is called.

hydraJobs #

This output is used by hydra – a tool for continuous integration used for NixOS. Hydra can use this attribute to create and evaluate jobs. The wiki article provides a very good example.

bundlers #

Bundlers are functions that allow post-processing derivations, for example, to pack a derivation so that it can be run on a non-NixOS system.

They are used by nix bundle (man page) and have a dedicated repo with example.

System-independent outputs #

overlays #

The main purpose of a Nix overlay is to push new or overwrite attributes of pkgs. As an example, the flake below adds a custom package called foo so that the consumer (in this case, overlayTest check inside the same flake) can just add the overlay when initializing pkgs and call pkgs.foo. Without the overlay pkgs.foo would (at the time of writing) produce “attribute ‘foo’ missing”.

{
  outputs =
    { nixpkgs, self }:
    let
      pkgs = import nixpkgs { system = "x86_64-linux"; };
    in
    {
      packages.x86_64-linux.foo = pkgs.writeShellScriptBin "foo" "echo -n 'I am a custom package'";

      overlays.default = final: prev: { inherit (self.packages.${prev.system}) foo; };

      checks.x86_64-linux.overlayTest = pkgs.testers.runNixOSTest {
          name = "overlayTest";

          node.pkgsReadOnly = false; # Option that is special for tests where machines need to change their nixpkgs

          nodes.machine1 = { pkgs, ... }: { # A module in the resulting system that is defined in line
            nixpkgs.overlays = [ self.overlays.default ];

            environment.systemPackages = [ pkgs.foo ];
          };

          testScript =
            # See the devmanual for available python methods:
            # https://nixos.org/manual/nixos/stable/#ssec-machine-objects
            ''
              assert machine.execute("foo")[1] == "I am a custom package"
            '';
        };
    };
}

nixosConfigurations #

nixosConfigurations exist to be consumed by nixos-rebuild --flake <flakeref>#<hostname> <other nixos-rebuild options>. With this, we can manage the entire (possibly, even remote) NixOS machine. This output is the first candidate to be set if migrating from the standard NixOS install with the system configuration under /etc/nixos.2

{
  outputs =
    { nixpkgs, self }:
    let
      pkgs = import nixpkgs { system = "x86_64-linux"; };
    in
    {
      packages.x86_64-linux.foo = pkgs.writeShellScriptBin "foo" "echo -n 'I am a custom package'";

      nixosConfigurations.my-machine = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";
        modules = [
          (
            { pkgs, ... }:
            {
              environment.systemPackages = [
                pkgs.hello
                self.packages.${pkgs.system}.foo
              ];
            }
          )
          ./configuration.nix
          ./other-modules.nix
          {
            # Stub options
            fileSystems."/".device = "/dev/sda";
            boot.loader.grub.devices = [ "/" ];

            system.stateVersion = "24.05";
          }
        ];
      };
    };
}

nixosConfigurations can also be run as interactive virtual machines by either:

❯ nixos-rebuild build-vm --flake .#my-machine
# or
❯ nix build .#nixosConfigurations.my-machine.config.system.build.vm

templates #

Temlpates are meant to be used by nix flake init -t <flakeref>#<templateref>. An example of a flake that clones itself when nix flake init is called:

{
  outputs =
    { nixpkgs, self }:
    let
      pkgs = import nixpkgs { system = "x86_64-linux"; };
    in
    {
      packages.x86_64-linux.foo = pkgs.writeShellScriptBin "foo" "echo -n 'I am a custom package'";

      templates.default = {
        description = "My self-cloning template";
        path = builtins.toPath self;
        welcomeText = "This template is an example. *Markdown works too*, depending on your terminal emulator.";
      };

    };
}
cd $(mktemp -d)
❯ ls
# empty
❯ nix flake init -t /tmp/tmp.fe06VZ7KtP
wrote: /tmp/tmp.Yr6JQB8UOE/flake.nix
wrote: /tmp/tmp.Yr6JQB8UOE/flake.lock


    This template is an example. Markdown works too, depending on your terminal emulator.
❯ nix flake show
path:/tmp/tmp.<boring>
├───packages
│   └───x86_64-linux
│       └───foo: package 'foo'
└───templates
    └───default: template: My self-cloning template

nixosModules #

Flakes can provide NixOS modules that can be units of a system’s configuration or provide configuration for the custom packages.

Here is an example of a flake with:

{
  outputs =
    { nixpkgs, self }:
    let
      pkgs = import nixpkgs { system = "x86_64-linux"; };
    in
    {
      packages.x86_64-linux.default = pkgs.writeShellScriptBin "foo" "echo -n 'I am a custom package'";
      nixosModules.default =
        { pkgs, ... }:
        {
          environment.systemPackages = [ self.packages.${pkgs.system}.default ];
        };

      checks.x86_64-linux = {

        # If for whatever reason the package definition does not contain integration tests
        packageTest = pkgs.stdenv.mkDerivation {
          name = "packageTest";

          dontUnpack = true;
          dontBuild = true;

          doCheck = true;

          nativeBuildInputs = [ self.packages.x86_64-linux.default ];

          checkPhase = ''
            if [ "$(foo)" = "I am a custom package" ]; then
              echo "OK"
            else
              echo "NOK"
              exit 1
            fi
          '';

          installPhase = "mkdir $out"; # "$out" needs to be produced by a derivation, otherwise check will not pass the check
        };

        # Check the module
        moduleTest = pkgs.testers.runNixOSTest {
          name = "moduleTest";
          nodes.machine1 = {
            imports = [ self.nixosModules.default ];
          };

          testScript =
            # See the devmanual for available python methods:
            # https://nixos.org/manual/nixos/stable/#ssec-machine-objects
            ''
              assert machine.execute("foo")[1] == "I am a custom package"
            '';
        };
      };
    };
}

home-manager modules #

Despite not being a part of the standard flake schema and having various names like homeManagerModules or hmModules, home manager modules are quite commonly encountered in per-project flakes.

As standard NixOS modules, they are functions. Unlike NixOS modules they need the rest of the home-manager modules to work and need to be imported in the context of a specific user.

Here is a flake that’s very similar to the one in nixosModules. It has:

{
  inputs.home-manager.url = "github:nix-community/home-manager";
  outputs =
    { nixpkgs, self, home-manager }:
    let
      pkgs = import nixpkgs { system = "x86_64-linux"; };
    in
    {
      packages.x86_64-linux.parametrized-foo = pkgs.writeShellScriptBin "parametrized-foo" ''
        echo "This is a sample program that tries to read a file from ~/.config."

        cat $HOME/.config/parametrized-foo || echo "File not found in ~/.config"
      '';

      homeManagerModules.default =
        {
          pkgs,
          lib,
          config,
          ...
        }:
        let
          cfg = config.programs.parametrized-foo;
          inherit (lib) mkEnableOption mkOption types;
        in
        {
          options.programs.parametrized-foo = {
            enable = mkEnableOption "parametrized-foo";
            etc-content = mkOption {
              # Use RFC42 for proper packages: https://github.com/NixOS/rfcs/blob/master/rfcs/0042-config-option.md
              type = types.str;
              description = "Content of the config";
            };
          };
          config = lib.mkIf cfg.enable {
            xdg.configFile."parametrized-foo".text = cfg.etc-content;
            home.packages = [ self.packages.${pkgs.system}.parametrized-foo ];
          };
        };

      checks.x86_64-linux = {
        packageTest = pkgs.stdenv.mkDerivation {
          name = "packageTest";

          dontUnpack = true;
          dontBuild = true;

          doCheck = true;

          nativeBuildInputs = [ self.packages.x86_64-linux.parametrized-foo ];

          checkPhase = ''
            EXPECTED_OUTPUT="This is a sample program that tries to read a file from ~/.config.
            File not found in ~/.config"

            if [ "$(parametrized-foo 2>/dev/null)" = "$EXPECTED_OUTPUT" ]; then
              echo "OK"
            else
              echo "NOK"
              exit 1
            fi
          '';

          installPhase = "mkdir $out"; # "$out" needs to be produced by a derivation, otherwise check will not pass the check
        };

        moduleTest = pkgs.testers.runNixOSTest {
          name = "moduleTest";

          nodes.machine1 = {
            imports = [ home-manager.nixosModules.home-manager ];

            users.users.alice = {
              isNormalUser = true;
            };

            home-manager.users.alice =
              _: # { config, ... }: # config is home-manager's config, not the OS one
              {
                imports = [ self.homeManagerModules.default ];
                home.stateVersion = "24.05";

                programs.parametrized-foo = {
                  enable = true;
                  etc-content = "bar";
                };
              };

          };

          testScript = ''
            machine.wait_for_unit("default.target")

            # This test executes a command "parametrized-foo" in the VM as "alice" user and checks that its output is "This is a sample program that tries to read a file from ~/.config.\nbar"
            # where "bar" is the part that's set through the option above
            assert machine.execute("su - alice bash -c 'parametrized-foo'")[1] == "This is a sample program that tries to read a file from ~/.config.\nbar"
          '';
        };
      };
    };
}

lib and other non-standard outputs #

Flake outputs are a proper nix attrset – so it is common to see some functions which build on top of the nix syntax to be available as .lib output. The standard flake schema does not recognize these outputs, so nix flake check emits a warning.

I’ve personally not encountered checks for lib output, but have done something like this:

{
  outputs =
    { nixpkgs, self }:
    let
      pkgs = import nixpkgs { system = "x86_64-linux"; };
    in
    {
      /**
        Always returns false for any input.
      */
      lib.myAwesomeFunc = _: true;

      checks.x86_64-linux = {
        testAwesomeFunc = pkgs.stdenv.mkDerivation {
          name = "testAwesomeFunc";

          dontUnpack = true;
          dontBuild = true;

          # If the test fails, $out is not created => derivation produces an error
          installPhase =
            if (self.lib.myAwesomeFunc 1) then
              "mkdir $out" # "$out" needs to be produced by a derivation, otherwise check will not pass the check
            else
              "";
        };
      };
    };
}

nixConfig #

This top-level flake attribute allows specifying nix.conf values for the flake. In my experience it is most often used to specify extra-substituters so that flake’s consumers can get pre-compiled packages from caches other than cache.nixos.org.

This attribute is entirely optional.

Frequently encountered code patterns #

There are certain code patterns that are commonly used in flakes that I feel like don’t get explained enough.

self as an argument for outputs #

self is a reference to the flake itself. This is how the flake components (usually outputs) can reference other flake components.

Consider this flake:

{
  outputs =
    { self }:
    {
      my-output = builtins.trace (self) true;
      my-other-output = _: false;
    };
}
❯ nix eval .#my-output
trace: { _type = "flake"; inputs = { }; lastModified = 1711942013; lastModifiedDate = "20240401032653"; my-other-output =
 «thunk»; my-output = «potential infinite recursion»; narHash = "sha256-5e+ExKiSrYBz5BUZRZkBIDUs2W9wsbn9u7QbMWtmUqU="; ou
tPath = "/nix/store/79q1s4jwvn2j9ssc8n3j33mvdjn14rgq-source"; outputs = { my-other-output = «thunk»; my-output = «potenti
al infinite recursion»; }; sourceInfo = { lastModified = 1711942013; lastModifiedDate = "20240401032653"; narHash = "sha2
56-5e+ExKiSrYBz5BUZRZkBIDUs2W9wsbn9u7QbMWtmUqU="; outPath = "/nix/store/79q1s4jwvn2j9ssc8n3j33mvdjn14rgq-source"; }; }
true

If self is not really needed in the flake, it can be hidden behind ... in arguments for outputs. This makes linters such as deadnix happy.

{
  outputs = { ... }: { my-output = true; };
}

Adding <flakeref> as an input without specifying it #

Inputs can be implicitly added to the arguments of outputs. As long as they can be resolved from the flake registry, nix will add them to the flake lockfile.

Creating a flake like this:

{ outputs = { nixpkgs, ... }: { }; }

and running nix flake lock will add nixpkgs to the lockfile.

inputs@{ ... } syntax #

When the list of inputs grows, it might become impractical to specify every single input in arguments for outputs. Nix has the "@ pattern", described in the docs.

Often the name inputs is used, which is an (unwritten?) convention. But any other name works:

{
  inputs.nixpkgs.url = "nixpkgs/nixos-23.11";

  outputs =
    args@{ ... }:
    # args@{ nixpkgs, ... }: # This would also work
    {
      packages.x86_64-linux.default = args.nixpkgs.legacyPackages.x86_64-linux.hello;
    };
}

flake-utils, flake.parts, etc. #

Certain parts of the flake require its developer to write a bit of boilerplate. Let’s say we want to provide a package for multiple architectures.

The straightforward way is:

{
  outputs =
    { nixpkgs, ... }:
    {
      packages.x86_64-linux.default = nixpkgs.legacyPackages.x86_64-linux.hello;
      packages.aarch64-linux.default = nixpkgs.legacyPackages.aarch64-linux.hello;
    };
}

But expanding it beyond just two systems and packages becomes tedious.

We could take advantage of nix’s dynamic nature by making packages a result of a function (adapted from bash flake template):

{
  outputs =
    { nixpkgs, ... }:
    let
      supportedSystems = [
        "x86_64-linux"
        "x86_64-darwin"
        "aarch64-linux"
        "aarch64-darwin"
      ];
      forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
      nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; });
    in
    {
      packages = forAllSystems (system: {
        default = (nixpkgsFor.${system}) hello;
      });
    };
}

But copying around this boilerplate would be kinda boring. Let’s reuse it from an existing library, flake-utils:

{
  outputs =
    { flake-utils, nixpkgs, ... }:
    flake-utils.lib.eachDefaultSystem (
      system:
      let
        pkgs = nixpkgs.legacyPackages.${system};
      in
      {
        packages.default = pkgs.hello;
      }
    );
}

This looks much more neat. One problem with such approaches is that libraries like flake-utils define their own domain specific language built on top of nix. This language would need some extra time to get adjusted to.

Specific to the flake-utils use is that flake-utils.lib.eachDefaultSystem function gets copied around and folks are defining system-independent outputs inside this function. While this is valid code:

{
  outputs =
    { flake-utils, nixpkgs, ... }:
    flake-utils.lib.eachDefaultSystem (
      system:
      let
        pkgs = nixpkgs.legacyPackages.${system};
      in
      {
        packages.default = pkgs.hello;
        nixosModules.default = _: { };
      }
    );
}

And it even passes nix flake check, the consumer would need to import <flakeref>.nixosModules.<system>.default rather than <flakeref>.nixosModules.default. One way around this is to merge the system-independent attributes to the result of flake-utils.lib.eachDefaultSystem:

{
  outputs =
    { flake-utils, nixpkgs, ... }:
    flake-utils.lib.eachDefaultSystem (
      system:
      let
        pkgs = nixpkgs.legacyPackages.${system};
      in
      {
        packages.default = pkgs.hello;
      }
    )
    // {
      nixosModules.default = _: { };
    };
}

An alternative to flake-utils, flake.parts, is more explicit about the type of outputs:

{
  outputs =
    inputs@{ flake-parts, nixpkgs, ... }:
    flake-parts.lib.mkFlake { inherit inputs; } {
      imports = [ ];
      systems = [
        "x86_64-linux"
        "aarch64-linux"
        "aarch64-darwin"
        "x86_64-darwin"
      ];
      perSystem =
        { pkgs, ... }:
        {
          packages.default = pkgs.hello;
        };
      flake = {
        nixosModules.default = _: { };
      };
    };
}

It also does a few other convenient things behind the scenes (like magically creating pkgs, allowing composing flakes like modules, etc.). Its DSL does take a bit of getting used to though and requires knowing the basics first.

Deprecated outputs #

Certain outputs (defaultPackage, nixosModule and the like) have been deprecated in nix PR #5532 but still may be encountered in older flakes.

nix flake check emits a warning like “flake output attribute <name> is deprecated”.

For example, this is a way to see all warnings:

{
  outputs =
    { self, nixpkgs }:
    let
      system = "x86_64-linux";
      pkgs = nixpkgs.legacyPackages.${system};
    in
    {
      # System-specific outputs
      defaultPackage.${system} = pkgs.hello; # packages.${system}.default
      devShell.${system} = pkgs.mkShell { natveBuildInputs = [ pkgs.hello ]; }; # devShells.${system}.default
      defaultApp.${system} = { # apps.${system}.default
        type = "app";
        program = pkgs.lib.getExe pkgs.hello;
      };
      defaultBundler.${system} = drv: drv; # bundlers.${system}.default

      # System-independent outputs
      overlay = final: prev: { inherit (pkgs) hello; }; # overlays.default
      nixosModule = _: { }; # nixosModules.default
      defaultTemplate = { # templates.default
        description = "Default template";
        path = builtins.toPath self;
      };
    };
}

And:

❯ nix flake check
warning: flake output attribute 'defaultPackage' is deprecated; use 'packages.<system>.default' instead
warning: flake output attribute 'devShell' is deprecated; use 'devShells.<system>.default' instead
warning: flake output attribute 'defaultApp' is deprecated; use 'apps.<system>.default' instead
warning: flake output attribute 'defaultBundler' is deprecated; use 'bundlers.<system>.default' instead
warning: flake output attribute 'overlay' is deprecated; use 'overlays.default' instead
warning: flake output attribute 'nixosModule' is deprecated; use 'nixosModules.default' instead
warning: flake output attribute 'defaultTemplate' is deprecated; use 'templates.default' instead

Further reading #


  1. but dont’t strictly need to; see non-standard outputs↩︎

  2. if a flake is managed in a git repository, then files outside of that repository cannot be included – they need to be copied into the repo ↩︎

Tags