A Gentle Introduction to Nix Flakes

English • Esperanto
Sat Jan 25 17:30:55 2025 +0800

But every decision for something is a decision against something else.
—H. G. Tannhaus, Dark (2017)

flakes

Table of contents

Introduction

When I discovered Nix almost two decades ago, I learned that there's still so much to computing than what I already knew. I was blown away. I was amazed. It was nothing short of marvel. My passion for systems administration was rekindled.

As someone who has spent an inordinate amount of time in the BSD-land—configuring everything by hand, memorizing all of the key places where important configuration should be—I found Nix and NixOS to be breath of fresh air.

Not long after, NixOS became my primary development machine. I could do everything with it, even use devices that were not designed from the start to be used with Linux. With the help of the official documentation and helpful articles of people who have used it before me, I was able to set it up according to my preferences. I wrote about what I have learned here.

NixOS

The way that I've always used my NixOS system was that, I would install user packages via nix-env. But no matter how much I optimized the process, it was still slow and cumbersome. I found out that the system was loading more things than necessary, severely impacting performance.

I dug deeper and discovered Nix Flakes. It says on the wiki that it is an experimental feature. I don't know exactly what that means, but it feels like an alpha feature that's already good to go.

To enable it, I edited /etc/nixos/configuration.nix and added the following:

nix.settings.experimental-features = [ "nix-command" "flakes" ];

then did a

sudo nixos-rebuild switch

I got a new set of commands from the nix command, and found out that they are significant departure to the commands that I'm already acquainted with.

To install a package—say emem—without flakes and to uninstall it, run

nix-env --install -A nixpkgs.emem
nix-env --uninstall emem

With flakes, to install and remove it, run

nix profile install nixpkgs#emem
nix profile remove emem

Darwin

When I got my hands on an M1 Macbook Pro, I got naturally curious if there's a way for me to use Nix on it. Soon after, I learned about nix-darwin. After about an hour of tinkering, I finally got the incantation that would build everything. Just like with flakes on NixOS, I got the new set of commads.

Most, if not all, important Nix commands, have already coalesced into nix. I went by fine with it for a year. Soon after, I decided that it's time to take the dive and use flakes outside of the basic configuration.

Flakes

One of the things that has always bothered me was transitioning from the old mode of using shell.nix to create portable Nix shells, to flake.nix. To use flakes, you need to create the file flake.nix, which will be the basis of everything. The command nix reads this file, by default, from the current directory. The init subcommand creates one for us, conveniently.

nix flake init

The resulting file, flake.nix, will look something like the following:

{
  description = "A flake️️";
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs?ref=nixpkgs-unstable";
  };
  outputs = { self, nixpkgs }: {
    packages.x86-64_linux.hello = nixpkgs.legacyPackages.x86-64_linux.hello;
    packages.x86-64_linux.default = self.packages.x86-64_linux.hello;
  };
}

We can see, immediately, that it is an attribute set, of three parts:

{
  description = ...;
  inputs = ...;
  outputs = ...;
}

Put a nice value in description so that we can use that as information when grepping for flakes. inputs specify the things that will go to the flake, while outputs are the ones that will be produced by it, that will then be used by nix commands. inputs itself is an attribute set, and we need first to specify the location for nixpkgs.

inputs = {
  nixpkgs.url = "github:nixos/nixpkgs?ref=nixpkgs-unstable";
}

Here we're using ref to specify a branch name. You can have other specifiers, like a commit ID, with rev,

inputs = {
  nixpkgs.url = "github:nixos/nixpkgs?ref=nixpkgs-unstable";
  oldnixpkgs.url = "github:nixos/nixpkgs?rev=d73ab2f14214a587059fa38cacf82198409e54eb";
}

The value of outputs should be a function that takes an attribute list as an argument and returns an attribute list containing the outputs specification. The form is as follows,

outputs = { }: { };

The outputs of a flake correspond with specific Nix commands. Some of the ones that I use are listed below.

outputused by
packagesnix build
devShellsnix develop
appsnix run
nixosConfigurationsnixos-rebuild –flake
darwinConfigurationsdarwin-rebuild –flake

packages

Let's talk about the the most basic kind of output—packages.

outputs = { self, nixpkgs }: {
  packages.x86-64_linux.hello = nixpkgs.legacyPackages.x86-64_linux.hello;
  packages.x86-64_linux.default = self.packages.x86-64_linux.hello;
};

