AGENTS.md — dots #
Kieran's NixOS/nix-darwin/home-manager dotfiles and homelab infrastructure.
Machine Inventory #
| Name | Type | Platform | Purpose |
|---|---|---|---|
atalanta |
nix-darwin | aarch64-darwin | Primary macOS workstation (Apple Silicon) |
terebithia |
NixOS | aarch64-linux | Main homelab server (ARM VPS, runs most services) |
prattle |
NixOS | x86_64-linux | Media + CI server (nixarr, garage, tangled spindle; root on bcachefs SSD) |
beef |
nix-darwin | aarch64-darwin | Build/compute server (M3 Max, Determinate Nix) |
iso-* |
NixOS ISO | x86_64 + aarch64 | Bootable install media |
Apply Commands #
# Darwin (atalanta, beef — run locally on that machine)
darwin-rebuild switch --flake .#atalanta
darwin-rebuild switch --flake .#beef
# NixOS (run locally on target or via deploy-rs)
nixos-rebuild switch --flake .#terebithia
nixos-rebuild switch --flake .#prattle
# Remote deploy via deploy-rs (from dev shell, uses Tailscale)
nix develop # enters shell with deploy-rs
deploy .#terebithia
deploy .#prattle
# nh helper (preferred on NixOS hosts themselves)
nh os switch
nh home switch
# Format all Nix files
nix fmt
# Build ISO
nix build .#packages.x86_64-linux.iso
nix build .#packages.aarch64-linux.iso
# Build and serve docs
nix build .#packages.aarch64-darwin.docs
nix run .#packages.aarch64-darwin.docs.serve
# Inspect services manifest (JSON)
nix eval --json .#services-manifest
Repo Layout #
flake.nix — single source of truth: all configurations, overlays, deploy nodes
machines/<name>/ — per-machine entry points (default.nix + home-manager.nix + home/)
modules/
nixos/
system/ — NixOS system modules (authentication, machine, wifi)
services/ — service modules, mostly using mkService factory
apps/ — NixOS-level app configs (tuigreet)
darwin/
defaults.nix — macOS defaults, the Lix pin, Darwin-only overlays
nix-cache.nix — prattle attic substituter (server Macs only)
home/
system/ — home-manager system (shell)
aesthetics/ — theming (Catppuccin), wallpapers
apps/ — home-manager app configs (helix, git, ghostty, etc.)
wm/ — window manager configs (yabai/skhd, hyprland)
shared/ — options shared across platforms (atelier.machine)
lib/
mkService.nix — service factory (see below)
lib/
services.nix — utilities for extracting service metadata
services-manifest.nix — generates the JSON manifest consumed by the docs/infra dashboard
secrets/
secrets.nix — agenix public key declarations
*.age — encrypted secrets
packages/ — custom derivations (bore-auth, docs, zmx, ghrpc)
ghrpc/ — Go source for the repo-creation CLI (cobra + fang + huh).
templates/ is embedded in the binary, so a new directory
there is a new scaffold; its template.json declares the
project type and one licenseSlot per thing that needs
licensing (hardware/firmware/docs), which the form asks
about and LICENSE.md is composed from
dots/ — raw config files (hyprland, yabai/skhd, wallpapers, scripts)
docs/ — mdBook source; built by nix build .#packages.*.docs
The atelier Namespace #
All custom options live under atelier.*. This is the house namespace — not upstream NixOS options.
Machine metadata (modules/shared/machine.nix):
atelier.machine = {
enable = true;
type = "server"; # or "client"
tailscaleHost = "hostname";
};
Setting enable = true makes the machine visible in the services manifest/dashboard.
Services (modules/lib/mkService.nix):
atelier.services.<name> = {
enable = true;
domain = "app.dunkirk.sh";
port = 3000;
secretsFile = config.age.secrets.<name>.path;
repository = "https://github.com/..."; # cloned once on first start
data.sqlite = "/var/lib/<name>/data/app.db"; # triggers WAL checkpoint + stop on backup
data.files = [ "/var/lib/<name>/uploads" ];
};
The factory auto-creates: systemd service, dedicated user/group, /var/lib/<name>/{app,data} dirs, Caddy virtualHost with Cloudflare DNS TLS, port-conflict assertion, sudo rules so the service user can restart itself (for CI deploys), and restic backup integration.
Services that don't fit the factory pattern (herald, bore/frps, tangled knot/spindle, n8n, minio) are handled as custom service modules with manual wiring, but they still appear in the manifest via hardcoded entries in lib/services.nix.
Adding a New Service #
- Create
modules/nixos/services/<name>.nix— either usemkServiceor write a custom module. - Register the secret in
secrets/secrets.nix(add the.ageentry withkierankpubkey). - Create the encrypted secret:
cd secrets && agenix -e <name>.age(agenix resolvessecrets.nixfrom the working directory). - In the target machine's
default.nix, add:age.secrets.<name> = { file = ../../secrets/<name>.age; owner = "<name>"; };atelier.services.<name>.enable = true;and remaining options
- Import the module (or rely on
import-treeif it auto-discovers frommodules/nixos/). - If Caddy is the proxy: the factory handles TLS automatically using Cloudflare DNS. Caddy needs
EnvironmentFilepointing to the cloudflare secret (already wired in terebithia'sdefault.nix).
Secrets Management #
- Tool: agenix, run from inside
secrets/:cd secrets && agenix -e <name>.age - Identity:
/Users/kierank/.ssh/id_rsa(Darwin) or/home/kierank/.ssh/id_rsa+/etc/ssh/id_rsa(NixOS) - All secrets encrypted to the single
kierankRSA key insecrets/secrets.nix - Secret files are referenced as
config.age.secrets.<name>.path— this is a runtime path, not a store path; only available after activation
Module Import Patterns #
Two patterns in use — know which machine uses which:
import-tree (terebithia, atalanta): Automatically imports all .nix files under a directory tree. Used as inputs.import-tree ../../modules/nixos or inputs.import-tree ../../../modules/home. Any .nix file dropped in those trees is automatically included — no manual import list needed.
Explicit imports (prattle): Individual imports = [ ... ] lists. Adding a module requires editing the machine's default.nix.
Home modules are not imported per machine. homeManagerModule in flake.nix puts import-tree ./modules/home (plus the catppuccin and spicetify home modules) into home-manager.sharedModules, so every user on every host has all of them available. They just won't activate without atelier.<module>.enable = true. A machine's home/*.nix only sets user details and enables features.
Modules under modules/ never take inputs #
Shared modules only see pkgs, lib, config and options, so they stay portable (see ysun's "specialArgs considered harmful"). Anything that needs a flake input is wired in flake.nix instead:
- A package from an input: add it to
overlays/default.nixand usepkgs.<name>. - An upstream module (catppuccin, spicetify, tangled knot/spindle): import it in
flake.nix(homeManagerModule'ssharedModules, or the host'smoduleslist). - Flake plumbing (registry,
nixPath):registryModuleinflake.nix.
specialArgs.inputs is still passed to the four real hosts, but only machine entry files (machines/<name>/default.nix) should use it. The ISOs no longer get it at all.
Overlays and Custom Packages #
overlays/default.nix is the one overlay for every platform. overlaysModule in flake.nix applies it (with NUR) to every configuration, Darwin included. It:
- Adds
pkgs.unstable(nixpkgs-unstable) — access unstable packages viapkgs.unstable.<name> - Adds
pkgs.zmx-binary,pkgs.drift-diff,pkgs.terminal-wakatime,pkgs.wakatime-ls,pkgs.spicePkgs,pkgs.impureeverywhere - Linux-only:
pkgs.bore-auth,pkgs.pear,pkgs.herald,pkgs.potluck,pkgs.lard,pkgs.tangle-of-trust, and the patched nix - Darwin-only: disables the
direnvtest suite and swaps in the patchedyabai
Formatting #
nix fmt runs nixfmt-tree (RFC-style formatter). This is the formatter; don't use nixfmt-rfc-style manually on individual files when the tree formatter is available. The formatter is set per-system in flake.nix:
formatter.x86_64-linux = nixpkgs.legacyPackages.x86_64-linux.nixfmt-tree;
formatter.aarch64-darwin = nixpkgs.legacyPackages.aarch64-darwin.nixfmt-tree;
CI/CD #
Servers (terebithia, prattle): .github/workflows/deploy.yaml — triggers on push to main, gated on which paths changed. Builds on an arch-matched GitHub runner (ubuntu-24.04-arm for terebithia), substituting our own packages from prattle's attic cache, then deploy-rs copies only the closure diff over Tailscale. Captures pre-deploy generations and rolls back the failing node in its own matrix leg. Docs build/publish follows a successful deploy.
Application services: .github/workflows/deploy-service.yml — SSH over Tailscale to service user, git pull, bun install, sudo systemctl restart <name>. Service users can restart their own service (sudoers rule from mkService).
ISOs: .github/workflows/build-iso.yml.
deploy.yaml uses the tag:deploy Tailscale tag; the reusable service workflow uses tag:ci. CI needs TS_OAUTH_CLIENT_ID, TS_OAUTH_SECRET, ATTIC_TOKEN, CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID as repo secrets.
Theming #
Global theme is Catppuccin Macchiato with green accent. The atelier.theming.enable option (from modules/home/aesthetics/theming.nix) wires catppuccin-nix across GTK, Qt (kvantum), qutebrowser, and cursor. The flavor and accent are hardcoded in that module — change there, not per-app.
Nix Version Note #
The Darwin machines run Lix (nix.package = pkgs.lixPackageSets.latest.lix, set in modules/darwin/defaults.nix) — except beef, which sets nix.enable = false and runs Determinate Nix. The NixOS servers run upstream CppNix, patched for NixOS/nix#12871 by the overlay in flake.nix.
Gotchas #
import-treeis greedy: every.nixfile inmodules/nixos/ormodules/home/is evaluated on machines that use it. A syntax error anywhere in those trees breaks the whole configuration for those machines.- Port conflicts are asserted:
mkServicechecks for port collisions across all enabled services and will failnixos-rebuildwith a clear message. Check existing ports before picking a new one. ExecStartPrewith!prefix:!only liftsUser=/Group=/SupplementaryGroups=; the mount-namespace sandbox (ProtectSystem,ReadWritePaths) still applies.+is the prefix that bypasses namespacing — don't reach for it. The script works becauseReadWritePathscoversdataDirand tmpfiles created it at activation.- agenix secrets are runtime paths:
config.age.secrets.<name>.pathresolves to something like/run/agenix/<name>. It's not a store path. Don't try to use it at build time. - Caddy TLS: all virtualHosts use Cloudflare DNS challenge (
dns cloudflare {env.CLOUDFLARE_API_TOKEN}). The token comes fromage.secrets.cloudflare, loaded viasystemd.services.caddy.serviceConfig.EnvironmentFile. This is already wired in terebithia — new services just need their virtualHost configured. - CI builds, the target does not:
--remote-buildwas dropped once runners were arch-matched, so the runner needs real build capacity. The dev shell (vianix develop) providesdeploy-rs. services.nixnixdoc format: functions inlib/services.nixuse nixdoc-compatible/**docstring syntax. Keep that format when adding functions there — it feeds the auto-generated docs.- zmx hosts and one-shot SSH: hosts with
atelier.ssh.hosts.<n>.zmx = true(and thet.*/p.*/e.*patterns) carry aRemoteCommandthat auto-attaches a zmx session, sossh <host> <cmd>,scpandrsyncwould normally fail withCannot execute command-line and remote command. AMatchblock cancels it wheneverZMX_OFF,AI_AGENT,CLAUDECODEorCRUSHis set, so agent shells already get plain SSH. Prefix withZMX_OFF=1if you ever see that error. For long remote work usessh <host> zmx run <session> <cmd>and read it back withssh <host> zmx history <session>.