How to best consolidate repeated portions of nixos configs?

I’m happy to report that merging my 3 or so NixOS machines’ configs into a single flake was remarkably smooth!

My next task will be DRYing out a lot of the repeated dependencies and configuration. Is there a recommended approach for this?

For example, my first approach was to make defaults/{linux,macos}.nix, and in e.g. linux.nix I’d have:

{pkgs, ...}: {
    basePackages = [ foo bar ];
    nixosDefaults = {
    environment.shells = [ pkgs.bashInteractive ];
    };
}

Then in each machine’s configuration.nix, I can:

let defaults = import defaults/linux.nix { inherit pkgs; };
in
{
imports = [ defaults.nixosDefaults ]
...
environment.systemPackages = with pkgs; [ foo bar ] ++ defaults.basePackages;

It seems usable, but that there may be room for improvement,.

For example, is there a way to use mkMerge to create a set of defaults that can then be modified and added to? I got started down this path, but it seemed like every single attribute in the set required a default, which made it clunkier than I had expected.

What other approaches is everyone using to DRY their NixOS configs?

Not sure what defining basePackages and then appending it achieves here, rather than defaults/linux.nix just being a proper NixOS module, i.e.:

# defaults/linux.nix
{pkgs, ...}: {
    environment.systemPackages = [ foo bar ];
    environment.shells = [ pkgs.bashInteractive ];
}
# configuration.nix
{
  imports = [ ./defaults/linux.nix ];
}
3 Likes

Nixos modules can merge lists, mutli line strings and attr sets.

You can write the following:
module a:

environment.systemPackages = [ foo bar ];

module b:

imports = [ ./moduleA ];
environment.systemPackages = [ buz bingo ];
2 Likes

Instead of making it file-based (each machine imports the files they need), I suggest making your own modules instead and load those and then simply assign roles and profiles to the various machines.

Then your hypothetical (and admitttedly contrived example) config ends up looking like this:

{
  henrie.roles.type = "nixos_laptop";
  henrie.profiles.hardware.model = "thinkpad_x14";
}

The idea is that a machine has 1 role (that may be pulling in other roles and a number of profiles) as well as a hardware profile.

This makes it super easy to compose configuration.

Here is an actual example from one customer (with names changed to protect the innocent):

{ config, lib, pkgs, ... }:

let
  inherit (import ../../common/customer_name.nix { }) sgDevice;

in
{
  own_namespace = {
    profiles.customers.customer_name = true;
    roles.standardAppliance = true;
  };

  networking.hostName = "sin-utility-1";
} // sgDevice "ens160" 51

All the customer specific stuff is set through a customer profile but this makes it super easy to deploy new stuff (and new customers).

1 Like

I swear I had tried that and it gave me a conflict instead of merging. Must have been before I tried importing module style – definitely works now. Thanks, this is what I was hoping for!

Proving it to myself, ripgrep is in the defaults, nix-index in the importer:

$ nix eval --json .#nixosConfigurations.n8arch.config.environment.systemPackages |
    jq |
    grep -e nix-index -e ripgrep
  "/nix/store/d86865gklffs5d594v6sp913a9xpmcr3-ripgrep-13.0.0",
  "/nix/store/bk838n101mrvhi6jp00jrjrygysnh8z9-nix-index-0.1.3",

Also I guess I need a better hostname, goodbye Arch :slight_smile:

EDIT: also thanks to @sandro with a similar suggestion.

Interesting, I"m going to have to chew on this one for a bit. Thanks for your input.

@peterhoeg still having a little trouble visualizing the approach you’re recommending. Are you aware of any more complete examples I could check out? (I realize you can’t share your customers’ data of course.)

Most of nixpkgs/nixos/modules/** fundamentally works the same way, though not in the “roles and profiles” sense. Still, they’re written to do nothing unless their activation conditions, usually an enable option, are met.

It’s just a set of custom modules. The roles and profiles are also just modules that switch things on/off and set their configuration in a consistent way.

I mentioned a hardware profile and then didn’t actually show it in my example - apologies. The reason for this is that it’s often set based on a customer profile (if customer X has standardized on a particular virtualization product, the hardware profile is set as part of the customer configuration).

@n8henrie , did you manage to move ahead with this or do you need anything?

Very kind of you to check back in – just more time I think, very busy at work lately.

I think the general concept makes sense, though I still haven’t quite clicked with the example you provided.

The suggestion is that I make modules that would contain the logic for what is needed based on a variable that determines the role. Is that correct?

pseudocode module:

{ config, pkgs, lib, ...}: {
    config.desktop = if options.role == "laptop" then "plasma" else null;
}

And then configuration.nix can be:

{ config, pkgs, lib}: {
options.role = "laptop";
}

Is that more or less right?

Maybe this is an example?