Nix configuration organized by clear axes: nix, os, client, and home.
The goal is predictable composition, minimal duplication, and explicit ownership of settings.
This repo models a host configuration as layered deltas:
nix/*defines cross-platform system foundation.os/<os>/*.nixdefines platform-specific behavior.clients/<client>/*.nixdefines client/role-specific system deltas.home/default.nixdefines user-level baseline, then imports OS/client home deltas.
Think of it as: foundation -> platform -> role -> user.
nix/owns shared system concerns:nix/core.nix: Nix settings + host identity baseline.nix/apps.nix: sharedenvironment.systemPackagesbaseline.nix/shell.nix: shared shell/env defaults.nix/sudoers.nix: shared sudoers assembly logic.
os/owns platform constraints and capabilities:os/<os>/system.nix,os/<os>/apps.nix,os/<os>/home.nix.- optional
os/<os>/overlays.nixfor platform-only overlay workarounds.
clients/owns host-role differences:clients/<client>/<os>.nixfor system deltas.clients/<client>/secrets.nixfor OpenBao secret mappings.clients/<client>/home.nixfor Home Manager deltas.
home/owns user-level reusable modules and files.
Rule: place code where its reason to change belongs.
inventory/hosts.nix is the source of truth for host metadata.
Each host entry provides identity and runtime facts (for example client, os, system, username, cores).
flake.nix reads inventory and derives outputs by OS:
darwinConfigurationsfor hosts withos = darwinnixosConfigurationsfor hosts withos = linuxhomeConfigurationsfor hosts withos = windows(WSL/Home Manager target)
Inventory attributes are passed via specialArgs, so modules can be parameterized without hardcoding host names.
Sudoers is assembled declaratively in nix/sudoers.nix from plain ASCII fragments:
- OS fragment:
os/<os>/files/etc/sudoers - optional client fragment:
clients/<client>/files/etc/sudoers
The merged result is written to /etc/sudoers.d/nix-${username}.
This keeps content editable as text while preserving declarative composition.
- Global user behavior:
home/*.nix(for examplehome/shell.nix,home/git.nix). - OS-specific user behavior:
os/<os>/home.nix. - Client-specific user behavior:
clients/<client>/home.nix.
home.file definitions are merged by target path; collisions only happen when the same destination is declared twice.
nix/bao-secrets.nix aggregates two scopes:
ststefanix.baoSystemSecrets: root/system service (systemd/launchd.daemons)ststefanix.baoUserSecrets: user service/agent (systemd --user/launchd.user.agents)
Use baoUserSecrets for files in ~ (for example ~/.config/sops/...).
Example host config (same schema on Linux and Darwin, split by scope):
{
ststefanix.baoUserSecrets = {
address = "https://bao.heldenzeit.net";
secrets = {
age_keys = {
bao_secret = "kv/data/ststefanix/age_keys";
# One OpenBao field contains the full keys.txt payload
bao_secret_key = "private_key";
destination = ".config/sops/age/keys.txt";
};
gh_token = {
bao_secret = "kv/data/dev/github";
bao_secret_key = "token";
};
};
};
# Typical Linux-style system secret example: private TLS key for nginx.
# (On Darwin you can also use baoSystemSecrets, but the concrete consumer differs.)
ststefanix.baoSystemSecrets = {
secrets = {
nginx_tls_key = {
bao_secret = "kv/data/web/nginx_tls";
bao_secret_key = "private_key";
destination = "/run/secrets/nginx/tls.key";
};
};
};
}Important:
baoUserSecretsuses~/.vault-token(OpenBao currently keeps Vault-compatible token helper naming; authenticate once as the inventory user viabao login)baoSystemSecretsuses/etc/bao.token(provide a separate root/system token)
Rendered secret files are owned by the account running the scope service: user-owned for baoUserSecrets, root-owned for baoSystemSecrets.
Model: one OpenBao field -> one rendered file.
Secrets are refreshed in intervals so that a change to the backend is automatically reflected locally. There is a 3m default that lives in the shared module, so Linux and Darwin behave the same unless a scope overrides it explicitly.
Static KV secrets are re-polled every 3m by default via staticSecretRenderInterval; override per scope if you need faster or slower propagation.
Scope override means setting the option on baoUserSecrets or baoSystemSecrets in the host module that owns that scope, for example:
{
ststefanix.baoUserSecrets.staticSecretRenderInterval = "1m";
ststefanix.baoSystemSecrets.staticSecretRenderInterval = "10m";
}The natural place for that override is usually clients/<client>/secrets.nix, because that file already owns the host-specific OpenBao secret mapping.
For an OS-wide default, place it in os/<os>/system.nix or another OS-level module that owns shared OpenBao behavior.
For baoUserSecrets, destination is a path relative to $HOME (for example .config/sops/age/keys.txt). If destination is set, OpenBao Agent still writes the physical file into the scope runtime secrets directory, and the module creates a symlink at destination.
Offline/reboot behavior:
- The host still boots if OpenBao is unreachable.
- OpenBao Agent services retry in the background, but restart attempts are throttled to once per hour.
- Existing rendered secret files remain on disk (last known value) until OpenBao becomes reachable again and the agent refreshes them.
- Startup diagnostics are written to the service logs (journald on Linux,
/var/log/bao-agent-system-secrets.logand~/Library/Logs/bao-agent-user-secrets.logon Darwin).
Use just as the primary entrypoint:
just update: update flake inputs.just build: build current host output from inventory.just diff: compare current generation with newly built result.just apply: switch to the built configuration for current host OS.just apply-nix-only: switch configuration without triggering Homebrew auto-update on Darwin.just gc: Show profile historyjust history: Wipe profiles older than 30 days and do a nix garbage-collectjust bao-login [args...]: authenticate to OpenBao and install/etc/bao.token.just bao-login-ststefa: convenience wrapper for my usual LDAP login.just refreshsecrets-system: refresh system-scoped OpenBao secrets (sudo). If nobaoSystemSecretsare configured for the current host, this is a no-op with an informational message.just refreshsecrets-user: refresh user-scoped OpenBao secrets (no sudo)just refreshsecrets: run both refresh targetsjust rollback: rollback one generation.
just resolves host OS from .#hostInventory.<hostname>.os.
This layout prefers explicit structure over implicit magic:
- fewer hidden couplings,
- easier refactors,
- host portability via inventory,
- clean boundaries between foundation, platform, role, and user settings.
This shows how the parts are connected together
graph LR
flake["flake.nix"]
hosts["inventory/hosts.nix"]
flake --> hosts
base["nix/{core,apps,shell}.nix + os/darwin/overlays.nix"]
home_default["home/default.nix"]
os_darwin["os/darwin/{system,apps,home}.nix"]
os_linux["os/linux/{system,apps,home}.nix"]
os_windows_home["os/windows/home.nix"]
clients_darwin["clients/{hudson,bwpm}/{common,darwin,home}.nix"]
clients_linux["clients/luna/{common,linux,home}.nix"]
clients_windows_home["clients/winni/home.nix"]
extra_darwin["os/darwin/system.nix -> merged sudoers fragments"]
hosts -->|"darwin hosts"| os_darwin
hosts -->|"linux hosts"| os_linux
hosts -->|"windows hosts (HM only)"| os_windows_home
hosts -->|"darwin hosts"| clients_darwin
hosts -->|"linux hosts"| clients_linux
hosts -->|"windows hosts (HM only)"| clients_windows_home
hosts --> base
hosts --> home_default
os_darwin --> extra_darwin