The argument of the function is an attribute set, with two keys: self and nixpkgs. self, there, is the attribute itself. This allows us to make references to other parts inside. nixpkgs contains all the packages for a specific system, which, in our example above, is x86-64_linux.

In

packages.x86-64_linux.hello = nixpkgs.legacyPackages.x86-64_linux.hello;

we create an output package named packages.x86-64_linux.hello, assigning it the hello derivation from Nixpkgs. We have to specify the arch, because packages are system-specific. Next, we create a default output package which would be evaluated if no package is specified. We use the identifier self.packages.x86-64_linux.hello to select packages.x86-64_linux.hello that was previously defined in this same attribute set.

Let's refactor outputs to make it more readable:

outputs = { nixpkgs }:
 let
   system = "x86-64_linux";
   pkgs = nixpkgs.legacyPackages.${system};
 in with pkgs; {
   packages.${system} = rec {
     hello = pkgs.hello;
     default = hello;
   };
 };

It's standard practice to have a pkgs variable that will point to all the packages. Then, I took out self from the attribute set, so that I'll have more liberty to use rec.

With flakes, everything has to be commited with Git. The nix commands won't work unless, they're part of the repository. Any .nix file that is referenced by the flakes, has to be part of the repository.

git init
git add .
git commit -m 'Initial commit'

To build the hello package, run

nix build .#hello

The . indicate the current directory at the flake source, while #hello says that we build the hello package. If the current directory is /Users/foo/tmp/, the following commands are equivalent.

nix build .#hello
nix build $PWD#hello
nix build /Users/foo/tmp#hello

To build the default package, we simply omit #hello,

nix build
nix build $PWD
nix build .
nix build /Users/foo/tmp

The command creates the symlink result in the current directory that points to the build output in the Nix store. To run hello,

./result/bin/hello

devshells

Perhaps the output that I use the most is devShells. It allows me to create «development shells» (whatever that means) that contain environments that are completely isolated from my main system. It is (essentially) the flakes version of the ones created with nix-shell.

Here's a simple one,

devShells.${system} = rec {
  lisp = mkShell { buildInputs = [ sbcl ]; };
  default = lisp;
};

It defines a lisp shell with pkgs.mkShell and takes an attribute list. The most important key is buildInputs which is a list of the packages. Here it is pkgs.sbcl. Just like with the packages output, we define a default shell with default.

Our flake.nix file now looks like

{
  description = "A flake️️";
  inputs = { nixpkgs.url = "github:nixos/nixpkgs?ref=nixpkgs-unstable"; };
  outputs = { nixpkgs, ... }:
    let
      system = "x86-64_linux";
      pkgs = nixpkgs.legacyPackages.${system};
    in with pkgs; {
      packages.${system} = rec {
        hello = hello;
        default = hello;
      };
      devShells.${system} = rec {
        lisp = mkShell { buildInputs = [ sbcl ]; };
        default = lisp;
      };
    };
}

To enter the default shell, which is lisp, run

nix develop

You then have access to the packages that you have declared,

sbcl --version

Apps

An app output on the other hand allows you to conveniently execute any arbitrary program from a package.

Let go and define an app output that launches a system monitor.

apps.${system} = rec {
  btop = {
    type = "app";
    program = "${pkgs.btop}/bin/btop";
  };
  default = btop;
};

The attribute type has to have the value "app". The attribute program contains the package path to the program that you want to run. To launch it, run

nix run

nixosConfiguration

One of the best things that I have discovered with flakes is the ability to provision the managing of the NixOS configuration. You don't need to change anything with the existing file, /etc/nixos/configuration.nix. You only need to tell flake.nix how to manage it.

nixosConfigurations."hostname" = nixos.lib.nixosSystem {
  modules = [ ./configuration.nix ];
  specialArgs = { inherit pkgs; };
};

The file ./configuration.nix above is a copy of the file /etc/nixos/configuration.nix which will be tracked by version control, too. Add it to the repository

git add configuration.nix

The string "hostname" should be replaced with the hostname of the machine that would use that configuration.

{
  inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; };
  outputs = { nixpkgs, ... }:
    let
      system = "x86-64_linux";
      pkgs = nixpkgs.legacyPackages.${system};
    in with pkgs; {
      nixosConfigurations = {
        "ebzzry-tpad" = nixpkgs.lib.nixosSystem {
          modules = [ ./nixos-configuration.nix ];
          specialArgs = { inherit pkgs; };
        };
      };
    };
}

