25 KiB
+++ date = "2023-11-25" draft = true path = "/blog/flakes-arent-real" tags = ["nix"] title = "Flakes aren't real and cannot hurt you: a guide to using Nix flakes the non-flake way" +++
Inflammatory title out of the way, let's go.
I think that Nix flakes have some considerable benefits, such as:
- Convenient pinning of evaluation-time dependencies
- Eliminating pointless rebuilds of code by only including tracked files in builds
- Making Nix code, on average, much more reproducible by pervasive pinning
- Allegedly caching evaluation
- Possibly making Nix easier to learn by reducing the amount of poking at
strange attribute sets and general
NIX_PATHbrokenness
However, at the same time, there are a few things that one might be led to think about flakes that are not the most effective way of doing things. I personally use flakes relatively extensively in my own work, but there are several ways I use them that are not standard, with reason.
Flakes are optional, and as much as some people whose salary depends on it might say otherwise, they are not the (only) future of Nix: they are simply a special entry point for Nix code with a built in pinning system, nothing more, nothing less.
Nix continues to gather a reputation for bad documentation, in part because the official documentation for nixpkgs and NixOS is de facto not allowed to talk about flakes, as a policy. This situation is certainly partially due to a divide between Nix developers and nixpkgs developers, which are groups with surprisingly little overlap.
Flakes also are a symptom or cause of much intra-community strife between "pro-flakes" and "anti-flakes" factions, but this situation is at some level a sign of broken consensus processes and various actors trying to sidestep them, an assumption by many people that the docs are "outdated" for not using flakes, and the bizarre proliferation of flakes everywhere in blog posts or tutorials leading to a belief that they are required for everything.
This post is about how to architect Nix projects in general, with a special eye on how to do so with flakes while avoiding their limitations. It tries to dispel misconceptions that can develop in such a monoculture.
"Flakes are the composition primitive in Nix"
The Nix language, functions, and nixpkgs utilities are an effective composition primitive, and are much better suited for putting parts of a project together, especially if it is in a monorepo.
The most flexible way of building large systems with Nix is to merely use flakes as an entry point, and develop the rest using "old" tools. This is for multitudinous reasons:
-
Flakes couple version control integration, dependency management and lockfile management. In medium sized projects, even at the scale of my dotfiles, locking dependencies of subprojects is often highly undesirable.
They're not ideal for either working in the large or in the small: in the small, there is too much overhead in writing a separate
flake.nixfor some tiny utility, and in the large, for example, in nixpkgs, if flakes were actually used for dependency management,flake.nixwould be 100,000 lines ofinputslong. -
In terms of making flexible builds, flakes don't support configuration except through hilarious abuses of
--override-input. This means that all build configuration variants have to be anticipated ahead of time, or that traditional nixpkgs/Nix language primitives need to be used instead. -
Flakes as a composition primitive is completely incompatible with cross compilation. Due to the lack of configuration support,
packages.${system}cannot be used for cross compilation: there is nowhere to specify the architecture to build with.
Because of all of this, even in a flakes world, to compose software in the
large and in the small reusably and efficiently, the other composition
primitives provided by Nix and nixpkgs remain the best choices to assemble
software. A flake can then be relegated to merely an entry point and a way of
acquiring dependencies that are required for evaluation (build-time
dependencies should use pkgs.fetchurl, fetchFromGitHub, etc.
For example, to expose multiple configurations of a program, one might write it the traditional way, using a lambda accepting some configuration parameters, then call that lambda multiple times to expose multiple output attributes inside the flake itself. This separates the capability to configure the software from the actual defined configurations of the software, and avoids letting the configuration non-system of flakes define how the internals of the build definition work.
One of the largest simultaneous advantages and disadvantages of the Nix language is that it is a Turing complete language, which causes pain to static analysis, but is also one of its largest assets: you can program it. This can be seen as a problem, but it also is awesome: you can programmatically patch packages, define configuration dynamically, read files of arbitrary formats and more.
Nix is a functional programming language, which means that its fundamental
composition primitive is the function. Even "fancy" objects like NixOS modules
or overlays are just functions that can be moved into separate files, imported,
or created through partial application of other functions (although, since
imports in modules are deduplicated by file name, NixOS modules generally
should be imported by path instead of generated by functions).
See the next section for concrete ways of composing software together.
"Flakes are where you put your Nix code"
Flakes are merely a fancy schema for making a standardized entry point into Nix
code. Most of the Nix code in a project of any significant size should not be
in flake.nix, for several reasons.
The most trivial reason to put as little code as possible in flake.nix is
maintainability: there is as much rightward drift in flake.nix as in recent
German and Dutch elections (concerningly much!), so from just that perspective,
it's useful to move things out of it.
Let's talk about some standard patterns that have existed before flakes did, which still are relevant in a flakes world.
package.nix
I am using package.nix to refer to the standard way for writing packages in
nixpkgs style, which are invoked with callPackage. This is as opposed to
writing something directly in flake.nix using pkgs.
A package.nix file looks something like so:
{ # receives(*) pkgs.hello and pkgs.stdenv
hello, stdenv,
# can be overridden with `yourPackage.override { enableSomething = true; }`
enableSomething ? false
}:
finalAttrs: # optional finalAttrs to refer to the set below; preferred over using `rec` attr sets
stdenv.mkDerivation {
# ...
}
Package definitions should be written with callPackage if possible, rather
than inline in flake.nix, since using package.nix makes them into small,
composable, configurable, and portable units of software. Also, by using
callPackage and writing in nixpkgs style, it becomes a lot easier to move
packages between projects, and indeed to upstream them to nixpkgs, since they
look and work a familiar way.
Cross compilation
A lesser-known fact is that callPackage is load-bearing for cross
compilation. If you write pkgs.foo in nativeBuildInputs, such a Nix
expression will break under cross compilation, but foo as an argument from
callPackage will not. This is because callPackage will magically resolve
foo appearing inside nativeBuildInputs to mean pkgs.buildPackages.foo;
that is, a package built for the build computer.
callPackage evaluates a Nix file multiple times with different
arguments and splices the results together such that buildInputs magically
receives target packages, and nativeBuildInputs receives build packages,
even if the same package name appears in both. Magic ✨
That is, in the following intentionally-flawed-for-other-reasons flake.nix:
{...}: {
outputs = { nixpkgs, ... }:
let pkgs = nixpkgs.legacyPackages.x86_64-linux;
in {
packages.x86_64-linux.x = pkgs.callPackage ./package.nix { };
};
}
then package.nix:
{ stdenv, hello, openssl }:
stdenv.mkDerivation {
# ...
# things used in the build go in here
nativeBuildInputs = [ hello ];
# libraries used by the resulting program go in here
buildInputs = [ openssl ];
}
Incidentally, notice anything there? Yeah, it's flakes completely not supporting cross compilation. See the next point. :D
It's possible to use the pkgs.buildPackages attribute to pull things into
nativeBuildInputs, and pkgs for buildInputs but it is not conventional
to do so, and is quite verbose.
See the manual about these callPackage shenanigans for more details. See also: the manual about dependency categories.
Overlays
An overlay is a function overriding nixpkgs which is evaluated
until it reaches a fixed point. An overlay takes two arguments, final and
prev (sometimes also called self and super), and returns an attribute set
that is shallowly replaced on top of nixpkgs with //.
It is useful as a means for distributing sets of software outside of nixpkgs,
and still is useful in that role in a flakes world, since overlays are simple
functions that can be evaluated against any version of nixpkgs, allowing for
cross compilation to work properly. One may notice that the overlays flake
output is not architecture specific, which follows from their definition as
functions that take package sets and return modifications to make.
Evaluation to a fixed point means that it is evaluated as many times as necessary
until it stops referring to the final argument (or overflows the stack). This
idea appears in many places, including tables of contents in LaTeX, Typst or
other typesetting programs: by generating the table of contents, you may affect
the layout of subsequent pages and change their page numbers, but after the
first run of that, the layout will probably not change, since the only change
is the numbers, so that iteration likely converges to the final result.
final gives the final version of the attribute set, after overlays have
been evaluated as far as they will go; your overlay may be run multiple times
in evaluating an attribute in final, or even cause infinite recursion. prev
gives the version of nixpkgs prior to the present overlay or any further
overlays.
For example, we could write an overlay to override GNU Hello to be a wrapper
that makes a reference to an excellent retrocomputing series.
Content of overlay.nix:
final: prev: {
hello = final.writeShellScriptBin "hello" ''
${prev.hello}/bin/hello -g "hellorld" "$@"
'';
}
Then:
» nix run --impure --expr '(import <nixpkgs> { overlays = [ (import ./overlay.nix) ]; }).hello'
hellorld
Here, the attribute hello of our modified nixpkgs now is our script that
calls the original hello to say "hellorld".
It's pretty easy to accidentally cause infinite recursion with overlays if
their laziness isn't correct. For example, attribute sets' attribute names are
evaluated strictly, with all names in an attribute set evaluated immediately,
but the values of attributes are lazily evaluated. There have been attempts to
change this but they were canned for performance reasons. Strict
attribute names can be a foot-gun, causing confusing infinite recursion in some
cases using mapAttrs or similar mechanisms on prev to generate the set of
things to override.
Infinite recursion is not typically a problem if an overlay doesn't actually replace anything or contain self-references, as may be the case for overlays distributing very simple software, and we can take advantage of that as shown in the next section.
The place of overlays in a flakes world
Flakes don't support cross compilation.
I am being a little bit tricky with the wording here. Flakes don't stop you from doing cross compilation, but you have to do an end-run around flakes and do it the "old" way.
Because of this design fault in flakes, namely, the lack of support for
parameters, the most compatible way of writing packaging in a flake project
is to write the package definitions into an overlay first, then expose the
packages from the overlay. Consumers that need cross compilation can use the
overlay, and consumers that don't care can use it through packages.
Keeping in mind "1000 instances of nixpkgs", a reasonable way of writing a flake that doesn't modify anything in nixpkgs, just adds stuff is:
{
inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
inputs.flake-utils.url = "github:numtide/flake-utils";
outputs = { self, nixpkgs, flake-utils, ... }:
let
out = system:
let
pkgs = nixpkgs.legacyPackages.${system};
appliedOverlay = self.overlays.default pkgs pkgs;
in
{
packages.myPackage = appliedOverlay.myPackage;
};
in
flake-utils.lib.eachDefaultSystem out // {
overlays.default = final: prev: {
myPackage = final.callPackage ./package.nix { };
};
};
}
Downstream consumers that need to cross compile in spite of flakes can use
the overlay, and other consumers can use packages as normal.
This uses a cute trick of calling the overlay, which is just a function, with
both final and prev as the final nixpkgs attribute set. This definitely
does not work on all overlays, since overlays can make self-references using
final, and indeed often need to do so, if they contain multiple derivations
that depend on each other.
However, with a little bit more work, this can be overcome very cleanly, while also avoiding any possibility of name shadowing problems!
If you do have to use a real overlay that needs to replace things, import nixpkgs again from your flake with the overlay as an argument. It's fine. It's just a second of gratuitous evaluation time:
let pkgs = import nixpkgs { inherit system; overlays = [ self.overlays.default ]; };
in # ....
NixOS modules
NixOS modules are, like overlays and package.nix, fundamentally just
functions which are invoked in a fancy way, and are not a flakes construct.
As used in flakes with the nixosModules.* output, they are
architecture independent since they are just functions, and if defining a
module for software that is built by the same flake, one would generally want to
use an overlay in nixpkgs.overlays or the trick above,
invoking the overlay with pkgs twice, to actually bring it in (again, to
remain cross compilation compatible).
To keep with the theme of putting things outside of flake.nix to enable
reusability, the code for the module can be placed in a separate file that is
imported. Then, flake.nix is used to import that module and inject
dependencies from its environment.
Injecting dependencies
There are a couple of ways to inject dependencies into NixOS modules from a
flake, one of which is mildly uglier. Injecting values from flake.nix into
NixOS is required for a couple of reasons, most notably, to use flakes-managed
dependencies inside NixOS configurations. It is also necessary to properly
configure NIX_PATH so <nixpkgs> resolves in a flake
configuration, since you need the actual inputs from flake.nix to
get a proper reference to nixpkgs suitable to create a dependency on the actual
flake input.
The simplest (and most reasonable, in my view) way to inject dependencies from
a flake is to write an inline module that has them in its lexical closure inside of
flake.nix. If you want to be fancy, you could even make an option to store
the injected dependencies:
let depInject = { pkgs, lib, ... }: {
options.dep-inject = lib.mkOption {
type = with lib.types; attrsOf unspecified;
default = { };
};
config.dep-inject = {
# inputs comes from the outer environment of flake.nix
flake-inputs = inputs;
};
};
in {
nixosModules.default = { pkgs, lib, ... }: {
imports = [ depInject ];
};
}
The uglier and perhaps more well-known way to inject dependencies into NixOS
modules from flakes is specialArgs. This is uglier, since it gets dumped into
the arguments for every module, which is unlike how every other bit of data
flow works in NixOS, and it also doesn't work outside of the flake that's
actually invoking nixpkgs.lib.nixosSystem. The latter is the much more
sinister part, and the reason I would strongly recommend inline modules with
closures instead of specialArgs: they break flake composition.
To use specialArgs, an attribute set is passed into nixpkgs.lib.nixosSystem,
which then land in the arguments of NixOS modules:
# ...
nixosConfigurations.something = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
specialArgs = {
myPkgs = nixpkgs;
};
modules = {
{ pkgs, lib, myPkgs }: {
# do something with myPkgs
}
};
}
Example
For example, this defines a very practical NixOS module that meows at the user on the console on boot:
{
inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs, ... }: {
overlays.default = final: prev: {
meow = final.writeShellScriptBin "meow" ''
echo meow
'';
};
nixosModules.default = { pkgs, config, lib, ... }: {
imports = [ ./nixos-module.nix ];
# inject dependencies from flake.nix, and don't do anything else
config = lib.mkIf config.services.meow.enable {
nixpkgs.overlays = [ self.overlays.default ];
services.meow.package = lib.mkDefault pkgs.meow;
};
};
};
}
and nixos-module.nix containing the actual code:
{ pkgs, config, lib, ... }:
let cfg = config.services.meow; in {
options = {
services.meow = {
enable = lib.mkEnableOption "meow";
package = lib.mkOption {
description = "meow package to use";
type = lib.types.package;
};
};
};
config = lib.mkIf cfg.enable {
systemd.services.meow = {
description = "meow at the user on the console";
serviceConfig = {
Type = "oneshot";
ExecStart = "${cfg.package}/bin/meow";
StandardOutput = "journal+console";
};
wantedBy = [ "multi-user.target" ];
};
};
}
{% codesample(desc="How I tested the above") %}
I put this into flake.nix:
nixosConfigurations.test = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
self.nixosModules.default
({ pkgs, lib, ... }: {
fileSystems."/" = {
device = "/dev/sda1";
};
boot.loader.grub.enable = false;
boot.initrd.enable = false;
boot.kernel.enable = false;
documentation.enable = false;
environment.noXlibs = true;
services.meow.enable = true;
system.stateVersion = "23.05";
})
];
};
Then I built the system configuration:
» nix build .#nixosConfigurations.test.config.system.build.toplevel
{% end %}
"Flakes are the future of Nix, and the only CLI"
Many words have been spilled on the new CLI and its design, mostly focusing on flakes. However, this is not the only mode of the new CLI: wherever it makes sense, it actually fully supports non-flake usage.
To get more exact equivalence with the old CLI, -L (--print-build-logs) and
--print-out-path are useful. Equally, the old CLI can have its output
improved to that of the new CLI by passing --log-format bar-with-logs. I
would be remiss not to mention nix-output-monitor as a much nicer way of
watching Nix builds, as well.
Here is a table of the equivalences:
| Old CLI | Equivalent |
nix-build -A hello |
nix build -f . hello |
nix-shell -A blah |
nix develop -f . blah |
- |
nix run -f . hello |
nix-build -E |
nix build --impure --expr |
nix-instantiate --eval --strict -E 'blah' |
nix eval --impure --expr 'blah' |
"Flakes are how to manage external dependencies"
Flakes are one way of managing external dependencies but they have many flaws in that role.
One flaw is that all the dependencies need to be listed in one file, and there is no way of scoping them into groups.
A poorly documented limitation of both flake inputs and the built in fetchers
in general, which is the reason they are banned in nixpkgs (in addition to
restrict-eval making them not work), is that they block
further evaluation while fetching. The alternative to this is to use a
fixed-output derivation that performs the fetching at build time, such as is
done with pkgs.fetchFromGitHub, pkgs.fetchurl and so on.
The blocking is not necessarily the biggest problem if the dependencies are Nix code required to evaluate the build, but it can be troublesome when the dependencies are not required to evaluate, since it slows down and serializes evaluation, downloading just one thing at a time. If the dependencies are required for evaluation, there is little way to make this better, but for instance, for builds requiring many inputs such as a pile of tree-sitter grammars, Haskell package sources, or such, it adds up badly.
If not flakes then what?
There's a perfectly reasonable argument to be made for just treating
dependencies the same way as nixpkgs and directly calling pkgs.fetchurl and
such inside Nix source. This works fine, is conventional, and avoids the
evaluation-time-build-dependency ("import from derivation" (IFD)) problems.
It's nice to have tools to automatically update these and grab the appropriate hash, though.
There are several tools that can maintain a lock file with Nix hashes, such as Niv, npins, and gridlock. The first two sadly ship Nix files that use built-in fetchers and thus have the evaluation performance issues, and the latter doesn't ship any Nix code.
Thus, the solution is to ignore any provided Nix code for whichever one you choose
to use and write some code to read the tool's JSON file and pull the package
URL and hashes out, and call pkgs.fetchurl with them. This is quite easy to
do and we would recommend it.