Skip to main content
Musings

A Flakeless World

Description
Removing flakes from my nix habits
Date
tags
nix

Solo Wing Pixy

Can you see any flakes from here?
What has flakes given us?

We're going to start over from scratch. That's what V2 is for.

prelude

A few years ago I migrated my servers to NixOS to make them declarative. This was largely a success. I used flakes at the time because I didn't fully understand what I was doing, and they seemed to help a lot with tooling and such. Where do I put a NixOS configuration? nixosConfigurations.<hostname>. How to deploy this? Use deploy-rs and add a deploy.nodes.<hostname> output containing some special variables.

As I expanded the scope of my infrastructure, this ended up being problematic. How do I build a VM from my configuration? How can I cross-compile for a Raspberry Pi? Or take that configuration and make an SD card image?

I made the mistake of trying to engineer a solution to this inside my flake.nix. I invented complex machinery to try to express my NixOS configurations as packages, packages with explicit cross compilation (because flakes do not have cross-compilation expressed in their packages). I didn't even get it working. I didn't really understand what I was doing, nor how it related to nix the language, nix the packages, and nix the OS.

I started reading articles about not using flakes: Flakes aren't real and cannot hurt you, and Organizing your Nix configuration without flakes. Those are much better articles than I could hope to articulate.

I'd like to remove flakes from my system, and at the same time, share some of the clarity I've gained by doing so. I'm going to be using npins as the input pinning scheme, and largely copying the organization from the second article.

pinning inputs

Flake inputs are references to external sources that are backed by a lockfile. The external source has its flake.nix evaluated in a similar fashion, and we can access the outputs of the flake. We can also override the inputs to the upstream flake using follows, but this is a little hacky and insufficient. There's no way to provide other controls or inputs. There are hacks that can be achieved by overriding inputs (which is insanely cursed).

This abstraction is leaky: it requires you to either be aware of all of the inputs of the other flake or suffer the problem of 1000 nixpkgs. It would be nice if we could:

  1. Expose inputs that can easily be overridden by the caller
  2. Fall back to known versions when not overridden
  3. Also present other input options besides tarballs/git repos.

Nix has this already, and it's called an attrset input:

{
    sources ? (import ./npins), # can override to provide all sources
    nixpkgs ? sources.nixpkgs, # can just override nixpkgs by source,
    pkgs ? (import nixpkgs {}), # can override the instance of nixpkgs used.
}

This method lets us control our "policy" when importing another repository. We can choose to force the sources argument, which fully locks out the dependencies. We can override the source of specific dependencies, which allows us to i.e fetch a specific commit of nixpkgs. Or we can override the instantiation of said dependency, which is very useful if you're making custom overrides to certain packages and want to make the input use them.

An expanded form of the above, largely borrowed from here, and the default.nix for this blog looks something like:

{
  sources ? (import ./npins),
  self ? (import ./. { }),
  system ? (builtins.currentSystem or null),
  nixpkgs ? sources.nixpkgs,
  pkgs ? (import nixpkgs { inherit system; }),
  bobifier-src ? sources.bobifier { inherit pkgs; },
  lib ? pkgs.lib,
  treefmt ? import ./treefmt.nix inputs,
}@inputs:
let
  bobifier = import bobifier-src { inherit system pkgs; };
in
{
  inherit sources self;
  outPath = ./.;
  nixpkgs = pkgs;
  packages.default = pkgs.callPackage ./package.nix { bobifier = bobifier.packages.bobifier; };
  formatter = treefmt.config.build.wrapper;
  checks = {
    formatting = treefmt.config.build.check self.outPath;
  };
}

outPath is part of Stolen Syntax, i.e it is a special variable implicit in the Nix language.

Notice how we can import the other repository and pass in arguments to the default.nix, which we use to override their instance of nixpkgs. This is not always feasible depending on the repository though. If we had additional options we could provide those as well, like being able to override the treefmt settings, or manually cross-compiling a system by passing a different system variable.

