<-- (back)

Simple and comprehensive Nix configs

29 Sep, 2025
1.1k words

My goal in this post is going to be to give wisdom. This is not a tutorial, this is explaining my mental model and how I think about things and what needed to "click" for me in order to be able to work with and not against Nix.

However, my ambitions fall short at the first step.

Why do you use Nix?

You have to answer why exactly is it that you use Nix, and no amount of my wisdom can help here because I'm me and you're you; and, unless it's me that's reading the post, these people are two different people. What I can only do is give you a hand by explaining possible reasons.

  1. You might be using Nix because it is the hot new thing in town. While technically Nix is more than 20 years old, I feel it has been gaining momentum lately. Maybe you've seen it somewhere, maybe you feel it's the "next step" you "should" take to "get better" at Linux. This is, of course, a slippery slope. I don't know if anything I have to say will be of use to you because things need to happen for a reason and if the reason is "just because" then everything goes.
  2. You want declarative servers.
  3. You want to share configurations between devices.
  4. You want a "clean" system, where you know where everything comes from.

My use case is... the last three. I manage servers and I don't want to touch them too often so it's nice to not have to have any amount of context when doing something on them. I can see all the configuration, I just touch that. And that configuration will work like that forever, I can rollback, etc.

But, I also have a MacBook with macOS and also Asahi Linux, and I want to have a similar config work for both. I have a phone that can launch a terminal so I want to have the same configuration there too and also sometimes I want to be able to use a windows machine with WSL and keep my configuration, again.

And yet, if I'm honest with myself, the main reason is the last one. The idea of a declarative system where there is a single source of truth really resonates with me. I like to spend time making my time using the computer nicer. I don't want to "waste" all that time. I don't want to climb the mountain and fall down, have to do something, go back up again and so on, I want to slowly settle to a place I'm happy with. I'm using Nix to try to realize that dream.

Building your happy place

It's useful to keep in mind why you're doing what you're doing but, in the end, there are three things I do with Nix and that you probably do too:

  1. Add packages to a system
  2. Configure packages
  3. Configure system

All of these things are things I do pretty often, so I want them to be very easy to change and track and tweak and scrap.

The thing is that generally, adding packages and configuring packages is done in the same place. You either have a flat config with everything, or you have a file for each package you want and its configuration.

I realized that that wasn't working for me. I don't want all my packages always, but I want to have them always configured.

My solution is to separate package configurations from what I call bundles. Package configurations are simple, have no options and are one per program. Then, I have various bundles with options to turn them on or off, so that I can choose what bundles of programs I want in each system. Bundles are a very lightweight thing, since it just adds packages or enables programs. Then, package configurations don't enable anything. If something needs to happen conditionally, they can still check lib.mkIf config.programs.my-program or whatever. This adds a very nice amount of flexibility since you don't need to create an option for each thing you have. If I want to add one specific package from one bundle without adding the whole bundle I can just... add that one package. And everything will work out.

This is harder to do with home-manager. I want to use it, but it kind of uses it's own modules. I have seen people do something like home-manager-modules and that seems horrible. I don't want to have that mental overhead of choosing where something should be configured, there should be just one place in package-configurations/my-program.nix and I just want to be able to use home manager inside there. The problem with home manager is that there can be many users. It's hard to do properly. Basically, you want to be able to do something like:

# package-configurations/git.nix
{ ... }: {
  home-manager.users."*".programs.git = {
    delta.enable = true;
    userEmail = "...";
    userName = "...";
    extraConfig = {
     # ...
    };
  };
}

And you can't do that in Nix. Except that that is exactly my configuration. Do I have a user named "*"? No, but Nix is a programming language so I just wrote some code that takes attributes that have an asterisk and replace them with every user declared in the configuration. Sometimes you want to check something specific per user, so I also allow to set the asterisk to a lambda that takes the user name as a parameter. This is useful to check whether some configurations are enabled or not.

The code is frankly ugly and hard to write, but the thing is that the contract is very simple so I probably won't have to touch it again! Remember, what I want to do is easy add and configure packages, and this makes that easier. The code is in my dotfiles repo, in case you want to take a look.

For configuring the system itself it's basically the same story. The only last wrinkle to iron out is the fact that some options don't exist on different "Nix-like" things. Basically, NixOS != nix-darwin != nix-on-droid != NixOS-WSL, they all have different options so you're going to get errors if you try to set non-existent options. The solution here is to add polyfills: create fake options that don't do anything (or, even better, throw if being set).

For example, for NixOS I have to polyfill homebrew and a few more nix-darwin exclusive options:

{
  lib,
  ...
}:
let
  inherit (import ./util.nix lib) fill;
in
{
  options = {
    homebrew = fill;
    security = {
      security.pam.services = fill;
    };

    services = {
      aerospace = fill;
    };

    system.defaults = fill;
  };
}

I set this in the flake itself depending on the type of system. Most of the configuration is agnostic to this, and is still regular ass Nix.

And that's it!