# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## What this is A flake-based, modular NixOS configuration for a single personal server ("eurovm") running on Hetzner Cloud in Europe. There is no application code here — this repo *is* the infrastructure: editing a `.nix` file and deploying is the equivalent of shipping a change. ## Commands There is no local build/test/lint step in the traditional sense — the workflow is evaluate -> deploy against the live host. Nix itself must be installed locally with flakes enabled (`nix --version`). Check the flake evaluates without deploying: ```bash nix flake check ``` Deploy a change (from an x86_64-linux machine): ```bash nixos-rebuild switch --flake .#eurovm \ --target-host admin@ \ --elevate=sudo ``` Deploy from a non-x86_64-linux machine (e.g. Apple Silicon) — the server builds its own closure: ```bash nixos-rebuild switch --flake .#eurovm \ --target-host admin@ \ --build-host admin@ \ --elevate=sudo ``` First-time provisioning of a fresh VM (partitions the disk via disko, installs NixOS): ```bash nix run github:nix-community/nixos-anywhere -- \ --flake .#eurovm \ --build-on remote \ root@ ``` Edit encrypted secrets (re-encrypts on save): ```bash sops secrets/secrets.yaml ``` Add a new sops recipient (e.g. after rotating a key or adding a new machine) and re-encrypt: ```bash sops updatekeys secrets/secrets.yaml ``` Full first-deploy / bootstrap sequence (age key setup, DNS, secrets, provisioning, per-service first-login steps) is documented step by step in `README.md` — follow it rather than re-deriving the sequence from the module files. ## Architecture **Single flake, single host.** `flake.nix` defines exactly one `nixosConfigurations.eurovm` target, pinned to `system = "x86_64-linux"` (switch to `aarch64-linux"` for an ARM Hetzner instance). Inputs are `nixpkgs` (pinned to `nixos-26.05`), `disko`, and `sops-nix`. **Host assembly (`hosts/eurovm/`)** wires together hardware profile, disk layout, and every service module: - `default.nix` — imports everything, sets hostname, the `admin` user + its authorized SSH keys, passwordless sudo for wheel, `system.stateVersion`. - `hardware.nix` — Hetzner virtio/qemu-guest profile, GRUB (BIOS boot), network via DHCP on eth0. - `disko.nix` — declarative disk partitioning consumed by `nixos-anywhere` on first install: a 1MiB BIOS-boot partition, a 512MiB ext4 `/boot`, and the rest as ext4 `/`. **Service modules (`modules/`)** are one file per concern, each self-contained and imported by `hosts/eurovm/default.nix`: - `common.nix` — SSH hardening (key-only, no root login), firewall (22/80/443/53 only), timezone, nix settings (flakes, auto-gc, auto-optimise-store), base packages, disabled autoUpgrade. - `sops.nix` — sops-nix wiring. The host's own age key is derived at boot from `/etc/ssh/ssh_host_ed25519_key`, so no separate host key material needs to be provisioned. Declares which secrets exist, their owning user/group, and file mode. - `acme.nix` — an independent `security.acme` certificate for `dns.karanj.com`, used only by AdGuard's own DoH/DoT TLS listener (not by Caddy, which gets its HTTPS for the same domain through its own separate, built-in ACME client). Issued via HTTP-01 against a webroot that `caddy.nix` serves on port 80 for that one vhost. The cert directory is group-owned by `adguardhome` (`security.acme.certs..group`) so the service can read it without running as root, and `reloadServices` restarts `adguardhome` on renewal. - `caddy.nix` — the main thing that binds to 80/443 (see `adguard.nix` below for the one exception). Reverse-proxies each subdomain to a service's localhost port; the `dns.karanj.com` vhost additionally serves the ACME HTTP-01 webroot for `acme.nix`. Application ports (5006, 8080, 8086) are bound to `127.0.0.1` and never exposed directly — Caddy is the public HTTP(S) entry point for everything except AdGuard's encrypted-DNS ports, and handles Let's Encrypt automatically for its own certs. - `adguard.nix` — AdGuard Home; plain DNS on `0.0.0.0:53`, DNS-over-TLS on `0.0.0.0:853`, DNS-over-HTTPS on `0.0.0.0:8443` (path `/dns-query`), web UI on `0.0.0.0:3000` (port 3000 is never opened in the firewall, so it's still effectively internal-only, reached via Caddy). `http.address` binds `0.0.0.0` rather than `127.0.0.1` specifically because AdGuard's HTTPS/DoH listener reuses that same bind host internally (confirmed from AdGuard's source) — narrowing it back to loopback would silently make DoH unreachable. TLS for DoH/DoT uses the cert from `acme.nix`; `systemd.services.adguardhome.after`/`wants` on `acme-dns.karanj.com.service` ensures the cert exists before AdGuard's first start. Runs with `mutableSettings = false` (fully declarative config), which creates a specific problem: the module's own `ExecStartPre` overwrites the state file with a config containing a literal `REPLACED_AT_ACTIVATION` placeholder on every start. A second `ExecStartPre`, ordered with `lib.mkAfter`, substitutes in the real bcrypt hash from the sops secret immediately before `ExecStart`. It also uses a static (non-`DynamicUser`) system user, because sops-nix (and now the acme cert's group ownership) need a stable UID/GID to chown to during activation, while a `DynamicUser` only exists while its service is actually running. If you touch this module, preserve that ordering; it's the whole point of the file. - `miniflux.nix` — RSS reader; admin account seeded from a sops credentials file (`ADMIN_USERNAME` / `ADMIN_PASSWORD` format) on first run. Uses the NixOS module's own PostgreSQL integration. - `actual.nix` — Actual Budget via the native `services.actual` module (ships in this flake's pinned nixpkgs). No secrets involved; the server password is set interactively on first browser visit. - `cgit.nix` — git hosting. Three-way access split: web browsing is public/unauthenticated via cgit, `git clone`/`pull` over HTTPS is public/read-only via `git-http-backend` + fcgiwrap, and `git push` is SSH-only through a dedicated `git` system user with its own authorized keys. cgit browsing and git-http-backend are both served by the *same* localhost-only nginx vhost (127.0.0.1:8086) — nginx tells the two apart by matching the request path (a regex for `info/refs`/`git-upload-pack`), not by a separate port — and Caddy reverse-proxies everything for `git.karanj.com` straight to that one vhost. Only `git-upload-pack` is wired up, so push over HTTP is impossible regardless of nginx config; push stays SSH-only structurally, not just by convention. **Secrets (`secrets/secrets.yaml` + `.sops.yaml`)** — encrypted with sops using age keys. Three recipients: two personal keys (`einstein`, `galileo` — the user's two machines) plus the host's own key (`eurovm`, derived from its SSH host key), so the file can be decrypted both from a laptop and by the running server. When adding a new secret, declare it in `modules/sops.nix` (owner/group/mode/restartUnits) before referencing its `config.sops.secrets."".path` in a service module. ## Conventions to preserve - Every module takes only the NixOS module arguments it actually uses (`{ config, pkgs, lib, ... }` etc.) — don't pad the argument set. - New services follow the same shape: bind the app to `127.0.0.1:`, add a `virtualHosts` entry in `caddy.nix`, and if the service needs a persistent secret, declare it in `modules/sops.nix` first. - Comments in modules explain *why* a workaround exists (e.g. the adguard password-injection dance, the nginx shim in cgit) — keep that context when editing those files rather than stripping it.