Key Decisions¶
Record of architectural decisions and their rationale. Update when making new decisions.
Beszel hub SSH key: preStart copy to PocketBase data dir¶
Decision: Copy sops secret to /var/lib/beszel-hub/beszel_data/id_ed25519 via preStart, not to the working directory root.
Why: PocketBase (beszel's DB) looks for the SSH key in its DataDir, which is beszel_data/. Putting it at the service root doesn't work.
Beszel agent runs as user on workstations¶
Decision: DynamicUser=false, User=<username> on semi/dsd/mach.
Why: Rootless Docker socket is at /run/user/<uid>/docker.sock inside a 0700 directory. A dynamic user can't traverse it. The agent must run as the actual user.
Beszel agent on obox: PrivateUsers=false¶
Decision: Keep DynamicUser=true but set PrivateUsers=false on obox.
Why: System Docker socket access requires PrivateUsers=false. Rootless Docker isn't used on obox, so the agent can stay dynamic.
FileBrowser Quantum runs as root¶
Decision: Run as root on all hosts.
Why: User home directories on NixOS are 0700. Running as a specific user can't access other users' homes. Root sees everything. Acceptable because the service is Tailscale-only (not exposed publicly).
Caddy config is imperative¶
Decision: configFile = "/etc/caddy/Caddyfile" - editable on the box, no rebuild.
Why: Adding/removing routes shouldn't require a NixOS rebuild. Caddy reloads with systemctl reload caddy. Trade-off: config isn't declaratively tracked, but operational speed matters more here.
Sops split: office vs server¶
Decision: Two sops files - office.yaml (semi, dsd) and server.yaml (obox, mach). Both encrypted with the office age key.
Why: Different trust boundaries. Office machines share Juspay work context. Servers share infrastructure creds. Using the same key for both simplifies key management while keeping the secret sets separate.
Stirling PDF: no env-based admin¶
Decision: Configure admin via UI, not env vars. Removed sops creds for stirling.
Why: Stirling's env-based admin only works on first boot with an empty DB. If the DB exists, the env vars are ignored. Managing via UI is simpler and avoids stale credentials. Clear /var/lib/stirling-pdf/* to reset.
Excalidraw: deferred¶
Decision: Removed excalidraw + excalidraw-room containers. Deferred to later.
Why: Collaboration requires a separate WebSocket server (excalidraw-room), and REACT_APP_BACKEND_V2_WS_BASE_URL must be a public domain (browser-side React env var). Needs Caddy routes + DNS setup. Not worth the complexity right now.
MkDocs Material for docs¶
Decision: Use MkDocs Material (not custom HTML, not Jekyll, not Docusaurus).
Why: Docs are content-driven, not UI-driven. Material is a polished, production-grade theme with search, sidebar nav, dark mode - all from markdown. Building custom HTML for docs would be over-engineering. One config file + GitHub Action to deploy.
nix-wire for auto-wiring¶
Decision: Use nix-wire (personal library) instead of manual module/ host registration.
Why: Auto-imports modules and hosts from directory structure. hosts/nixos/<name>/default.nix → nixosConfigurations.<name> automatically. Eliminates boilerplate and keeps the flake.nix minimal (just inputs + mkFlake). Hostname is auto-set from directory name.
obox skips the base module¶
Decision: obox imports flakeModules.nix directly, not nixosModules.default.
Why: obox is a headless server. The base module pulls in stylix, home-manager default (with tons of fonts), and the cachyos kernel overlay - all unnecessary on a VPS. Importing just the nix settings keeps it lean.
Custom packages via overlay, not per-module¶
Decision: All custom packages in packages/ are auto-discovered and re-exposed via overlays/packages.nix into pkgs.
Why: One place to define, available everywhere as pkgs.<name>. No need to pass flake outputs around in modules. The overlay also handles opencode-vim patching (stale hashes + bun version) and appstream Darwin fix.
Standalone home-manager configs in hosts/home/¶
Decision: hosts/home/<user>.nix for standalone (non-NixOS) home-manager users.
Why: Some users (nikhil, admin) run home-manager on systems not managed by this flake (e.g., another NixOS config, or a foreign distro). These compose homeModules.default + homeModules.home-only (which adds flakeModules.nix for nix settings).
DeathChest over GraveSafe for Minecraft¶
Decision: Use DeathChest 1.5.7 instead of GraveSafe 1.0.0.
Why: GraveSafe duped armor - it collected getContents() (all 41 slots incl armor+offhand) then re-collected getArmorContents() + getItemInOffHand(), adding them twice. DeathChest doesn't have this bug.
obox services directory¶
Decision: obox service configs live in hosts/nixos/obox/services/ with a default.nix that auto-imports all service files.
Why: Keeps default.nix focused on host setup. Adding a new service means dropping a .nix file in services/ - no manual import list to maintain.
nix-wire autoImport for obox¶
Decision: default.nix uses flake.inputs.nix-wire.lib.autoImport ./. for host-level files (disk, hardware, services dir). services/default.nix uses autoImport ./. for service files.
Why: No manual import lists. Files are auto-discovered. Explicit flake module imports (tailscale, beszel, etc.) stay in the manual list since they come from flake outputs, not local files.
naste: package option with withPackages wrapper¶
Decision: naste modules expose a package option with a default set via a withPackages wrapper that applies the overlay internally.
Why: Consumers import the module and it just works - no need to add naste.overlays.default to their nixpkgs. The wrapper does pkgs.extend overlay and sets package = lib.mkDefault own.naste-server. Overrides are still possible via mkForce.
naste: per-scope metadata¶
Decision: Metadata stored per-scope: metadata/public/slug.json and metadata/private/slug.json.
Why: Shared metadata caused bugs when the same slug existed in both scopes - the last-saved metadata would make the other scope's paste invisible or serve it without auth. Per-scope metadata makes public and private pastes fully independent.
naste: let bindings inside config¶
Decision: let cfg = config.services.naste-server lives inside config = let ... in, not at the module function top level.
Why: When a module is wrapped by flake-parts and consumed via nix-wire, top-level let bindings force config evaluation before the module system finishes merging. This causes infinite recursion. Moving inside config makes it lazy.
naste: sops secrets need group + mode¶
Decision: sops secrets for naste set group = "naste" and mode = "0440". The service config also needs StateDirectory so systemd creates the data dir before sandbox setup.
Why: Default sops secrets are root:root 0400. The naste service user can't read them. ProtectSystem = "strict" was removed because it blocked sops-nix secret reads from /run/secrets/. StateDirectory ensures the data dir exists before ReadWritePaths namespace setup (otherwise systemd fails with 226/NAMESPACE).
naste: client in shared home module, creds per-host¶
Decision: modules/home/naste.nix enables programs.naste-client with endpoint only (no private creds). Hosts with sops add private.userFile/passFile in their user config.
Why: All users get the CLI client. Private credentials are host-specific (different sops files, different trust boundaries). Standalone home users get public paste access only.
vim-motions-pi with clipboard + escape sequence¶
Decision: Use a forked vim-motions-pi (feat/clipboard-and-escape branch) with OSC52 clipboard + jk escape.
Why: Upstream vim-motions-pi didn't support clipboard integration or custom escape sequences. The fork adds VIM_MOTION_PI_ESCAPE_SEQUENCE = "jk" and VIM_MOTION_PI_CLIPBOARD_COMMAND = copy for SSH-friendly clipboard.
AI module: removed openagents-control registry¶
Decision: Remove the openagents-control "developer" profile registry entirely. Sisyphus (oh-my-openagent) is the only agent used.
Why: The registry installed 131KB of agent prompts (openagent.md 30KB, opencoder.md 23KB, 9 subagents 78KB) that were never loaded since the user runs Sisyphus exclusively. This was ~33K tokens of dead context per session. oh-my-openagent provides its own complete agent system (Sisyphus, explore, librarian, oracle, metis, momus, etc.) which fully replaces the registry's agents.
AI module: MCP server conditional inclusion¶
Decision: Only include universally-needed MCP servers (git, fetch) globally. Work-specific servers (github, gitnexus, newton-hs-prod) are gated behind ndots.ai.mcp.workServers. Removed playwright, sequential-thinking, everything/filesystem, deepwiki, nixos from the global config entirely.
Why: Opencode spawns every configured MCP server as a child process on startup - it does not support lazy/eager lifecycle. 11 servers meant 8 node/python processes spawning before the TUI rendered. The everything filesystem server was redundant (opencode has its own read/glob/grep tools), sequential-thinking is rarely useful (the model can reason internally), and playwright is only needed for browser automation. These can be added per-project when needed.