16 KiB
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:<port>. - 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:<host port>.
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/<name>/default.nix via
homey.storage.device. Use a /dev/disk/by-label/ or /dev/disk/by-id/
path for stability.
Build / Validate Commands
# 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 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.
# 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_passwordgitea/admin_passwordnextcloud/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
-
Module pattern — every service is an opt-in module with an
enableoption (defaulting tofalsefor optional services):options.homey.myservice.enable = lib.mkEnableOption "My service"; config = lib.mkIf config.homey.myservice.enable { ... }; -
homeyConfigspecialArgs — top-level site config (domain, org name, timezone) is passed viaspecialArgsinflake.nixand accessed ashomeyConfigin every module. Do not hardcode domain/org strings. -
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".pathto get the runtime path of a secret file. -
Secret injection pattern — because
oci-containersenvironmentFilesis limited, use asystemd ExecStartPrescript to write an ephemeral env file at/run/<service>-secrets.envand reference it viaenvironmentFiles. Clean it up inpostStop. -
--network=homey— all containers join the privatehomeypodman network. Inter-container traffic uses container names as hostnames; host access is via explicitportsmappings to127.0.0.1:<port>. -
Systemd ordering — always express
after/requiresdependencies explicitly. The external HD mount unit ismnt-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 pairs50–64— open one_factor services65–79— per-path rules (resources + subject combinations)
domain(list of strings)policy—bypass|one_factor|two_factor|denysubject(optional list) — e.g.[ "group:admins" ]resources(optional list) — URL path regexes
Adding a New Service
- Create
modules/services/<name>.nixfollowing the existing module pattern. - Import it in
flake.nix(in themoduleslist insidemkHost). - Enable it in
hosts/pi-main/default.nix. - 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; }]
- Caddy: add
- Add any new secrets to
secrets/secrets.yamland document them.
Updating or Regenerating Secrets
# 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
# 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— fixvendorHash: The Caddy build with the Cloudflare DNS plugin useslib.fakeHashas a placeholder. After the firstnix build, replace it with the hash Nix reports in the error message. -
monitoring.nix— Grafana dashboard hash: The Node Exporter Full dashboardfetchurlhash is a placeholder. Run:nix store prefetch-file --hash-type sha256 \ https://grafana.com/api/dashboards/1860/revisions/37/downloadand replace the hash in
modules/monitoring.nix. -
secrets/secrets.yaml— populate and encrypt: Fill in all secret values, then runsops --encrypt --in-place secrets/secrets.yamlbefore 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.nixfor 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 thepi-secondaryentry inflake.nix. Services communicating cross-machine should reference the primary Pi's LAN IP instead of127.0.0.1. -
Jellyfin and Transmission: Both modules exist but are disabled. Enable in
hosts/pi-main/default.nixwhen ready:homey.jellyfin.enable = true; homey.transmission.enable = true; -
Backup — offload script: Write
scripts/offload-backup.shfor manually copying snapshots to a local disk. Usesrestic copyto clone from the S3 repo into a local restic repo. SeeTODO.orgfor 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.yamlalongside the existing PGP key, then runsops updatekeys secrets/secrets.yaml. -
hosts/pi-main/hardware.nix— verify SD card labels: The file assumes partition labelsNIXOS_SD(root) andFIRMWARE(boot). Relabel after flashing if they differ, or update thefileSystemsentries. -
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
- Host:
-
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:
sudo ntfy webpush keysSet
homey.ntfy.webPushPublicKeyindefault.nixand add the private key to sops asntfy/web_push_private_key. -
Uptime Kuma monitors: On first boot,
uptime-kuma-syncwill automatically create all monitors declared viahomey.monitoring.monitors. Verify they appear correctly in the UI athttps://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.