To rebuild the NixOS configuration for the machine ebzzry-tpad, run

sudo nixos-rebuild switch --flake .#ebzzry-tpad

If there's only one configuration, the following command would suffice,

sudo nixos-rebuild switch --flake .

darwinConfiguration

With nix-darwin, you can do the same as above,

{
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
    nix-darwin.url = "github:lnl7/nix-darwin";
    nix-darwin.inputs.nixpkgs.follows = "nixpkgs";
  };
  outputs = { nixpkgs, nix-darwin }:
    let
      system = "aarch64-darwin";
      pkgs = nixpkgs.legacyPackages.${system};
    in with pkgs; {
      darwinConfigurations = {
        "ebzzry-mbp" = nix-darwin.lib.darwinSystem {
          modules = [ ./darwin-configuration.nix ];
          specialArgs = { inherit pkgs; };
        };
      };
      darwinPackages = self.darwinConfigurations."ebzzry-mbp".pkgs;
    };
}

To rebuild your Darwin configuration, run

darwin-rebuild switch --flake .

flake-utils

Soon after, you'll discover that your config is a mess:

{
  inputs = { nixpkgs.url = "github:nixos/nixpkgs?ref=nixpkgs-unstable"; };
  outputs = { nixpkgs, ... }:
    let
      nixosSystem = "x86-64_linux";
      nixosPackages = nixpkgs.legacyPackages.${nixosSystem};
      darwinSystem = "aarch64-darwin";
      darwinPackages = nixpkgs.legacyPackages.${darwinSystem};
    in {
      packages.${nixosSystem} = with nixosPackages; rec {
        hello = hello;
        default = hello;
      };
      devShells.${nixosSystem} = with nixosPackages; rec {
        lisp = mkShell { buildInputs = [ sbcl ]; };
        default = lisp;
      };
      apps.${nixosSystem} = with nixosPackages; rec {
        btop = {
          type = "app";
          program = "${pkgs.btop}/bin/btop";
        };
        default = btop;
      };
      packages.${darwinSystem} = with darwinPackages; rec {
        hello = hello;
        default = hello;
      };
      devShells.${darwinSystem} = with darwinPackages; rec {
        lisp = mkShell { buildInputs = [ sbcl ]; };
        default = lisp;
      };
      apps.${darwinSystem} = with darwinPackages; rec {
        btop = {
          type = "app";
          program = "${pkgs.btop}/bin/btop";
        };
        default = btop;
      };
    };
}

Each output that we create for a system, needs to be written for other systems that we want to support. This is where flake-utils helps. It is a set of utility functions that helps in writing better Nix expressions. Let first refactor the outputs to make them nicer.

apps.nix:

{ pkgs }: rec {
  hello = {
    type = "app";
    program = "${pkgs.hello}/bin/hello";
  };
  default = hello;
}
 

packages.nix:

{ pkgs }: rec {
  btop = pkgs.btop;
  default = btop;
}
 

shells.nix:

{ nixpkgs, pkgs, ... }:
with pkgs; rec {
  lisp = mkShell { buildInputs = [ sbcl ]; };
  default = lisp;
}
 

flake.nix:

{
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
    flake-utils.url = "github:numtide/flake-utils";
  };
  outputs = { nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem (system:
      let pkgs = nixpkgs.legacyPackages.${system};
      in {
        apps = import ./apps.nix { inherit pkgs; };
        packages = import ./packages.nix { inherit pkgs; };
        devShells = import ./shells.nix { inherit nixpkgs pkgs; };
      });
}
 

Don't forget to add the new .nix files that you're going to create.

What's happening here is that we're adding a new input, flake-utils, next, we're passing it to outputs, finally we're calling a function.

The function flake-utils.lib.eachDefaultSystem takes a function as an argument—(system: ...). That function takes a single argument system, that would correspond to all the available systems. It will then loop through each system and generate the expressions.

Closing remarks

With Nix Flakes, everything becomes more declarative, more comprehensible, and more succinct. I found it easier to manage my systems with a straightforward approach.

All the files that I used in this article can be found here.

It may change in the future, but flakes now, is the best way to manage packages and configurations. Give it a try!