tty.is_

Migrating from NixOS channels to Flakes

In a recent episode of Linux Unplugged, Chris remarked that he wanted to move his NixOS configuration to using Flakes. Brent mentioned that his brother, a new NixOS convert, made the jump rather painlessly.

Indeed, some relearning is required before you can wrap your head around flakes, and can be hard to convince yourself that it's worth it to move from something that works to something you have to invest time into – especially when you're not entirely sold on the benefits.

So, why bother?

If you've been around the block with nixos-unstable, you're probably familiar with broken channel updates… your system just won't build due to a bug in some package or module somewhere. If you want to revert back, you have to do some spelunking and channel surgery to get back to a clean working state (or just boot back to a previous generation depending on what was broken), which kinda sucks.

Worse yet, imagine that your functional config hops in a time machine and needs to be applied to a fresh machine. How do you get back to that chewy fudge brownie of a system you once had? Well, assuming you can't borrow the time machine your config used, you're going to have to resort to git repo archaeology and guesswork.

It ain't great. Ask me how I know.

Flakes are a chewy fudge brownie

Flakes enable some pretty killer scenarios, but the most basic (and to my mind most important) is that the state of nixpkgs is recorded along with the config via the flake.lock file. Your meticulous hand-crafted setup can, like a fine wine, be uncorked at some future date with all of the package versions intact.

Need to stand up a CI VM like it used to be back in good ol' 2022? Flakes got you.

Ugh, I don't have the ti-

Look, it's really not that hard to start. I'll even throw in some free advice1 for you at the end!

Enable flake support in your current config

Add this to your config and do your nixos-rebuild routine:

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

Start a new git repo

Use GitHub, Gitlab, or whatever if you like… or just do something local-only:

mkdir ~/nixos-conf
cd ~/nixos-conf
git init

Create your flake.nix

You can start completely from scratch if you like:

nix flake init

…but that's not really why you're here. Instead of staring at a mostly-empty nano window (I see you), you can start from a more fully-fledged template. I recommend Misterio77's minimal starter config.

nix flake init -t github:misterio77/nix-starter-config#minimal

The template is pretty thorough, but you'll have to fill out some details appropriate to your scenario. The template helpfully marks the couple of changes that need to be made with FIXME and TODO so crack open your favorite editor, search for FIXME and TODO and get to fixin'. A more detailed treatment of this can be found at the starter config link mentioned above.

Personally, I don't like managing home-manager separately from the broader system, so I followed Misterio77's advice to merge it into my system configs.

Copy in your existing config

cp /etc/nixos/{configuration,hardware-configuration}.nix ~/nixos-conf/nixos

Getting nix to see your changes

Common gotcha: flakes tooling sees the world through git. If a file isn't being tracked, nixos-rebuild won't reference it.

git add --all

Update your flake.lock

This is roughly equivalent to updating your channel. I'll explain a bit more later.

nix flake update

Time for a test build

nixos-rebuild build --flake .#your-hostname

Note: You can safely ignore any warnings about your build being "dirty" - this just means you have changes you haven't committed in git yet.

This test build, if successful, will produce a symlink named result in the directory you ran the command from. You can safely rm this symlink. You might consider adding result to your .gitignore (optional).

Drumroll please

sudo nixos-rebuild switch --flake .#your-hostname

Make it permanent

Don't want to lose a good thing, right?

git commit -a -m "first post!"
git push # assumes you've got a remote to push to

Next steps

Okay, so now you're running a flake-based install. What now?

Ongoing updates and upgrades

How do you run this thing? Updating/upgrading looks something like this (should look familiar):

  1. Make edits to the configuration.nix in your repo (e.g. to add a new package)
  2. nix flake update <- if you want to pull more recent versions of things
  3. git commit -a -m "BAM!" && git push
  4. sudo nixos-rebuild switch --flake .#your-hostname

So what exactly does nix flake update do?

When you run nix flake update, each input defined in your flake.nix is examined and updated.

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

This defines an input called nixpkgs backed by a GitHub URL (if you prefer the long form, you can say "https://github.com/nixos/nixpkgs/tree/nixos-unstable" ). It refers to the nixos-unstable branch of the nixpkgs project in the nixos organization. The nixos-unstable branch moves over time as PRs get merged in the broader project, so how does Nix know what point in time we're supposed to be using? That's what the flake.lock is for.

{
    "nodes": {
        "nixpkgs": {
            "locked": {
                "lastModified": 1720957393,
                "narHash": "sha256-oedh2RwpjEa+TNxhg5Je9Ch6d3W1NKi7DbRO1ziHemA=",
                "owner": "NixOS",
                "repo": "nixpkgs",
                "rev": "693bc46d169f5af9c992095736e82c3488bf7dbb",
                "type": "github"
            },
            "original": {
                "owner": "NixOS",
                "ref": "nixos-unstable",
                "repo": "nixpkgs",
                "type": "github"
            }
        }
    }
}

Flake super powers

If you've decided to use a public GitHub or Gitlab repo as a remote, you can reference your flake(s) directly from other machines. This means that to reinstall your machine completely from scratch, you can boot a vanilla NixOS install image, do your partitioning as normal, then magic:

sudo nixos-install --flake github:YourGithubUsername/YourFlakeRepo#your-hostname

You don't have to clone your repo or futz about pointing curl at some random URL – you just install straight from your repo.

Managing multiple systems with one flake

