# AGENTS.md Self-hosted home server configuration for a Raspberry Pi 4 (8 GB), managed entirely through NixOS. Services run as podman containers or native NixOS services under systemd. Remote access is via Cloudflare Tunnel; local access goes through Caddy with Let's Encrypt TLS (DNS-01, Cloudflare API). The original Kubernetes/Helm setup is preserved on the `main` branch. This branch (`nixos-port`) is the active NixOS port. --- ## Project Structure ``` flake.nix # Entry point — defines all hosts modules/ common.nix # Shared system config (nix, podman, sops, SSH) storage.nix # External HD mount + per-service directory layout caddy.nix # Caddy reverse proxy (DNS-01 ACME, forward_auth) cloudflared.nix # Cloudflare Tunnel for remote access backup.nix # Restic daily backups (S3 primary + manual offload) monitoring.nix # Prometheus + Grafana (native NixOS services) services/ openldap.nix # OpenLDAP — central identity provider authelia.nix # Authelia — SSO gateway + accessControlRules option gitea.nix # Gitea — Git server gitea-runner.nix # Gitea Actions runner nextcloud.nix # Nextcloud + PostgreSQL phpldapadmin.nix # phpLDAPadmin — LDAP web UI jellyfin.nix # Jellyfin — media server (disabled) transmission.nix # Transmission — torrent client (disabled) uptime-kuma.nix # Uptime Kuma + homey.monitoring.monitors option ntfy.nix # Ntfy — push notification server (native NixOS) mealie.nix # Mealie — recipe manager paperless.nix # Paperless-ngx — document management eurovote.nix # Eurovision Vote — Django voting app hosts/ pi-main/ default.nix # Service selection + host-specific overrides hardware.nix # Pi 4 boot, SD card labels, ARM platform secrets/ .sops.yaml # Age key configuration secrets.yaml # sops-encrypted secrets (commit only after encrypting) PORTING.md # Step-by-step migration guide from the old Helm setup ``` ## Services and URLs All services live under `zakobar.com`. | Service | URL | Auth | Runtime | |---------|-----|------|---------| | Authelia | `auth.zakobar.com` | Public (it is the auth portal) | container | | Gitea | `git.zakobar.com` | Gitea-native (LDAP) | container | | Nextcloud | `nextcloud.zakobar.com` | Nextcloud-native | container | | Mealie | `mealie.zakobar.com` | Mealie-native (LDAP) | container | | Paperless | `paperless.zakobar.com` | Authelia one_factor (SSO) | container | | phpLDAPadmin | `ldapadmin.zakobar.com` | Authelia two_factor, admins only | container | | Uptime Kuma | `uptime.zakobar.com` | Authelia two_factor, admins only | container | | Grafana | `grafana.zakobar.com` | Authelia two_factor, admins only | NixOS | | Ntfy | `ntfy.zakobar.com` | Bypass (ntfy token/password auth) | NixOS | | Eurovision Vote | `eurovision-vote.zakobar.com` | Authelia one_factor (`/admin` two_factor) | NixOS | | Jellyfin | `jellyfin.zakobar.com` | Jellyfin-native | container (disabled) | | Transmission | `torrent.zakobar.com` | Authelia two_factor, admins only | container (disabled) | ## Networking All containers join a private podman network named **`homey`**, created by the `podman-homey-network` systemd service in `common.nix`. This provides: - **DNS isolation** — containers reach each other by name (e.g. `openldap`, `nextcloud-postgres`) without being exposed on the host network. - **No port conflicts** — Caddy owns host ports 80/443; service containers map only to `127.0.0.1:`. - **Defence in depth** — even if the firewall were misconfigured, services are not bound to `0.0.0.0`. Native NixOS services (not containers) listen on `127.0.0.1` directly: | Service | Host port | |---------|-----------| | ntfy | 2586 | | Eurovision Vote | 8007 | | Prometheus | 9090 | | Grafana | 3002 | Container host-port mappings (all bound to `127.0.0.1`): | Container | Host port | Container port | |-----------|-----------|----------------| | openldap | 389 | 389 | | authelia | 9091 | 9091 | | gitea | 3000 | 3000 | | nextcloud | 8080 | 80 | | nextcloud-postgres | 5432 | 5432 | | phpldapadmin | 8081 | 80 | | uptime-kuma | 3001 | 3001 | | mealie | 9093 | 9000 | | paperless | 8083 | 8000 | | paperless-redis | (internal only) | 6379 | | jellyfin | 8096 | 8096 | | transmission | 9092 | 9091 | Inter-container communication uses container names on the `homey` network (e.g. authelia → `ldap://openldap:389`, nextcloud → `nextcloud-postgres:5432`). Caddy (running on the host) proxies via `127.0.0.1:`. ## Storage Layout All persistent data lives on the external HD at `/mnt/data/`: ``` /mnt/data/ openldap/ etc-ldap-slapd.d/ → /etc/ldap/slapd.d in container var-lib-ldap/ → /var/lib/ldap in container authelia/config/ → /config gitea/data/ → /data nextcloud/ html/ → /var/www/html db/ → /var/lib/postgresql/data db-dump/ → pg_dump output (pre-backup) jellyfin/config/ → /config media/movies|tvshows|... → shared media (read-only to jellyfin) transmission/config/ → /config uptime-kuma/ → /app/data mealie/data/ → /app/data paperless/ data/ → /usr/src/paperless/data (DB, index) media/ → /usr/src/paperless/media (document files) consume/ → /usr/src/paperless/consume (drop folder) export/ → /usr/src/paperless/export ntfy/ auth.db → ntfy user/token database (host path) cache.db → ntfy message cache (host path) attachments/ → file attachments (host path) restic-cache/ → restic local cache ``` Grafana and Prometheus use system state dirs (`/var/lib/grafana`, `/var/lib/prometheus2`) and are not backed up — dashboards are provisioned by Nix and metrics are ephemeral. The drive device path is set per-host in `hosts//default.nix` via `homey.storage.device`. Use a `/dev/disk/by-label/` or `/dev/disk/by-id/` path for stability. ## Build / Validate Commands ```bash # Check flake structure and evaluate all hosts (no build) nix flake check # Dry-run: show what would change without applying sudo nixos-rebuild dry-activate --flake .#pi-main # Apply configuration sudo nixos-rebuild switch --flake .#pi-main # Build without switching (e.g. cross-compile on workstation) nix build .#nixosConfigurations.pi-main.config.system.build.toplevel # Show diff between running system and new config nvd diff /run/current-system $(nix build --no-link --print-out-paths .#nixosConfigurations.pi-main.config.system.build.toplevel) ``` ## Secret Management Secrets are managed with [sops-nix](https://github.com/Mic92/sops-nix) and age keys. The encrypted `secrets/secrets.yaml` is committed to the repo; the age private key lives on the Pi at `/var/lib/sops-nix/key.txt`. ```bash # Edit secrets (decrypts, opens $EDITOR, re-encrypts on save) sops secrets/secrets.yaml # Encrypt a plaintext secrets.yaml for the first time sops --encrypt --in-place secrets/secrets.yaml # Add a new host key (after generating it on the new machine) # 1. Add the public key to secrets/.sops.yaml # 2. Run: sops updatekeys secrets/secrets.yaml # Generate a new age key on a host age-keygen -o /var/lib/sops-nix/key.txt age-keygen -y /var/lib/sops-nix/key.txt # print public key ``` Secrets that must come from the old deployment (see `PORTING.md` for how to extract them from the old k8s cluster): - `openldap/admin_password`, `openldap/config_password`, `openldap/ro_password` - `gitea/admin_password` - `nextcloud/admin_password`, `nextcloud/postgres_password` Everything else (authelia JWT/session/encryption keys, gitea JWT tokens, restic password, Cloudflare tokens) can be generated fresh. ## Code Style Guidelines ### Nix 1. **Module pattern** — every service is an opt-in module with an `enable` option (defaulting to `false` for optional services): ```nix options.homey.myservice.enable = lib.mkEnableOption "My service"; config = lib.mkIf config.homey.myservice.enable { ... }; ``` 2. **`homeyConfig` specialArgs** — top-level site config (domain, org name, timezone) is passed via `specialArgs` in `flake.nix` and accessed as `homeyConfig` in every module. Do not hardcode domain/org strings. 3. **No secrets in the Nix store** — secrets are always read from sops-managed files at runtime, never embedded in the built config. Use `config.sops.secrets."key".path` to get the runtime path of a secret file. 4. **Secret injection pattern** — because `oci-containers` `environmentFiles` is limited, use a `systemd ExecStartPre` script to write an ephemeral env file at `/run/-secrets.env` and reference it via `environmentFiles`. Clean it up in `postStop`. 5. **`--network=homey`** — all containers join the private `homey` podman network. Inter-container traffic uses container names as hostnames; host access is via explicit `ports` mappings to `127.0.0.1:`. 6. **Systemd ordering** — always express `after`/`requires` dependencies explicitly. The external HD mount unit is `mnt-data.mount`; containers that need storage must depend on it. ### Module Contribution Options Several cross-cutting concerns are wired up via list options that any service module can append to, rather than editing central files: | Option | Declared in | Purpose | |--------|-------------|---------| | `homey.caddy.virtualHosts` | `caddy.nix` | Add a reverse-proxy vhost | | `homey.storage.extraDirs` | `storage.nix` | Create tmpfiles dirs on the HD | | `homey.backup.extraPaths` | `backup.nix` | Include a path in restic backups | | `homey.monitoring.monitors` | `uptime-kuma.nix` | Add an Uptime Kuma HTTP monitor | | `homey.authelia.accessControlRules` | `authelia.nix` | Add Authelia access-control rules | Each service module declares its own entries. No central file edits needed. **`homey.authelia.accessControlRules`** — each rule has: - `priority` (int) — lower = earlier in the list. Authelia stops at the first match, so more-specific rules (e.g. `subject: group:admins`) must precede their catch-all counterparts. Assigned priority ranges by category: - `0` — auth bypass (Authelia itself) - `10–19` — blanket bypasses (e.g. ntfy) - `20–49` — admin-only two_factor + deny pairs - `50–64` — open one_factor services - `65–79` — per-path rules (resources + subject combinations) - `domain` (list of strings) - `policy` — `bypass` | `one_factor` | `two_factor` | `deny` - `subject` (optional list) — e.g. `[ "group:admins" ]` - `resources` (optional list) — URL path regexes ### Adding a New Service 1. Create `modules/services/.nix` following the existing module pattern. 2. Import it in `flake.nix` (in the `modules` list inside `mkHost`). 3. Enable it in `hosts/pi-main/default.nix`. 4. Inside the module's `config = lib.mkIf cfg.enable { ... }` block: - **Caddy**: add `homey.caddy.virtualHosts = [{ subdomain = "…"; port = …; auth = true/false; }]` - **Storage**: add `homey.storage.extraDirs = [{ path = "…"; }]` for each HD directory - **Backup**: add `homey.backup.extraPaths = [ "${dataDir}/…" ]` - **Authelia**: add `homey.authelia.accessControlRules = [{ priority = …; domain = […]; policy = "…"; }]` - **Monitoring**: add `homey.monitoring.monitors = [{ name = "…"; url = "…"; interval = 60; }]` 5. Add any new secrets to `secrets/secrets.yaml` and document them. ### Updating or Regenerating Secrets ```bash # Edit the encrypted file — sops opens $EDITOR sops secrets/secrets.yaml # Copy updated secrets to the Pi and rebuild rsync secrets/secrets.yaml admin@pi-main:/path/to/homey/secrets/ ssh admin@pi-main 'sudo nixos-rebuild switch --flake /path/to/homey#pi-main' ``` ### Debugging Containers ```bash # List all running containers podman ps # Follow logs for a service journalctl -fu podman-authelia.service # Drop into a running container podman exec -it authelia sh # Restart a single service sudo systemctl restart podman-gitea.service # Check why a service failed to start systemctl status podman-openldap.service journalctl -u podman-openldap.service --since "5 min ago" ``` --- ## Outstanding TODOs These items are known gaps that need to be addressed before the setup is production-ready: - [ ] **`caddy.nix` — fix `vendorHash`**: The Caddy build with the Cloudflare DNS plugin uses `lib.fakeHash` as a placeholder. After the first `nix build`, replace it with the hash Nix reports in the error message. - [ ] **`monitoring.nix` — Grafana dashboard hash**: The Node Exporter Full dashboard `fetchurl` hash is a placeholder. Run: ```bash nix store prefetch-file --hash-type sha256 \ https://grafana.com/api/dashboards/1860/revisions/37/download ``` and replace the hash in `modules/monitoring.nix`. - [ ] **`secrets/secrets.yaml` — populate and encrypt**: Fill in all secret values, then run `sops --encrypt --in-place secrets/secrets.yaml` before committing. Secrets needed: - From old k8s deployment: openldap passwords, gitea/nextcloud passwords - Fresh: authelia JWT/session/encryption keys, gitea JWT tokens - New services: `uptime-kuma/admin_password`, `ntfy/admin_password`, `grafana/secret_key`, `ntfy/web_push_private_key` - Backup: `restic/s3_access_key_id`, `restic/s3_secret_access_key` - WiFi: `wifi/psk` - [ ] **Cloudflare Tunnel**: Create the tunnel in the Zero Trust dashboard, copy the tunnel token into secrets, and configure public hostnames for all enabled services. See `modules/cloudflared.nix` for details. - [ ] **Cloudflare Tunnel — add new services**: After the initial tunnel is set up, add public hostnames for: `uptime`, `ntfy`, `grafana`, `mealie`, `paperless`, `eurovision-vote`. - [ ] **Second machine**: When ready, add `hosts/pi-secondary/` and uncomment the `pi-secondary` entry in `flake.nix`. Services communicating cross-machine should reference the primary Pi's LAN IP instead of `127.0.0.1`. - [ ] **Jellyfin and Transmission**: Both modules exist but are disabled. Enable in `hosts/pi-main/default.nix` when ready: ```nix homey.jellyfin.enable = true; homey.transmission.enable = true; ``` - [ ] **Backup — offload script**: Write `scripts/offload-backup.sh` for manually copying snapshots to a local disk. Uses `restic copy` to clone from the S3 repo into a local restic repo. See `TODO.org` for design notes. ### Post-Pi first boot These items require the Pi to be built, flashed, and booted at least once. - [ ] **`secrets/.sops.yaml` — add Pi age key**: After generating the age key on the Pi (`age-keygen -o /var/lib/sops-nix/key.txt`), add the public key to `.sops.yaml` alongside the existing PGP key, then run `sops updatekeys secrets/secrets.yaml`. - [ ] **`hosts/pi-main/hardware.nix` — verify SD card labels**: The file assumes partition labels `NIXOS_SD` (root) and `FIRMWARE` (boot). Relabel after flashing if they differ, or update the `fileSystems` entries. - [ ] **Gitea LDAP auth**: After first start, configure LDAP authentication in Gitea's admin panel (Admin → Authentication Sources → Add LDAP source). Relevant settings: - Host: `127.0.0.1`, Port: `389`, Security: Unencrypted - Bind DN: `cn=readonly,dc=zakobar,dc=com` - User search base: `ou=users,dc=zakobar,dc=com` - [ ] **Nextcloud LDAP app**: After restoring the Nextcloud volume, verify the LDAP Users and Contacts app is still configured correctly (Admin → LDAP/AD Integration). - [ ] **Ntfy VAPID keys**: Generate Web Push keys on the Pi: ```bash sudo ntfy webpush keys ``` Set `homey.ntfy.webPushPublicKey` in `default.nix` and add the private key to sops as `ntfy/web_push_private_key`. - [ ] **Uptime Kuma monitors**: On first boot, `uptime-kuma-sync` will automatically create all monitors declared via `homey.monitoring.monitors`. Verify they appear correctly in the UI at `https://uptime.zakobar.com`. - [ ] **Paperless admin token (iOS Shortcut)**: After first start, generate a dedicated API token in the Paperless web UI (Profile → API Auth Token) for the iOS Shortcut upload flow. The `/api/documents/post_document/` path bypasses Authelia — the token is the only auth.