# A Nix flake that shares one module tree across NixOS, macOS and home-manager

> This personal system configuration manages the author's macOS, NixOS and Linux machines from a single flake built on flake-parts and a dendritic module pattern, with the home-manager configuration fully decoupled so the same tree can be applied to a plain Linux or macOS machine. It is MIT licensed, it is one person's dotfiles rather than a framework, and its SSH key and sops secrets workflow is the part that is genuinely hard to copy.

**kclejeune/system** — Declarative system configurations using nixOS, nix-darwin, and home-manager

- Repository: https://github.com/kclejeune/system
- Stars: 535 · Forks: 41
- Language: Nix
- License: MIT
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/kclejeune-system

## One flake, three host classes and a module tree

This repository manages system configurations for all of the author's macOS, NixOS and Linux machines, and the design is worth reading for the structure rather than for the content, because the content is one person's preferences.

The flake follows the dendritic pattern on top of flake-parts. The flake.nix file holds the inputs, the concrete host outputs, which are nixosConfigurations, darwinConfigurations and homeConfigurations, and the perSystem wiring for overlays, devShell, treefmt, pre-commit and checks.

The modules are pulled in recursively by vic/import-tree, which pulls in every .nix file under the modules directory, and each one self-registers as a reusable module body under flake.<class>Modules.<name>. Hosts then compose those modules by name.

The tree itself is laid out like this:

```
modules/
├── _lib.nix   # mkAspect helper (underscore-prefixed, skipped by import-tree)
├── shared/    # cross-class option modules + wiring
│               (primary-user, common-base, nixpkgs-wiring, identity, fonts)
├── nixos/     # flake.nixosModules.<name>
├── darwin/    # flake.darwinModules.<name>
├── home/      # flake.homeModules.<name>  (includes assets/ for non-Nix files)
└── profiles/  # identity profiles registered across all three classes
```

The prefix on _lib.nix is not decoration, it is the mechanism, since import-tree skips files whose name starts with a prefix character and that is how the helper stays out of the module registry.

The repository also holds secrets, a pkgs directory for custom packages, a terraform directory, a .sops.yaml, a flake.lock, a .stylua.toml for formatting Nix code, and an AGENTS.md and CLAUDE.md.

## Each module file registers itself and contains the whole body

The convention that makes the tree work is that a module file inlines its own body rather than importing a separate implementation file. The documentation gives the example: a file like modules/nixos/hyprland.nix both registers flake.nixosModules.hyprland and contains the full compositor configuration.

The consequence is that a module is one file you can read end to end, and there is no indirection between the name in a host's imports list and the settings it applies.

Cross-module references go through config.flake.<class>Modules.<name>, so the example given is that hm.imports = [ config.flake.homeModules.hyprland ] in the NixOS module is how the home-manager side of Hyprland is pulled in when Hyprland is enabled.

That last part is the interesting one. The NixOS module says nothing about home-manager directly; it references a home module through the shared registry, and the home module is registered under the home class. The two sides of a piece of software therefore live in their own trees and are joined at the point of use.

The non-Nix files that are not flake-parts modules live in secrets, in pkgs for custom packages, and in modules/home/assets for the dotfiles, nvim and yazi directories, which are referenced by source path from the corresponding home modules. So an editor configuration is a file in the tree and a module that points at it, rather than a string of file contents inside a Nix expression.

## NixOS and nix-darwin share their defaults, and home-manager shares nothing

The two operating system classes overlap deliberately. nix-darwin and NixOS share identical shell, user, fonts and packages setup via modules/nixos/default.nix and modules/darwin/default.nix, with the shared option declarations named as user and hm, and the nixpkgs wiring factored out into the modules/shared directory.

The shared directory holds five modules, listed in the tree comment: primary-user, common-base, nixpkgs-wiring, identity and fonts. The point of factoring them out is that a change to the package set or the font configuration lands on both operating systems at once, which is the behaviour you want when the two are meant to look the same.

The home-manager configuration takes the opposite approach and is entirely decoupled from NixOS and nix-darwin. All of its modules live in modules/home, and for each NixOS or darwin host they are pulled in through the flake-parts hm alias, which forwards config.hm.* to home-manager.users.${config.user.name}.*.

The same module tree is also exposed as homeConfigurations in flake.nix, fanned out across x86_64-linux, aarch64-linux and aarch64-darwin, so it is usable as a standalone configuration on any Linux or macOS machine through the home-manager CLI. That is the part of the design that makes the repository more than one person's dotfiles: the same home configuration applies to a machine running macOS, a machine running NixOS and a machine running plain Linux, and the operating system specific parts are the only difference.

The forwarding through the user name is worth noting, because it means the home configuration is bound to one user on a NixOS host, and the shared primary-user module is what supplies that name.

## Profiles, and why a profile spans three module classes

The user profile concept handles settings that depend on who you are rather than what you run.

Profiles live in modules/profiles, and the modules configure contextual, identity-specific settings such as SSL certificates or email addresses. The mechanism that makes them work across the three classes is the mkAspect helper from the _lib.nix module, and each profile is declared across all three module classes in a single file. The documentation notes that currently only personal.nix exists.

That is a small idea with real reach. A certificate or an email address is not a NixOS setting or a darwin setting or a home-manager setting in the abstract, it is a thing about you that needs to appear in all three places. Writing it once and having the aspect expand it into the right class avoids the usual three copies drifting apart.