The output attribute set is entirely freeform, but we can use the flake conventions as a guide. The Nix command line tooling is not "magic" like flakes. If you want to build a package, it has to be nix build -f . packages.default. You can still use nix3 commands by specifying -f . <attr>. However, nix develop is hopelessly tied to flakes/nixpkgs and will be weird. You should just use nix-shell instead (or better yet, use direnv).

handling nixos

So we've figured out how to build packages, and include other repositories. Now we need to do NixOS builds/deploys. I previously used deploy-rs to deploy the flake with automatic rollback. There's a lot that this does, but you can simplifiy it by using nixos-rebuild --target-host <host>, which does as the name suggests and targets a separate host to change. This is sufficient for now, but I'll revisit this later when making a rollback/deploy system to suit my needs.

The other task is pinning nixpkgs. You see, before flakes, nix had the concept of "channels" (which are a heavily overloaded term). A channel is a bit like a release of a major operating system, for example debian. Debian 13 is released, and no breaking changes are promised. However, software is still updated throughout the release cycle, fixing bugs or sometimes adding non-breaking features. NixOS has stable releases twice a year, and each release is given in the form of a "channel", which promises some level of stability and consistency for that release.

The downside is that our systems lose reproducibility, because channels move. They are not tied to the configuration of the system and are not checked in to git or npins. Flakes magically pin the <nixpkgs> in a system to the nixpkgs specified in the flake.lock - we need to do the same ourselves with npins. To do this, and also to be able to use external modules, we need to inject the npins sources into the nixos module environment. There's two ways to do this: _module.args, and the more common (but slightly more taboo) specialArgs. The difference is that specialArgs can be used in imports blocks of NixOS modules, but _module.args will recurse forever if you try to do that.

{
    pkgs,
    lib,
    config,
    # both `specialArgs` and `_module.args` do this...
    mySpecialArg,
    ...
}: {
    # ... which can be used like this (default.nix) .. 
    environment.systemPackages = [ mySpecialArg.packages.default ];
    # ...but only `specialArgs` lets you do this
    imports = [
        "${mySpecialArg}/mymodule.nix"
    ]
}

So we'll turn our sources into specialArgs and pass it into the NixOS configuration call:

# ... `sources` is already in scope
  nixosSystem =
    nixpkgs: configuration:
    import "${nixpkgs}/nixos" {
      inherit configuration;

      system = null;

      # Generally specialArgs should be avoided if possible.
      # However, we want to be able to use sources/self in `imports` in
      # other modules, and so this is the only way.
      specialArgs = {
        inherit nixpkgs self;
        sources = flatSources;
      };
    };
Woah, what's `flatSources`? New versions of `npins` allow you to provide an instance of `nixpkgs` to use the fetchers inside `pkgs` instead of the bulitins. This is better since it avoids Import-From-Derivation. `flatSources` is just every source with `nixpkgs` provided to make it faster

Now our NixOS modules can access the nixpkgs path. We can use the same machinery as Flakes using a dumb module:

{
    nixpkgs,
    ...
}: {
  nix.settings = {
    experimental-features = "nix-command flakes";
  };
  nixpkgs.flake.source = nixpkgs;
  nix.channel.enable = false;
}

nixpkgs.flake.source just means "make <nixpkgs> or nixpkgs# point to this". We disable channels as well, since those are a footgun.

I have some third-party modules that I use (agenix, nixos-hardware, to name a few). The location of the module in the source is important, since the repo may not have a default.nix or expose the module through there, you end up needing to just provide the path:

{
    sources,
    ...
}: {
    imports = [
        "${sources.agenix}/modules/age.nix"
    ]
}

Not always great, but it works.

conclusion

I learned a lot by doing this, since it removes some abstractions which are opaque. Being able to read the source is a crucial skill, so if your nix chops are a little underdeveloped it might be confusing at first. I still need a better deployment system, and I want to experiment with build-vm, images, and better cross-compilation for ARM, but those are future projects.