Managing Multiple nix-config Host Variables for NixOS and Darwin
Table of Contents
Overview and video
This article describes the introduction of new module we added to nix-config that facilitates dynamic configuration hosts based on several options.
If you prefer, I also have a video on YouTube that covers much of the same content.
Introduction
When work began on remotely installing NixOS and nix-config earlier in the 2024, we used configVars
to establish some constants, such as primary username, across hosts as well as flags to indicate desired host states. This came about specifically because of a need to differentiate between building a host to "full" specifications or to a stripped-down "minimal" spec during remote installation, and thus, the isMinimal
boolean flag was the first configVars
variable. As the contents of configVars
grew, it continued to serve a useful purpose but in a manner that was increasingly too static. Aside from a few flags, the rest of the variables were effectively global constants dictating the entirety of the nix-config. The desire for a more elegant solution led to a significant refactoring away from configVars
towards an option-based nix module we call hostSpec
.
The approach we landed on is certainly not the only solution. We hope that by providing the context of what we've done along with some examples of how it streamlines nix-config will help you on your own journey, whether that be using what we've done or inspriring an idea that better fits your own mental models or environment.
Much of the work to establish the module was completed by fidgetingbits with additional input, refinement, and of course this documentation, by myself. The result is a module of options that can be enabled and customized on a per-host basis.
The refactor to hostSpec
also introduced support for hosts that run Darwin instead of NixOS, which is where we'll start because it necessitated some anatomical changes to the nix-config.
Why complicate the structure with Darwin?
The first question may actually be, "wtf is Darwin"? In brief, it is Apple's open source operating system that makes up the core of macOS. You can learn more at www.PureDarwin.org.
I was hesitant about muddying my config with 'support' for darwin-based hosts because I don't own a Mac anymore. I'm frankly not a fan of Apple products for many reasons that I won't get into but I have to admit that Hexley, Darwin's platypus mascot, is pretty darn cute so the decision was made. Seriously though, I know there are a lot of people who use Apple hardware who are also interested in using nix and I want nix to succeed. There are also annoying situations where employers force their employees to run monitoring software on their devices. Most of that software will only run on Windows or macOS (if you're lucky). Given the option to pick between the two poisons, I'd opt for the latter and make the best of it by running nix-darwin to placate the policy authorities. If I do end up in that situation again, future me will be thankful that past me added support.
Structural Enhancements
One of the main challenges in supporting both Darwin and NixOS is that not everything that runs on NixOS will run for Darwin. We are faced with scenarios where some of the config works for one, or the other, or both. To address this, we added some platform-specific directories and, as you'll see in the section about changes to hosts/common/core
, some platform-specific .nix
files as well.
To help facilitate distinguishing between linux and darwin configs in our code, we've added two directories that house the respective host configurations:
nix-config/hosts/linux
nix-config/hosts/darwin
All of the host directories that would have been stored in hosts
are now housed in their respective platform directory.
For our custom modules, we've moved added nix-config/modules/common
and nix-config/modules/hosts/
, in which we've moved the existing nixos
directory. The common modules directory was added specifically to house the new hostSpec
module but the yubikey
module was also relocated there. There will no doubt be additional modules in the future that will apply to both nixos and darwin.
Lastly, the introduction of the hostSpec
module eliminates the use of configVars
and therefore the vars
directory is no longer needed.
nix-config
├── checks
├── docs
├── home
├── hosts
│ ├── common
-│ ├── ghost
-│ ├── grief
-│ ├── guppy
-│ └── gusto
+│ ├── darwin
+│ └── nixos
+│ ├── ghost
+│ ├── grief
+│ ├── guppy
+│ └── gusto
├── lib
├── modules
+│ ├── common
-│ ├── home-manager
-│ └── nixos
+│ ├── home
+│ └── hosts
+│ ├── common
+│ ├── darwin
+│ └── nixos
├── nixos-installer
│ └── iso
├── overlays
├── pkgs
├── scripts
-└── vars
To reflect these changes visually, the Nix-Config anatomy diagram has been overhauled to provide a more readable conceptual flow.
The hostSpec Module
The hostSpec
module itself takes the variables from the old configVars
, along with some new ones, and makes them into proper options using lib.mkOption
.
nix-config/modules/commmon/hostSpec.nix
--------------------
# Specifications For Differentiating Hosts
{
config,
pkgs,
lib,
...
}:
{
options.hostSpec = {
# Data variables that don't dictate configuration settings
username = lib.mkOption {
type = lib.types.str;
description = "The username of the host";
};
hostName = lib.mkOption {
type = lib.types.str;
description = "The hostname of the host";
};
email = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
description = "The email of the user";
};
work = lib.mkOption {
default = { };
type = lib.types.attrsOf lib.types.anything;
description = "An attribute set of work-related information if isWork is true";
};
networking = lib.mkOption {
default = { };
type = lib.types.attrsOf lib.types.anything;
description = "An attribute set of networking information";
};
wifi = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Used to indicate if a host has wifi";
};
domain = lib.mkOption {
type = lib.types.str;
description = "The domain of the host";
};
userFullName = lib.mkOption {
type = lib.types.str;
description = "The full name of the user";
};
handle = lib.mkOption {
type = lib.types.str;
description = "The handle of the user (eg: github user)";
};
home = lib.mkOption {
type = lib.types.str;
description = "The home directory of the user";
default =
let
user = config.hostSpec.username;
in
if pkgs.stdenv.isLinux then "/home/${user}" else "/Users/${user}";
};
persistFolder = lib.mkOption {
type = lib.types.str;
description = "The folder to persist data if impermenance is enabled";
default = "";
};
# Configuration Settings
isMinimal = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Used to indicate a minimal host";
};
isProduction = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Used to indicate a production host";
};
isServer = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Used to indicate a server host";
};
isWork = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Used to indicate a host that uses work resources";
};
# Sometimes we can't use pkgs.stdenv.isLinux due to infinite recursion
isDarwin = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Used to indicate a host that is darwin";
};
useYubikey = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Used to indicate if the host uses a yubikey";
};
voiceCoding = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Used to indicate a host that uses voice coding";
};
isAutoStyled = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Used to indicate a host that wants auto styling like stylix";
};
useNeovimTerminal = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Used to indicate a host that uses neovim for terminals";
};
useWindowManager = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Used to indicate a host that uses a window manager";
};
useAtticCache = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Used to indicate a host that uses LAN atticd for caching";
};
hdr = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Used to indicate a host that uses HDR";
};
scaling = lib.mkOption {
type = lib.types.str;
default = "1";
description = "Used to indicate what scaling to use. Floating point number";
};
};
config = {
assertions =
let
# We import these options to HM and NixOS, so need to not fail on HM
isImpermanent =
config ? "system" && config.system ? "impermanence" && config.system.impermanence.enable;
in
[
{
assertion =
!config.hostSpec.isWork || (config.hostSpec.isWork && !builtins.isNull config.hostSpec.work);
message = "isWork is true but no work attribute set is provided";
}
{
assertion = !isImpermanent || (isImpermanent && !("${config.hostSpec.persistFolder}" == ""));
message = "config.system.impermanence.enable is true but no persistFolder path is provided";
}
];
};
}
As you can see there several isFoo
flags, including the original isMinimal
that can be used throughout nix-config to dynamically assign expressions based on differing hostSpec
declarations.
The home
option dynamically sets the path of the primary user's home directory based on the host's platform, allowing us to removed every instance of homeDirectory = if pkgs.stdenv.isLinux then "/home/${configVars.username}" else "/Users/${configVars.username}";
that was previously littered throughout the nix-config.
Some of the options available in hostSpec
aren't currently leveraged but were created in anticipation of scenarios we want to handle down the road. Examples of this include isAutoStyled
, useNeovimTerminal
, and useWindowManager
. Over time we'll build out logic throughout the nix-config to make use of them and more. Granted there is some added complexity in this approach but the goal is a personalized configuration for multiple hosts where the vast majority of configuration effort for adding a new host occurs by declaring hostSpec
options.
hostSpec in Action
With the defaults defined in the module, many host configs will only need to set a few options. For example, here is the relevant section of hostSpec
options for the updated minimal-configuration.nix
:
nix-config/nixos-installer/minimal-configuration.nix
--------------------
# ...
hostSpec = {
isMinimal = lib.mkForce true;
isProduction = lib.mkForce true;
hostName = "installer";
username = "ta";
};
# ...
The set specifies the host and primary user names while setting flags to indicate that the config is a non-production, minimal configuration.
For a daily-driver example, here are the relevant sections for my main deck, "ghost".
nix-config/hosts/linux/ghost/default.nix
--------------------
# ...
hostSpec = {
hostName = "ghost";
useYubikey = lib.mkForce true;
hdr = lib.mkForce true;
};
# ...
As you can see, we indicate that ghost will use Yubikeys and its monitors support HDR. The obvious omission here, compared to the minimal-configuration, is username
. Unlike the minimal-configuration, a "full specification" host imports host/common/core
, so many of the hostSpec
options, such as my primary user info, can be declared in core along with some private values from nix-secrets. This way all of the hostSpec
options that apply across all of my hosts can be conveniently modified where needed.
There's a bit more going on in this snippet but we'll go through it.
nix-config/hosts/common/core/default.nix
--------------------
# ...
let
platform = if isDarwin then "darwin" else "nixos";
platformModules = "${platform}Modules";
in
{
imports = lib.flatten [
inputs.home-manager.${platformModules}.home-manager
inputs.sops-nix.${platformModules}.sops
(map lib.custom.relativeToRoot [
"modules/common"
"modules/hosts/${platform}"
"hosts/common/core/${platform}.nix"
"hosts/common/core/sops.nix"
"hosts/common/users/${primaryUser}"
"hosts/common/users/${primaryUser}/${platform}.nix"
])
];
#
# ========== Core Host Specifications ==========
#
hostSpec = {
username = "ta";
handle = "emergentmind";
inherit (inputs.nix-secrets)
domain
email
userFullName
networking
;
};
# ...
We'll come back to what's going on with the imports
attribute under the next heading but first let's talk about the hostSpec
options that are declared here. Of particular note, we inherit some attributes from inputs.nix-secrets
. The flake.nix
file in my private nix-secrets
repo declares many attributes that house private data such as my personal email and networking information. These "soft secrets" aren't sensitive enough to bother encrypting in my nix-secrets/sops/shared.yaml
file using sops. However, storing them in the private repo and then inheriting them to hostSpec
allows the data to be used throughout nix-config without exposing it publicly. This way we can do things like, foo = hostSpec.networking.subnets.<hosts>.ip
for example. It may be worth noting that the same thing was done previously with configVars
but I don't think I covered how to achieve any of it in my previous content.
Example of Referencing hostSpec Options
The nix-config/home/ta/common/optional/development
module of my nix-config provides several examples of hostSpec
options being used dynamically.
nix-config/home/ta/common/optional/development/default.nix
--------------------
{
inputs,
config,
lib,
pkgs,
...
}:
let
publicGitEmail = config.hostSpec.email.gitHub;
sshFolder = "${config.home.homeDirectory}/.ssh";
publicKey =
if config.hostSpec.useYubikey then "${sshFolder}/id_yubikey.pub" else "${sshFolder}/id_manu.pub";
privateGitConfig = "${config.home.homeDirectory}/.config/git/gitconfig.private";
workEmail = inputs.nix-secrets.email.work;
workGitConfig = "${config.home.homeDirectory}/.config/git/gitconfig.work";
workGitUrlsTable = lib.optionalAttrs config.hostSpec.isWork (
builtins.listToAttrs (
map (url: {
name = "ssh://git@${url}";
value = {
insteadOf = "https://${url}";
};
}) (lib.splitString " " inputs.nix-secrets.work.git.servers)
)
);
in
{
# ...
programs.git = {
userName = config.hostSpec.handle;
userEmail = publicGitEmail;
extraConfig = {
# ...
url = lib.optionalAttrs config.hostSpec.isWork (
lib.recursiveUpdate {
"ssh://git@${inputs.nix-secrets.work.git.serverMain}" = {
insteadOf = "https://${inputs.nix-secrets.work.git.serverMain}";
};
} workGitUrlsTable
);
# ...
};
# ...
home.file."${privateGitConfig}".text = ''
[user]
name = "${config.hostSpec.handle}"
email = ${publicGitEmail}
'';
home.file."${workGitConfig}".text = ''
[user]
name = "${config.hostSpec.userFullName}"
email = "${workEmail}"
'';
}
As you can see, hostSpec
is accessed through config
and used to populate values related to the primary user, the specific host being built, and some soft secrets from nix-secrets that hostSpec
inherited. The values include information such as email.gitHub
, email.work
, and handle
. The useYubikey
and isWork
flags are used to conditionally configure options that are only applicable when those flags are true
in the relevant hostSpec
declaration.
Other examples can be found by searching for "hostSpec." in my nix-config repository.
Dynamic Common Core
Now let's get back to imports
in the snippet above. You'll note that let
binds determine the platform and the name of the platform modules (either darwinModules
or nixosModules
) used by both the home-manager
and sops-nix
inputs. These are all used in the imports
call to dynamically import the approprirate custom modules directory, the specific modules within core (either nixos.nix
or darwin.nix
), and a platform-specific user config file for the primary user.
This is initially, admittedly a bit of a pain to establish because it splits the single default.nix
files, within core/
and users/
, into three separate files: default.nix
, nixos.nix
, darwin.nix
. Any options that apply to both platforms stay in default.nix
while options that are platform specific go in their respective platform files.
It's worth noting that for anyone who is not going to use two platforms, you can simply keep the platform-specific files as empty expressions. They'll be required this way though to prevent errors on flake checks or rebuilds. In fact, most of my darwin.nix
files are "blank"/empty expressions at the moment; they'll be conveniently waiting for me if the time comes. If you are certain you'll only ever use NixOS, you could just keep everything in default.nix
. However, with the existing structure in place, you can set yourself up for a little bit for future possibilities.
A similar splitting of options occurs at the home level, in home/[username]/common/core/
, however in this case, there are only platform-specific files and not directories.
nix-config/home/ta/common/core/default.nix
--------------------
# ...
let
platform = if hostSpec.isDarwin then "darwin" else "nixos";
in
{
imports = lib.flatten [
(map lib.custom.relativeToRoot [
"modules/common/host-spec.nix"
"modules/home"
])
./${platform}.nix
./zsh
./nixvim
./bash.nix
./bat.nix
./direnv.nix
./fonts.nix
./git.nix
./kitty.nix
./screen.nix
./ssh.nix
./zoxide.nix
];
# ...
As you can see, we set a let
bind based on the isDarwin
flag from hostSpec
and then import the relevant ${platform}.nix
file along with all of the other core home-manager files.
Streamlining flake.nix
An added benefit of using hostSpec
is we can do away with editing our flake.nix
file for each host. We moved inputs
to the bottom of the file, as they are the least often referenced portion of the file, and removed the host-specific declarations from outputs
so that instead, a set of functions import or read the relevant parent directories elsewhere in the nix-config.
For comparison, here is a stripped-down version of the old file:
nix-config/flake.nix - old version
--------------------
{
description = "EmergentMind's Nix-Config";
inputs = {
#################### Official NixOS and HM Package Sources ####################
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
nixpkgs-stable.url = "github:NixOS/nixpkgs/nixos-24.05";
nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixos-unstable";
hardware.url = "github:nixos/nixos-hardware";
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs-stable";
};
#################### Utilities ####################
# Declarative partitioning and formatting
disko = {
url = "github:nix-community/disko";
inputs.nixpkgs.follows = "nixpkgs";
};
# Secrets management. See ./docs/secretsmgmt.md
sops-nix = {
url = "github:mic92/sops-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
# vim4LMFQR!
nixvim = {
#url = "github:nix-community/nixvim/nixos-24.05";
#inputs.nixpkgs.follows = "nixpkgs";
url = "github:nix-community/nixvim";
inputs.nixpkgs.follows = "nixpkgs-unstable";
};
pre-commit-hooks = {
url = "github:cachix/git-hooks.nix";
inputs.nixpkgs.follows = "nixpkgs";
};
#################### Personal Repositories ####################
# Private secrets repo. See ./docs/secretsmgmt.md
# Authenticate via ssh and use shallow clone
nix-secrets = {
url = "git+ssh://git@gitlab.com/emergentmind/nix-secrets.git?ref=main&shallow=1";
inputs = { };
};
};
outputs =
{
self,
nixpkgs,
home-manager,
stylix,
...
}@inputs:
let
inherit (self) outputs;
forAllSystems = nixpkgs.lib.genAttrs [
"x86_64-linux"
#"aarch64-darwin"
];
inherit (nixpkgs) lib;
configVars = import ./vars { inherit inputs lib; };
configLib = import ./lib { inherit lib; };
specialArgs = {
inherit
inputs
outputs
configVars
configLib
nixpkgs
;
};
in
{
# Custom modules to enable special functionality for nixos or home-manager oriented configs.
nixosModules = import ./modules/nixos;
homeManagerModules = import ./modules/home-manager;
# Custom modifications/overrides to upstream packages.
overlays = import ./overlays { inherit inputs outputs; };
# Custom packages to be shared or upstreamed.
packages = forAllSystems (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
import ./pkgs { inherit pkgs; }
);
checks = forAllSystems (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
import ./checks { inherit inputs system pkgs; }
);
# Nix formatter available through 'nix fmt' https://nix-community.github.io/nixpkgs-fmt
formatter = forAllSystems (system: nixpkgs.legacyPackages.${system}.nixfmt-rfc-style);
# ################### DevShell ####################
#
# Custom shell for bootstrapping on new hosts, modifying nix-config, and secrets management
devShells = forAllSystems (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
checks = self.checks.${system};
in
import ./shell.nix { inherit checks pkgs; }
);
#################### NixOS Configurations ####################
#
# Building configurations available through `just rebuild` or `nixos-rebuild --flake .#hostname`
nixosConfigurations = {
# Main
ghost = lib.nixosSystem {
inherit specialArgs;
modules = [
stylix.nixosModules.stylix
home-manager.nixosModules.home-manager
{ home-manager.extraSpecialArgs = specialArgs; }
./hosts/ghost
];
};
# Qemu VM dev lab
grief = lib.nixosSystem {
inherit specialArgs;
modules = [
home-manager.nixosModules.home-manager
{ home-manager.extraSpecialArgs = specialArgs; }
./hosts/grief
];
};
# Qemu VM deployment test lab
guppy = lib.nixosSystem {
inherit specialArgs;
modules = [
home-manager.nixosModules.home-manager
{ home-manager.extraSpecialArgs = specialArgs; }
./hosts/guppy
];
};
# Theatre - ASUS VivoPC VM40B-S081M
gusto = lib.nixosSystem {
inherit specialArgs;
modules = [
stylix.nixosModules.stylix
home-manager.nixosModules.home-manager
{ home-manager.extraSpecialArgs = specialArgs; }
./hosts/gusto
];
};
};
};
}
And an example of the new flake.nix
:
nix-config/flake.nix
--------------------
{
description = "EmergentMind's Nix-Config";
outputs =
{ self, nixpkgs, ... }@inputs:
let
inherit (self) outputs;
inherit (nixpkgs) lib;
#
# ========= Architectures =========
#
forAllSystems = nixpkgs.lib.genAttrs [
"x86_64-linux"
"aarch64-darwin"
];
#
# ========= Host Config Functions =========
#
# Handle a given host config based on whether its underlying system is nixos or darwin
mkHost = host: isDarwin: {
${host} =
let
func = if isDarwin then inputs.nix-darwin.lib.darwinSystem else lib.nixosSystem;
systemFunc = func;
in
systemFunc {
specialArgs = {
inherit
inputs
outputs
isDarwin
;
# ========== Extend lib with lib.custom ==========
# NOTE: This approach allows lib.custom to propagate into hm
# see: https://github.com/nix-community/home-manager/pull/3454
lib = nixpkgs.lib.extend (self: super: { custom = import ./lib { inherit (nixpkgs) lib; }; });
};
modules = [ ./hosts/${if isDarwin then "darwin" else "nixos"}/${host} ];
};
};
# Invoke mkHost for each host config that is declared for either nixos or darwin
mkHostConfigs =
hosts: isDarwin: lib.foldl (acc: set: acc // set) { } (lib.map (host: mkHost host isDarwin) hosts);
# Return the hosts declared in the given directory
readHosts = folder: lib.attrNames (builtins.readDir ./hosts/${folder});
in
{
#
# ========= Overlays =========
#
# Custom modifications/overrides to upstream packages.
overlays = import ./overlays { inherit inputs; };
#
# ========= Host Configurations =========
#
# Building configurations is available through `just rebuild` or `nixos-rebuild --flake .#hostname`
nixosConfigurations = mkHostConfigs (readHosts "nixos") false;
darwinConfigurations = mkHostConfigs (readHosts "darwin") true;
better
#
# ========= Packages =========
#
# Add custom packages to be shared or upstreamed.
packages = forAllSystems (
system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ self.overlays.default ];
};
in
lib.packagesFromDirectoryRecursive {
callPackage = lib.callPackageWith pkgs;
directory = ./pkgs/common;
}
);
#
# ========= Formatting =========
#
#nixosModules = { inherit (import ./modules/nixos); };
#homeManagerModules = { inherit (import ./modules/home-manager); };
# Nix formatter available through 'nix fmt' https://nix-community.github.io/nixpkgs-fmt
formatter = forAllSystems (system: nixpkgs.legacyPackages.${system}.nixfmt-rfc-style);
# Pre-commit checks
checks = forAllSystems (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
import ./checks.nix { inherit inputs system pkgs; }
);
#
# ========= DevShell =========
#
# Custom shell for bootstrapping on new hosts, modifying nix-config, and secrets management
devShells = forAllSystems (
system:
import ./shell.nix {
pkgs = nixpkgs.legacyPackages.${system};
checks = self.checks.${system};
}
);
};
inputs = {
#
# ========= Official NixOS, Darwin, and HM Package Sources =========
#
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
nixpkgs-stable.url = "github:NixOS/nixpkgs/nixos-24.11";
nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixos-unstable";
hardware.url = "github:nixos/nixos-hardware";
home-manager = {
url = "github:nix-community/home-manager/release-24.11";
inputs.nixpkgs.follows = "nixpkgs";
};
nixpkgs-darwin.url = "github:nixos/nixpkgs/nixpkgs-24.11-darwin";
nix-darwin = {
url = "github:lnl7/nix-darwin";
inputs.nixpkgs.follows = "nixpkgs-darwin";
};
#
# ========= Utilities =========
#
disko = {
url = "github:nix-community/disko";
inputs.nixpkgs.follows = "nixpkgs";
};
sops-nix = {
url = "github:mic92/sops-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
nixvim = {
url = "github:nix-community/nixvim/nixos-24.11";
inputs.nixpkgs.follows = "nixpkgs";
};
# Pre-commit
pre-commit-hooks = {
url = "github:cachix/git-hooks.nix";
inputs.nixpkgs.follows = "nixpkgs";
};
#
# ========= Personal Repositories =========
#
# Private secrets repo. See ./docs/secretsmgmt.md
# Authenticate via ssh and use shallow clone
nix-secrets = {
url = "git+ssh://git@gitlab.com/emergentmind/nix-secrets.git?ref=main&shallow=1";
inputs = { };
};
};
}
As you can see, the "Host Configurations" section at the end of the old version has been replaced with two functions that read the contents of nix-config/hosts
and dynamically declare either darwinConfigurations
or nixosConfigurations
based on the appropriate platform.
For convenience, the relevant code snippet is:
# ...
let
# ...
mkHost = host: isDarwin: {
${host} =
let
func = if isDarwin then inputs.nix-darwin.lib.darwinSystem else lib.nixosSystem;
systemFunc = func;
in
systemFunc {
specialArgs = {
inherit
inputs
outputs
isDarwin
;
# ========== Extend lib with lib.custom ==========
# NOTE: This approach allows lib.custom to propagate into hm
# see: https://github.com/nix-community/home-manager/pull/3454
lib = nixpkgs.lib.extend (self: super: { custom = import ./lib { inherit (nixpkgs) lib; }; });
};
modules = [ ./hosts/${if isDarwin then "darwin" else "nixos"}/${host} ];
};
};
# Invoke mkHost for each host config that is declared for either nixos or darwin
mkHostConfigs =
hosts: isDarwin: lib.foldl (acc: set: acc // set) { } (lib.map (host: mkHost host isDarwin) hosts);
# Return the hosts declared in the given directory
readHosts = folder: lib.attrNames (builtins.readDir ./hosts/${folder});
in
# ...
#
# ========= Host Configurations =========
#
# Building configurations is available through `just rebuild` or `nixos-rebuild --flake .#hostname`
nixosConfigurations = mkHostConfigs (readHosts "nixos") false;
darwinConfigurations = mkHostConfigs (readHosts "darwin") true;
# ...
The functions effectively determine the platform based on the relevant nix-config/hosts/[platform]/
directories and then set up the host declarations accordingly. With these two functions in place, we're able to can focus our attention on the nix-config/hosts/[platform]/[hostname]/default.nix
files themselves when adding or editing hosts, with no need to manipulate flake.nix
.
Custom lib
Lastly, you'll notice that the mkHost
function above extends lib
with lib.custom
. Prior to this, our custom library was initially accessed via configLib
. This always felt a tad clunky, in part because it required passing in configLib
everywhere it was needed, often in addition to the builtin lib
. To make the solution a bit more elegant we opted to extend lib
itself so that our custom functions can be accessed using lib.custom.foo
.
Previously, an example usage looked something like the following:
{
inputs,
lib,
configLib,
config,
pkgs,
...
}:
{
imports = lib.flatten [
#
# ========== Hardware ==========
#
./hardware-configuration.nix
inputs.hardware.nixosModules.common-cpu-amd
inputs.hardware.nixosModules.common-gpu-amd
inputs.hardware.nixosModules.common-pc-ssd
#
# ========== Disk Layout ==========
#
inputs.disko.nixosModules.disko
(configLib.relativeToRoot "hosts/common/disks/ghost.nix")
# ...
Note the need for both lib
and configLib
in the arguments section. Then lib.flatten
is used to access flatten
while configLib
is used when calling our custom relativeToRoot
function on the last line.
By extending lib
with our custom library, we can now achieve the same result as follows:
{
inputs,
lib,
config,
pkgs,
...
}:
{
imports = lib.flatten [
#
# ========== Hardware ==========
#
./hardware-configuration.nix
inputs.hardware.nixosModules.common-cpu-amd
inputs.hardware.nixosModules.common-gpu-amd
inputs.hardware.nixosModules.common-pc-ssd
#
# ========== Disk Layout ==========
#
inputs.disko.nixosModules.disko
(lib.custom.relativeToRoot "hosts/common/disks/ghost.nix")
# ...
The result is streamlined access to the custom library functions.
Moving toward a more modular nix-like approach to the nix-config is the general intent of how some our roadmapped refactoring and feature additions will be designed. Arguably some of this could/should have been done from the beginning but the learning process along the way has been invaluable.
This article was updated on 2025-02-28 to correct some typos.