The other things the identity profile carries are the credentials. The login password lives in secrets/users.yaml, and the repository sets mutableUsers to false, so there is no fallback password. That single setting is the reason the SSH host key step in the install procedure is not optional, and it is worth understanding before borrowing any of this.

## Bootstrapping a machine, and the part that bites

The install instructions cover three operating system paths, and one of them is where the difficulty lives.

For a non-NixOS machine the first step is the Nix package manager itself, installed with a single command:

```bash
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install --determinate
```

That step is skipped on NixOS, where nix is the package manager by default. For Darwin and Linux the repository is cloned into ~/.nixpkgs, and a new system is bootstrapped with the nh helper, which auto-detects the host and installs nix-darwin or home-manager:

```bash
git clone https://github.com/kclejeune/system ~/.nixpkgs
```

```bash
nix run .#nh -- darwin switch .#kclejeune@aarch64-darwin
```

The auto-detection can be overridden with --darwin or --home-manager if you need it.

NixOS hosts are provisioned remotely with nixos-anywhere, which partitions the disk from the host's disko config, builds the system locally and copies it over SSH. And that procedure has a dependency that the documentation is unusually explicit about: the host's SSH host key has to be pre-generated, because sops-nix decrypts secrets with it during nixos-install, and the login password lives in secrets/users.yaml with mutableUsers set to false, so there is no fallback. Without the key in place at install time the user ends up with no password.

## The recovery path, and what a FIDO2 key changes

There is a documented recovery procedure, and reading it tells you how the failure mode is understood.

If a host installed without its secrets, for example with no login password, you finish the first two steps, boot the installer again, and rerun the install step with --disko-mode mount and --phases disko,install,reboot. That opens and mounts the existing disk instead of reformatting it. The documentation is honest that the log still prints Formatting hard drive with disko, and that it only asks for the passphrase once, so a person watching the output has to know that the disk is not being destroyed.

After the first boot there is one more step, and it is a security improvement rather than a convenience. You enrol a FIDO2 key for the LUKS passphrase:

```bash
   sudo systemd-cryptenroll --fido2-device=auto /dev/disk/by-partlabel/disk-main-luks
```

So the sequence is: the SSH host key exists before the install so that the password secret can be decrypted, and once the machine is up a hardware key is enrolled so that the disk passphrase is not a human-typed secret on every boot.

There is one more detail about the installer itself. The minimal ISO has no NetworkManager, so Wi-Fi is joined with systemctl start wpa_supplicant and wpa_cli, a password is set with passwd, and the IP is noted so that the provisioning machine can copy an authorised key across with ssh-copy-id. And nixos-anywhere copies those authorised keys to root and reconnects as root, which is why key authentication is required and a password alone is not enough.

## Conclusion

Adopt this repository as a reference if you already live in Nix and want to see how one person wires NixOS, nix-darwin and home-manager together without duplicating modules, since the dendritic pattern and the decoupled home tree are the parts worth studying. Do not adopt it as a starting point if you are new to Nix, because the module system assumes you already know what an option, an overlay and a flake output are. Before you copy anything, check three things: that you understand the SSH host key and sops enrolment sequence, because a host installed without it ends up with no login password, that the module tree assumes a single primary user, and that the darwin and nixpkgs wiring is shared, so changing it affects every host at once.

## FAQ

### How does the kclejeune/system flake share modules between NixOS and macOS?

Through a dendritic module pattern on top of flake-parts, where vic/import-tree pulls in every .nix file under modules and each one self-registers as flake.<class>Modules.<name>. The shared setup lives in modules/shared, with the nixos and darwin default modules holding identical shell, user, fonts and packages configuration.

### How is the home-manager configuration kept separate from the system configuration?

All home modules live in modules/home and are pulled into each host through the flake-parts hm alias, which forwards config.hm.* to home-manager.users.${config.user.name}.*. The same tree is also exposed as homeConfigurations for x86_64-linux, aarch64-linux and aarch64-darwin, so it works standalone through the home-manager CLI.

### What has to happen before installing a NixOS host?

The host's SSH host key has to be pre-generated, because sops-nix decrypts secrets with it during nixos-install and the login password in secrets/users.yaml has no fallback with mutableUsers set to false. The key is then enrolled in sops and the file re-encrypted before the installer ISO is booted.

### How do I bootstrap a macOS or Linux machine from this configuration?

Install the Nix package manager with the Determinate Systems installer unless you are on NixOS, clone the repository into ~/.nixpkgs, and run nix run .#nh -- darwin switch .#kclejeune@aarch64-darwin. The nh helper auto-detects the host and installs nix-darwin or home-manager, with --darwin and --home-manager as overrides.

### What happens if a NixOS host is installed without its secrets?

The documented recovery is to finish the key generation and sops enrolment steps, boot the installer again, and rerun the install with --disko-mode mount and --phases disko,install,reboot, which unlocks and mounts the existing disk instead of reformatting it. The log still prints that the hard drive is being formatted, so the operator needs to know that.

## Sources

- [Issues](https://github.com/kclejeune/system/issues)
- [kclejeune/system on GitHub](https://github.com/kclejeune/system)
- [License: MIT](https://github.com/kclejeune/system/blob/master/LICENSE)
- [README](https://github.com/kclejeune/system/blob/master/README.md)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/kclejeune-system