It's easy to adapt your new setup to be useful on more than one machine. Take a look at this section in your flake.nix:

nixosConfigurations = {
  your-hostname = nixpkgs.lib.nixosSystem {
    specialArgs = {inherit inputs outputs;};
    modules = [./nixos/configuration.nix];
  };
};

If you had two identical machines that are identically partitioned, you could possibly get away with doing something like this (depending on how your hardware-configuration.nix is written):

nixosConfigurations = {
  your-hostname = nixpkgs.lib.nixosSystem {
    specialArgs = {inherit inputs outputs;};
    modules = [./nixos/configuration.nix];
  };
  your-second-hostname = nixpkgs.lib.nixosSystem {
    specialArgs = {inherit inputs outputs;};
    modules = [./nixos/configuration.nix];
  };
};

But of course, you don't have two identical machines2. You probably have some set of things you want to share across machines (e.g. installing/configuring your favorite editor) and some things that are decidedly machine-specific (e.g. networking.hostId). Here's how to get to the "shared stuff is shared; machine stuff is split" promised land:

cd ~/nixos-conf # or wherever your repo root is
mkdir -p nixos/{your-hostname,your-second-hostname,common}
mv nixos/configuration.nix nixos/your-hostname/default.nix
mv nixos/hardware-configuration.nix nixos/your-hostname

Note: default.nix has a special meaning to nix – if some .nix expression references a directory without specifying a filename, default.nix will be used automatically if it exists.

Create a new file, nixos/common/default.nix, and pull common config items out of your existing system's config.

{ inputs, lib, config, pkgs, ... }: {
  services.emacs.enable = true;

  # other config you want to share goes here
}

Now you reference this common config in each system you want to share with.

nixos/your-hostname/default.nix:

{ inputs, lib, config, pkgs, ... }: {
  imports = [
    ../common
  ];

  # the rest of your system config goes here
}

Repeat this exercise for each system.

Finally, update your flake.nix:

nixosConfigurations = {
  your-hostname = nixpkgs.lib.nixosSystem {
    specialArgs = {inherit inputs outputs;};
    modules = [./nixos/your-hostname];
  };
  your-second-hostname = nixpkgs.lib.nixosSystem {
    specialArgs = {inherit inputs outputs;};
    modules = [./nixos/your-second-hostname];
  };
};

Don't forget to add your new files to git and/or commit them! As noted above, this is a pretty common gotcha that folks run into when first starting out with flakes. If you're ever seeing error messages indicating that a file doesn't exist, but you know it does, in all likelihood you've forgotten to git add it.

Why do flakes behave this way? Well, part of the point of flakes is to avoid depending on any state that can vary at build time without being detected – we want that reproducibility. The Nix language can reference environment variables and arbitrary files, and can be influenced by other outside state. By limiting the view of the world down to what's in the git repo, we can greatly improve our ability to define software that builds exactly the same across machines and in different environments.

Anyways, now that you've gotten everything in git, you can manage both machines from the same flake as you would normally (except, of course, you specify the host name when you nixos-rebuild).

The End Result

To recap, here's the directory structure you should end up with:

git_repo_root
- flake.nix
- flake.lock
  - nixos/
    - common/default.nix
    - your-hostname/
      - default.nix
      - hardware-configuration.nix
    - your-second-hostname/
      - default.nix
      - hardware-configuration.nix
  - home-manager/home.nix

And a rough sketch of the files should look something like this:

{
  description = "NixOS";
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; # or stable, if that's your bag
    home-manager.url = "github:nix-community/home-manager"; # or pin to a release if you like
    home-manager.inputs.nixpkgs.follows = "nixpkgs";
  };

  outputs = { self, nixpkgs, home-manager, ...}@inputs: let
    inherit (self) outputs;
  in {
    nixosConfigurations = {
      your-hostname = nixpkgs.lib.nixosSystem {
        specialArgs = {inherit inputs outputs;};
        modules = [./nixos/your-hostname];
      };
      your-second-hostname = nixpkgs.lib.nixosSystem {
        specialArgs = {inherit inputs outputs;};
        modules = [./nixos/your-second-hostname];
      };
    };

    # as noted further up, I prefer merging the home-manager module directly into the host configuration
  };
}
<autogenerated-file />
{ inputs, lib, config, pkgs, ... }: {
  imports = [
    inputs.home-manager.nixosModules.home-manager
  ];

  # assuming you want home-manager set up the same for every system
  home-manager = {
    backupFileExtension = "back";
    useGlobalPkgs = true;
    useUserPackages = true;
    extraSpecialArgs = { inherit inputs; };
    users.yourUserName = import ../../home-manager/home.nix;
  };

  # other config you want to share goes here
  services.emacs.enable = true;
}    
{ inputs, lib, config, pkgs, ... }: {
  imports = [
    ./hardware-configuration.nix
    ../common
  ];

  networking.hostName = "your-hostname";

  # all of your config specific to your-hostname
}
{ inputs, lib, config, pkgs, ... }: {
  imports = [
    ./hardware-configuration.nix
    ../common
  ];

  networking.hostName = "your-second-hostname";

  # all of your config specific to your-second-hostname
}
{ inputs, lib, config, pkgs, ... }: {
  home = {
    username = "your-username";
    homeDirectory = "/home/your-username";
  };

  # whatever other home-manager config you like
}

Footnotes:

1

Worth every penny :D

2

VM maybe?