Kieran's opinionated (and probably slightly dumb) nix config
dots AGENTS.md
13 kB · 200 lines
Markdown
at main

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 #

  1. Create modules/nixos/services/<name>.nix — either use mkService or write a custom module.
  2. Register the secret in secrets/secrets.nix (add the .age entry with kierank pubkey).
  3. Create the encrypted secret: cd secrets && agenix -e <name>.age (agenix resolves secrets.nix from the working directory).
  4. 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
  5. Import the module (or rely on import-tree if it auto-discovers from modules/nixos/).
  6. If Caddy is the proxy: the factory handles TLS automatically using Cloudflare DNS. Caddy needs EnvironmentFile pointing to the cloudflare secret (already wired in terebithia's default.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 kierank RSA key in secrets/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.nix and use pkgs.<name>.
  • An upstream module (catppuccin, spicetify, tangled knot/spindle): import it in flake.nix (homeManagerModule's sharedModules, or the host's modules list).
  • Flake plumbing (registry, nixPath): registryModule in flake.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 via pkgs.unstable.<name>
  • Adds pkgs.zmx-binary, pkgs.drift-diff, pkgs.terminal-wakatime, pkgs.wakatime-ls, pkgs.spicePkgs, pkgs.impure everywhere
  • 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 direnv test suite and swaps in the patched yabai

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-tree is greedy: every .nix file in modules/nixos/ or modules/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: mkService checks for port collisions across all enabled services and will fail nixos-rebuild with a clear message. Check existing ports before picking a new one.
  • ExecStartPre with ! prefix: ! only lifts User=/Group=/SupplementaryGroups=; the mount-namespace sandbox (ProtectSystem, ReadWritePaths) still applies. + is the prefix that bypasses namespacing — don't reach for it. The script works because ReadWritePaths covers dataDir and tmpfiles created it at activation.
  • agenix secrets are runtime paths: config.age.secrets.<name>.path resolves 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 from age.secrets.cloudflare, loaded via systemd.services.caddy.serviceConfig.EnvironmentFile. This is already wired in terebithia — new services just need their virtualHost configured.
  • CI builds, the target does not: --remote-build was dropped once runners were arch-matched, so the runner needs real build capacity. The dev shell (via nix develop) provides deploy-rs.
  • services.nix nixdoc format: functions in lib/services.nix use 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 the t.*/p.*/e.* patterns) carry a RemoteCommand that auto-attaches a zmx session, so ssh <host> <cmd>, scp and rsync would normally fail with Cannot execute command-line and remote command. A Match block cancels it whenever ZMX_OFF, AI_AGENT, CLAUDECODE or CRUSH is set, so agent shells already get plain SSH. Prefix with ZMX_OFF=1 if you ever see that error. For long remote work use ssh <host> zmx run <session> <cmd> and read it back with ssh <host> zmx history <session>.