The home directory looked like a mobile declarative laboratory. We had two flakes, seventy-five system packages, five sheets of high-powered elisp, a salt shaker half full of evil-mode keybindings, and a whole galaxy of multi-colored doom modules, LSP servers, formatters, linters... and also a quart of zellij, a quart of karabiner, a case of AI CLI tools, a pint of raw terraform and two dozen shell aliases. Not that we needed all that for the config, but once you get locked into a serious dotfiles collection, the tendency is to push it as far as you can.
Nix flake managing two targets from a single repo:
- darwwwin -- macOS workstation (aarch64-darwin) via nix-darwin
- nixxxos -- NixOS server on DigitalOcean (x86_64-linux)
| File | Purpose |
|---|---|
flake.nix |
Flake definition, system configs, helper scripts |
common.nix |
Shared packages and settings (both platforms) |
darwin.nix |
macOS-specific: homebrew, GUI apps, hostname |
nixos.nix |
NixOS-specific: SSH, firewall, Tailscale |
digitalocean.nix |
Disk/boot config for DO droplets |
infra/ |
Terraform (droplet + Tailscale keys) and provision/deploy scripts |
home.nix |
Home Manager: git, zsh, neovim, zellij, fzf, direnv |
nvim/ |
Neovim config (AstroNvim v5) |
doom/ |
Doom Emacs config (nix-doom-emacs-unstraightened) |
nushell/ |
Nushell config, fj command, and md sync |
zellij/ |
Terminal multiplexer config |
karabiner/ |
Keyboard remapping (caps lock -> ctrl/esc) |
Nushell is the primary shell. fj (also aliased as jf) unifies version
control and developer tools:
| Command | Routes to |
|---|---|
fj |
git status + gt ls |
fj ui |
gitui |
fj pr list, fj pr view 123 |
gh pr ... |
fj issue list, fj issue create |
gh issue ... |
fj mut, fj mut -a |
gt modify ... |
fj ss, fj create, fj sync, ... |
gt (graphite) |
fj check |
repo-specific checks |
| anything else | git |
Terraform-like plan/apply for syncing markdown files between source repos and an Obsidian vault.
| Command | Purpose |
|---|---|
fj md plan |
Compute a sync plan |
fj md diff |
Show unified diffs for planned changes |
fj md sync |
Apply a previously generated plan |
fj md |
Run plan + diff + apply in one step |
Config lives at ~/.config/mdaemon.nuon:
{ vault: "~/code/notes", orgs: ["~/code/st0x"] }darwin-rebuild switch --flake ~/.config
darwin-rebuild build --flake ~/.config
nixfmt *.nix- Shared (both platforms):
common.nix - macOS only:
darwin.nix - NixOS only:
nixos.nix
The nixxxos droplet is provisioned with Terraform and installed with
nixos-anywhere. Terraform also mints the Tailscale auth keys; NixOS joins the
tailnet declaratively. There is no resident agent service — run one manually over
the tailnet (ssh nixxxos, then fj clanker, which launches Pi; use fj clanker --claude to launch Claude Code with --remote-control for claude.ai / mobile; see
"Running an agent on the box" below).
Infra credentials live encrypted in infra/terraform.tfvars.age (DigitalOcean
token + Tailscale API key). Edit them with:
nix run .#tfVars # decrypt, edit in $EDITOR, re-encryptTerraform state (infra/terraform.tfstate) uses the default local backend and
is gitignored, but holds the generated secrets (DO token, Tailscale keys) in
plaintext — the intentional model for this personal repo. Keep it on an
encrypted disk; it is never committed.
Tailscale credentials all expire after 90 days: the API token and the two auth keys it mints. Rotate the API token, then re-mint the node key with
nix run .#tfApply -- -replace=tailscale_tailnet_key.nodeand re-seed it onto the box by rebuilding (nix run .#decommissionthennix run .#provision, since the node key is only staged at install time). Re-mint the CI key on the same cadence withnix run .#tfApply -- -replace=tailscale_tailnet_key.ciand copy the newtailscale_ci_authkeyoutput into theTS_AUTHKEYGitHub secret.
On the box, runtime secrets are plain root-only files under /var/lib/secrets/,
seeded at install time and persisted across rebuilds:
| File | Purpose |
|---|---|
/var/lib/secrets/tailscale.authkey |
Tailscale node key (auto-join on first boot) |
The node key is seeded only at install time (nixos-anywhere --extra-files)
and removed once tailscaled has joined and persisted its state, after which the
box stays on the tailnet across rebuilds without it.
nix run .#provision # stand up the box (idempotent) and connect
nix run .#decommission # tear the box down
# or via fj:
fj infra provision
fj infra decommissionprovision applies Terraform and, only when no box already exists in
terraform state, installs NixOS via nixos-anywhere (seeding
/var/lib/secrets/). An existing box is left intact — it is never -replaced —
so re-running provision is safe. Once the box is reachable over the tailnet,
provision SSHes in (over the tailnet, since public SSH is closed) as
-i ~/.ssh/dotconfig-nixos and attaches a zellij session named nixxxos; if it
isn't reachable yet it prints the manual connect command instead of hanging.
Because the node is untagged, disable key expiry for it in the Tailscale
admin console (Machines → nixxxos → Disable key expiry), or its default 180-day
expiry will drop it off the tailnet.
decommission runs terraform destroy (droplet + Tailscale auth keys) and
clears the local host-key pin. To rebuild from scratch: decommission then
provision; to change an existing box without recreating it, use the CD deploy.
A fresh install seeds the node auth key at install time (
nixos-anywhere --extra-files). If install fails partway (disk wiped, NixOS not yet activated), the box is left unbootable —decommissionthen re-runprovision.
There is no resident agent service; start Claude Code by hand over the tailnet. Sign in once with a claude.ai account (remote control needs OAuth, not an API key):
ssh nixxxos
claude /login # claude.ai account; required for remote controlThen launch it with fj clanker --claude. On the nixxxos host the Claude route
automatically adds --remote-control, so the session is drivable from claude.ai
and the Claude mobile app. Plain fj clanker launches Pi instead.
Every push to master runs nix flake check, builds the NixOS toplevel, and —
on a green build — deploys it to nixxxos over the tailnet
(.github/workflows/ci.yml). One-time setup:
- Generate a deploy keypair; put the public key in
keys.nixasciand the private key in the repo secretDEPLOY_SSH_KEY. - Put the CI Tailscale key in the repo secret
TS_AUTHKEY:The CI runner joins the tailnet as an untagged ephemeral node, so it gets the tailnet's default ACL (full access). To restrict it, define an ACL tag (e.g.terraform -chdir=infra output -raw tailscale_ci_authkey
tag:ci) in the Tailscale admin console and addtags = ["tag:ci"]totailscale_tailnet_key.ciininfra/main.tf— the tag must exist in the policy first orterraform applyrejects it. - Deploy once locally (
nix run .#provisionor a manualnixos-rebuild) so thecikey lands in the box's authorized_keys before CI first runs.
Optional hardening: set the
NIXXXOS_KNOWN_HOSTSrepo secret to nixxxos's SSH host key (ssh-keyscanit over the tailnet right after provision) to pin the host instead of trusting on first use. The deploy authenticates over the tailnet (WireGuard) regardless; pinning just makes it independent of tailnet ACL correctness. Without it the deploy uses trust-on-first-use every run (runners are ephemeral), so set it for ongoing hardening, not just once. A reprovision regenerates the host key, so refresh the secret afterwards or the pinned deploy will fail host-key verification. Until bothTS_AUTHKEYandDEPLOY_SSH_KEYare set, the deploy job skips cleanly rather than failing every push tomaster.
See CLAUDE.md at the repo root for coding guidelines, architecture details,
and workflow rules. Per-directory CLAUDE.md and README.md files provide
additional context for specific areas.
- README.md: user-facing docs at top, contributor summary at bottom
- CLAUDE.md: contributor and agent instructions
- Root-level files cover high-level guidelines; per-directory files cover concrete, detailed instructions for that area