Files
homey/AGENTS.md
T
2026-04-15 17:20:35 +03:00

10 KiB

AGENTS.md

Self-hosted home server configuration for a Raspberry Pi 4 (8 GB), managed entirely through NixOS. Services run as podman containers 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
  services/
    openldap.nix              # OpenLDAP — central identity provider
    authelia.nix              # Authelia — SSO gateway
    gitea.nix                 # Gitea — Git server
    nextcloud.nix             # Nextcloud + PostgreSQL
    phpldapadmin.nix          # phpLDAPadmin — LDAP web UI
    jellyfin.nix              # Jellyfin — media server (disabled by default)
    transmission.nix          # Transmission — torrent client (disabled by default)
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 home.zakobar.com.

Service URL Auth
Authelia auth.home.zakobar.com Public (it is the auth portal)
Gitea git.home.zakobar.com Authelia one_factor
Nextcloud nextcloud.home.zakobar.com Nextcloud-native
phpLDAPadmin ldapadmin.home.zakobar.com Authelia two_factor, admins only
Jellyfin jellyfin.home.zakobar.com Authelia one_factor
Transmission torrent.home.zakobar.com Authelia two_factor, admins only

Internal ports (all bound to 127.0.0.1):

Container Port
openldap 389
authelia 9091
gitea 3000
nextcloud 8080
nextcloud-postgres 5432
phpldapadmin 8081
jellyfin 8096
transmission 9092 (not 9091 — avoids clash with authelia)

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
  restic-cache/             → restic local cache

The drive device path is set per-host in hosts/<name>/default.nix via homey.storage.device. Use a /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_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:

    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 read domain/org from hardcoded 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/<service>-secrets.env and reference it via EnvironmentFile. Clean it up in postStop.

  5. --network=host — all containers use host networking for simplicity on a single-node setup. Services communicate via 127.0.0.1:<port>.

  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.

Adding a New Service

  1. Create modules/services/<name>.nix following the existing module pattern.
  2. Add homey.<name>.enable = false as the default option.
  3. Import the new module in flake.nix (in the modules list inside mkHost).
  4. Enable it in hosts/pi-main/default.nix.
  5. Add a Caddy virtual host block in modules/caddy.nix.
  6. Add the service data directory to modules/storage.nix tmpfiles.rules.
  7. Add the data path to the paths list in modules/backup.nix.
  8. Add any new secrets to secrets/secrets.yaml (plaintext) and 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 — 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.

  • hosts/pi-main/default.nix — fill in real values:

    • SSH public key in users.users.admin.openssh.authorizedKeys.keys
    • External HD device path in homey.storage.device
    • Backup repository URL in homey.backup.repository
  • secrets/secrets.yaml — populate and encrypt: Fill in all secret values (old passwords from k8s + freshly generated ones), then run sops --encrypt --in-place secrets/secrets.yaml before committing.

  • secrets/.sops.yaml — add real age keys: Replace both AGE-PUBLIC-KEY-* placeholders with actual public keys (workstation + Pi).

  • Cloudflare Tunnel: Create the tunnel in the Zero Trust dashboard, copy the tunnel token into secrets, and configure public hostnames. See modules/cloudflared.nix and Phase 3 of PORTING.md for details.

  • Gitea LDAP auth: After first start, configure LDAP authentication in Gitea's admin panel (Admin → Authentication Sources → Add LDAP source). The old Helm chart had this commented out; it must be done manually once. Relevant settings:

    • Host: 127.0.0.1, Port: 389, Security: Unencrypted
    • Bind DN: cn=readonly,dc=home,dc=zakobar,dc=com
    • User search base: ou=users,dc=home,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).

  • 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.

  • 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 are written and importable but disabled. Enable in hosts/pi-main/default.nix when ready:

    homey.jellyfin.enable    = true;
    homey.transmission.enable = true;