Skip to main content
ArticlesProjects

One Host, Twenty-One Files: A NixOS Flake That Stays Out Of Your Way

How auto-imported flake-parts modules, wrapped desktop packages, and a single base16 palette file shape one NixOS config - and where each one bites.

Steve McDougallAug 202613 min read

I run one machine on NixOS. A Framework Desktop, AMD AI Max 300 series, running the niri Wayland compositor with the noctalia shell. The configuration behind it is twenty-one .nix files, twenty-six tracked files in total, and one build that produces both the system and my user environment together.

The package list is the boring part of any config like this. What I want to walk through is the wiring, because three decisions shape everything else in the repo. There is no import list to maintain. Desktop programs are configured by building a different package rather than by writing dotfiles. And one file, thirty-nine lines long, controls the colour of everything on screen.

Each of those decisions has a sharp edge, and I found all three the hard way. Let me show you the config first, then tell you exactly where it bites.

The flake is a stub

Here is flake.nix in full. Inputs, and a single line of outputs.

{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
import-tree.url = "github:vic/import-tree";
wrapper-modules.url = "github:BirdeeHub/nix-wrapper-modules";
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
nixos-hardware.url = "github:NixOS/nixos-hardware/master";
stylix = {
url = "github:nix-community/stylix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = inputs: inputs.flake-parts.lib.mkFlake { inherit inputs; }
(inputs.import-tree ./modules);
}

That last line does all the work. flake-parts gives me a module system for the flake itself, and import-tree recursively hands it every .nix file under ./modules. Add a new file anywhere in that tree and it is wired in. There is no imports = [ ... ] to keep in sync, which is the single most tedious part of a Nix config as it grows.

The trade-offs are worth stating plainly, because none of them announce themselves:

  • Every file under modules/ has to be a flake-parts module. Top-level flake.*, perSystem, config, and so on. Put a bare NixOS module in there and evaluation fails with option errors that point nowhere near the real cause.
  • import-tree skips any basename starting with an underscore. That makes _scratch.nix a useful way to park a file without loading it.
  • Non-.nix files are ignored entirely, so a stray JSON file sitting in the tree is inert.

Two things deliberately live outside modules/ because of that first rule. palette.nix sits at the repo root, since it is a plain attrset rather than a module of any kind, and import-tree would try to load it as one. The whole users/ tree is Home Manager modules, which are also not flake-parts modules, so those get imported by path instead. The cost is real: users/ files are listed by hand, and a new one does nothing at all until I add it to the list.

Modules are values, not files

Nothing in this config imports another config file by path, with the one exception above. Each file defines a named output instead.

modules/features/stylix.nix
flake.nixosModules.stylixTheme = { pkgs, lib, ... }: { /* ... */ };

Other files then consume it by name through self:

modules/hosts/framework/configuration.nix
imports = [
self.nixosModules.frameworkHardware
self.nixosModules.niri
self.nixosModules.stylixTheme
self.nixosModules.devServices
];

The full chain, from flake output down to a themed application, looks like this:

flake.nixosConfigurations.framework (hosts/framework/default.nix)
├─ nixos-hardware framework-desktop-amd-ai-max-300-series
├─ self.nixosModules.frameworkConfiguration (hosts/framework/configuration.nix)
│ ├─ self.nixosModules.frameworkHardware (hosts/framework/hardware.nix)
│ ├─ self.nixosModules.niri (features/niri.nix)
│ ├─ self.nixosModules.stylixTheme (features/stylix.nix)
│ └─ self.nixosModules.devServices (features/devservices.nix)
└─ home-manager.nixosModules.home-manager
└─ users."steve" = users/steve.nix
└─ users/features/*.nix (listed by hand)

self and inputs are threaded down through specialArgs and extraSpecialArgs, so both NixOS and Home Manager modules can reach flake outputs:

flake.nixosConfigurations.framework = inputs.nixpkgs.lib.nixosSystem {
specialArgs = { inherit self inputs; };
modules = [
# ...
{
home-manager = {
useGlobalPkgs = true;
useUserPackages = true;
backupFileExtension = "hm-bak";
extraSpecialArgs = { inherit self inputs; };
users."steve" = import ../../../users/steve.nix;
};
}
];
};

Home Manager runs as a NixOS module here rather than standalone, so nixos-rebuild switch is the only command I ever need. System and user config activate together, or neither does.

That backupFileExtension line matters far more than it looks. Home Manager refuses to overwrite a file it did not create, and it aborts the entire activation with a message about an existing file being clobbered. Setting a backup extension moves the old file aside instead, so the switch succeeds and the original is still recoverable.

The two evaluation contexts

This is the concept that explains most of the confusing failures in a flake-parts config, and it is worth internalising early.

There are two separate worlds:

  • flake.* outputs are evaluated once, not per system. That is nixosConfigurations and nixosModules.
  • perSystem outputs are evaluated once per system, each with their own pkgs. That is packages, devShells, and formatter.

modules/parts.nix declares the systems, all four of them, even when only one is deployed:

{
config.systems = [
"x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"
];
}

Here is the part that catches people. perSystem cannot see the NixOS configuration. Not that it should not, it genuinely cannot. It is a different evaluation with a different pkgs and no access to config.* at all. Two consequences bite in practice.

The first is unfree packages. I set nixpkgs.config.allowUnfree = true inside the NixOS module, so it does not apply to the pkgs that perSystem sees. Reference pkgs.vscode inside a perSystem package and it fails to evaluate. Because the niri package is referenced by programs.niri.package, that one failure takes down the entire nixos-rebuild, not just the package that caused it. Which is why the editor keybind in this config resolves from PATH at runtime rather than being pinned:

# Deliberately not lib.getExe pkgs.vscode
"Mod+C".spawn-sh = "code";

It also keeps roughly 400MB of editor out of the compositor’s closure, so the iteration loop stays fast. Two problems, one workaround.

The second is theme colours. config.lib.stylix.colors is just as unreachable from perSystem. That is the entire reason my palette is a plain importable file rather than something read out of stylix at evaluation time.

The general rule I keep coming back to: to share anything across that boundary, it has to be a plain value in a file that both sides import.

Desktop programs are packages, not dotfiles

Neither niri nor noctalia is configured through Home Manager options or hand-written config files here. Instead, perSystem builds a pre-configured wrapper using wrapper-modules:

perSystem = { pkgs, lib, self', ... }: {
packages.myNiri = inputs.wrapper-modules.wrappers.niri.wrap {
inherit pkgs;
settings = {
spawn-at-startup = [ [ (lib.getExe self'.packages.myNoctalia) ] ];
layout.gaps = 5;
binds = {
"Mod+Return".spawn-sh = lib.getExe pkgs.ghostty;
"Mod+Q".close-window = { };
# ...
};
};
};
};

The NixOS module then does nothing more than install it:

flake.nixosModules.niri = { pkgs, ... }: {
programs.niri = {
enable = true;
package = self.packages.${pkgs.stdenv.hostPlatform.system}.myNiri;
};
};

So the compositor’s entire configuration lives inside the derivation. Change a keybind, get a new store path. Nothing is written to ~/.config, and there is no file for the running program to drift away from.

Because niri references the shell package directly, both for spawn-at-startup and for the Mod+Space bind that calls noctalia-shell ipc call launcher toggle, the two files are coupled. Changing the shell’s settings rebuilds the compositor’s config as well.

Where this model strains

I want to be honest about the failure mode here, because it is a real one and it took me a while to spot.

Some programs are GUI-first. Their settings panel edits their config file rather than merging over a read-only base, and noctalia is one of them. The default wrapper behaviour points an environment variable at a config file in the Nix store, which is read-only. Every change I made in the shell’s own settings panel silently failed to save, while the real ~/.config file sat orphaned and ignored.

Worse, the settings themselves were being dropped. noctalia loads its config through a QML JsonAdapter, which binds only declared properties. An unknown key is discarded without comment. No warning, no error, no log line. A hand-written Nix block full of plausible-looking keys like bar.height, modules.left and theme.accent produced a shell running on stock defaults, and nothing anywhere told me so.

Two lessons generalise well beyond this one program.

Verify keys against the program’s own schema rather than against what looks reasonable. In this case the truth was sitting in $out/share/noctalia-shell/Assets/settings-default.json the entire time I was guessing.

Check whether the program expects to write its own config. If it does, a read-only store path is the wrong delivery mechanism. wrapper-modules has an outOfStoreConfig escape hatch that seeds a real directory instead, but the copy is no-clobber, which makes it seed-once rather than declarative. That trade-off is worth taking deliberately rather than by accident.

The generic version of that second lesson is one I would put on a wall: a declarative config layer and an application that persists its own state will fight, and the application usually wins quietly.

One file controls every colour

palette.nix is a plain base16 attrset, Catppuccin Mocha, and it depends on nothing at all:

{
base00 = "1e1e2e"; # base: default background
base01 = "181825"; # mantle: lighter background (status bars)
# ...
base0D = "89b4fa"; # blue: functions, headings
base0E = "cba6f7"; # mauve: keywords
}

Stylix consumes it and themes roughly 130 applications from that one definition. GTK, Qt, VS Code, ghostty, fuzzel, bat, GRUB, Plymouth. Fonts come from the same block. No application in this config carries its own colour settings anywhere.

Editing that one file repaints the desktop on the next rebuild. That is the whole feature.

There are two opposite override traps waiting here, and they catch people in both directions.

Stylix’s targets use mkDefault, so an explicit setting in your own config silently wins and stylix appears to do nothing at all. No error, no clue. If you have set font-family or workbench.colorTheme yourself, that is why your theme is not applying.

Some of stylix’s own options are plain definitions. stylix.targets.qt.platform is set from the enabled desktop manager without mkDefault, so overriding it needs lib.mkForce. A normal assignment there is a conflict, not an override.

Then there is the coverage gap. Stylix has no niri or noctalia target, and it could not reach either package anyway, since they are built by perSystem. Both import palette.nix directly instead. Same palette, different delivery mechanism, which is the “plain value in a file both sides import” rule from earlier showing up in practice.

Layout and conventions

flake.nix inputs + one line of outputs
palette.nix base16 colours, plain attrset, not a module
modules/
parts.nix systems list
hosts/framework/
default.nix nixosConfigurations.framework
configuration.nix system-scoped config
hardware.nix generated, filesystem UUIDs and kernel modules
features/
niri.nix compositor package + NixOS module
noctalia.nix shell package
stylix.nix theming
devservices.nix dev databases as containers
users/
steve.nix thin index, identity + import list
features/ one file per concern
cli.nix desktop.nix dev.nix direnv.nix firefox.nix
git.nix shell.nix ssh.nix terminal.nix vscode.nix

The split is by scope, not by topic. Anything user-scoped goes under users/features/, one file per concern: packages, aliases, shell, git identity, ~/.local/bin scripts. System-scoped config goes in configuration.nix.

This only works because of a small but load-bearing detail. home.packages is a list and home.shellAliases is an attrset, so every feature file can contribute to either and they merge rather than conflict. That is what makes one file per concern viable instead of one enormous file.

Gotchas worth writing down

Flakes only see git-tracked files. A new .nix file that has never been git added is invisible to the build even though it is sitting right there in front of you, and the failure looks like a missing option rather than a git problem. Add before you rebuild. Keep .gitignore narrow for the same reason, since anything ignored is invisible rather than merely untracked.

Do not run nix flake update under sudo. It writes flake.lock, which is owned by your user. Run it as root and root ends up owning the lock file, and every later unprivileged update fails with a permission error. The two halves belong in one alias, with only the switch elevated:

update = "nix flake update --flake /home/steve/nixconfig \
&& sudo nixos-rebuild switch --flake /home/steve/nixconfig#framework";

On Nix 2.34 the flake is a flag rather than a positional argument. The older nix flake update <path> form is gone, and it is --flake <path> now.

Pin container images by digest, not by tag. oci-containers only pulls when the image is absent locally, so a bare :18 tag pins to whatever happened to be current on first boot. Unreproducible, and never updated. A digest makes that explicit:

image = "docker.io/library/postgres:18@sha256:3a82e1f56c8f0f5616a11103ac...";

The build loop

There are no tests here and no linter. My verification loop is two commands, three if I am applying:

Terminal window
nix flake check # evaluate every output
sudo nixos-rebuild build --flake .#framework # build, don't activate
sudo nixos-rebuild switch --flake .#framework # apply

For desktop work, building one package is dramatically faster than a full rebuild:

Terminal window
nix build .#myNiri
nix build .#myNoctalia

That is the real payoff of the wrapped-package approach. Iterating on a keybind or a bar layout is a package build, not a system activation. You can inspect the generated config directly in the store before committing to anything:

Terminal window
nix build .#myNoctalia --no-link --print-out-paths

Aliases defined in users/features/shell.nix shorten the common ones: rebuild, build, update, clean, conf. Worth remembering that those only exist after a successful switch, which is its own small chicken-and-egg moment the first time you break something.

What this design buys

One command. rebuild produces the system and the user environment together. There is no separate home-manager switch step and no way for the two halves to be out of sync.

No import bookkeeping under modules/. New feature file, done. That is a genuine quality-of-life difference as a config grows past a handful of files.

Desktop config that is content-addressed. Programs configured through wrapper-modules have no mutable config file, so there is nothing to drift.

One file for the whole palette.

What it costs

A boundary you have to hold in your head. perSystem cannot see the NixOS config, everything shared across that line has to be a plain value in a plain file, and forgetting it produces errors that never name the real cause.

Auto-import is all or nothing. Every file under modules/ has to be the right kind of module, and users/ has to sit outside the tree and be listed by hand.

GUI-first programs do not fit. Anything that writes its own config will fight a store-path config file, and it fights quietly.

Silent failure is the dominant failure mode. Stylix targets that mkDefault, JSON adapters that drop unknown keys, git-untracked files invisible to the build. None of those produce an error. They produce a system that builds cleanly and behaves as if your config is not there.

That last one is the thing I would most want to know going in. Nix has a reputation for loud, obscure error messages, and it earns it. The failures that actually cost me time on this config were the ones with no message at all.

The habit worth building is simple enough to state and surprisingly hard to keep: verify the generated artifact, not the input you handed over.

Share

XLinkedIn

Related

Keep Reading

All posts →