Files
homey/PORTING.md
T
Aner Zakobar 2f0d0b5e4c Port to NixOS: replace Helm chart with flake-based NixOS config
Replaces the Helm/k3s setup with a declarative NixOS configuration targeting
a Raspberry Pi 4. Services run as podman containers under systemd, with data
on an external HD at /mnt/data. Key components:

- flake.nix: multi-host flake with pi-main (aarch64) and a placeholder for a
  second machine
- modules/common.nix: shared system config (nix, podman, sops, SSH)
- modules/storage.nix: external HD mount with per-service subdirs
- modules/caddy.nix: Caddy with cloudflare DNS-01 ACME + authelia forward_auth
- modules/cloudflared.nix: Cloudflare tunnel for remote access
- modules/backup.nix: restic daily backups with NC maintenance mode pre-hook
- modules/services/{openldap,authelia,gitea,nextcloud,phpldapadmin}.nix: core services
- modules/services/{jellyfin,transmission}.nix: media services (disabled by default)
- secrets/: sops-nix scaffold with .sops.yaml age key config
- hosts/pi-main/: hardware config + service selection for the Pi
- PORTING.md: step-by-step migration guide (SD card → data restore → verify)
2026-04-15 17:18:12 +03:00

11 KiB

Porting Guide — Helm/k3s → NixOS

This document walks through setting up the new NixOS-based home server from scratch on a Raspberry Pi 4, restoring data from old Longhorn volumes, and verifying each service.


Prerequisites (on your workstation)

  • nix with flakes enabled (~/.config/nix/nix.conf: experimental-features = nix-command flakes)
  • sops + age CLI tools (nix-shell -p sops age)
  • An SSH key pair

Phase 0 — Secrets

0.1 Generate your age key (workstation)

age-keygen -o ~/.config/sops/age/keys.txt
age-keygen -y ~/.config/sops/age/keys.txt   # print public key

You will add the Pi's public key in step 2.2; for now add your workstation public key so you can edit the secrets file offline.

Edit secrets/.sops.yaml, replace the placeholder with your workstation pubkey:

- age:
    - AGE-PUBLIC-KEY-YOUR-WORKSTATION   # ← paste here

0.2 Fill in secrets.yaml

secrets/secrets.yaml is a plaintext template — do not commit it until encrypted. Fill in all values:

Key Source
openldap/admin_password From old k8s secret openldap-admin
openldap/config_password From old k8s secret openldap-config
openldap/ro_password From old k8s secret openldap-ro
gitea/admin_password From old k8s secret gitea-admin-pass
nextcloud/admin_password From old k8s secret nextcloud-admin-pass
nextcloud/postgres_password From old k8s secret nextcloud-postgres-pass
authelia/jwt_secret Generate: openssl rand -hex 64
authelia/session_secret Generate: openssl rand -hex 64
authelia/storage_encryption_key Generate: openssl rand -hex 64
gitea/lfs_jwt_secret Generate: openssl rand -base64 32 | tr -d '='
gitea/oauth2_jwt_secret Generate: openssl rand -base64 32 | tr -d '='
gitea/internal_token Generate: openssl rand -base64 75 | tr -d '\n='
cloudflare/api_token Cloudflare dashboard → API Tokens → DNS:Edit
cloudflare/tunnel_token Created in Phase 3 (Cloudflare setup)
restic/password Generate: openssl rand -base64 32

To get old k8s secrets (if the cluster is still running):

kubectl get secret openldap-admin -n homey -o jsonpath='{.data.password}' | base64 -d
kubectl get secret openldap-config -n homey -o jsonpath='{.data.password}' | base64 -d
kubectl get secret openldap-ro     -n homey -o jsonpath='{.data.password}' | base64 -d
kubectl get secret gitea-admin-pass       -n homey -o jsonpath='{.data.password}' | base64 -d
kubectl get secret nextcloud-admin-pass   -n homey -o jsonpath='{.data.password}' | base64 -d
kubectl get secret nextcloud-postgres-pass -n homey -o jsonpath='{.data.password}' | base64 -d

0.3 Encrypt secrets.yaml (workstation, before committing)

sops --encrypt --in-place secrets/secrets.yaml
git add secrets/secrets.yaml secrets/.sops.yaml

Phase 1 — Install NixOS on the Raspberry Pi 4

1.1 Flash the SD card

Download the NixOS aarch64 SD card image:

https://nixos.org/download#nixos-iso
→ "Raspberry Pi (aarch64) SD card image"

Flash with:

# macOS
sudo dd if=nixos-*-aarch64-linux.img of=/dev/rdiskN bs=4m status=progress

# Linux
sudo dd if=nixos-*-aarch64-linux.img of=/dev/sdX bs=4M status=progress conv=fsync

Label the partitions to match hardware.nix:

# After flashing, mount the root partition and relabel if needed:
sudo e2label /dev/sdX2 NIXOS_SD
sudo fatlabel /dev/sdX1 FIRMWARE

Boot the Pi from the SD card. You should get a serial console or HDMI output.

1.2 Initial network setup

On the Pi (serial or HDMI):

# Find your IP
ip addr

# Set a temporary password for nixos user to SSH in
passwd nixos

From your workstation:

ssh nixos@<pi-ip>

1.3 Copy the flake to the Pi

# From your workstation (repo root)
rsync -avz --exclude='.git' . nixos@<pi-ip>:/tmp/homey/

1.4 Generate the Pi's age key

# On the Pi
nix-shell -p age --run 'age-keygen -o /tmp/pi-age-key.txt'
age-keygen -y /tmp/pi-age-key.txt   # print public key

Copy the public key back to your workstation. Add it to secrets/.sops.yaml:

- age:
    - AGE-PUBLIC-KEY-YOUR-WORKSTATION
    - AGE-PUBLIC-KEY-PI-MAIN-REPLACE-ME   # ← paste Pi's public key here

Re-encrypt secrets so the Pi can decrypt them:

# On workstation
sops updatekeys secrets/secrets.yaml
git add secrets/.sops.yaml secrets/secrets.yaml
git commit -m "Add Pi age key"

# Copy updated files to Pi
rsync -avz secrets/ nixos@<pi-ip>:/tmp/homey/secrets/

Place the Pi's private key where sops-nix expects it:

# On the Pi
sudo mkdir -p /var/lib/sops-nix
sudo cp /tmp/pi-age-key.txt /var/lib/sops-nix/key.txt
sudo chmod 600 /var/lib/sops-nix/key.txt

1.5 Configure host-specific settings

Edit hosts/pi-main/default.nix on the Pi (or on workstation first):

  1. Set your SSH public key in users.users.admin.openssh.authorizedKeys.keys
  2. Set homey.storage.device to your USB drive:
    ls -la /dev/disk/by-id/ | grep -v part
    
  3. Set homey.backup.repository to your backup destination

Edit hosts/pi-main/hardware.nix if the disk labels differ from defaults.

1.6 Install NixOS

# On the Pi
sudo nixos-install --flake /tmp/homey#pi-main --no-root-passwd

# Reboot
sudo reboot

After reboot, SSH in with your admin key:

ssh admin@<pi-ip>

Phase 2 — Restore Data from Old Volumes

Mount the external HD (if not auto-mounted):

sudo mount /dev/disk/by-id/<your-drive-id> /mnt/data

Copy data from the old Longhorn volume backups into the new layout:

# Adjust source paths to wherever your Longhorn volume dumps are
BACKUP_SRC=/path/to/longhorn/backups

# OpenLDAP
sudo rsync -av $BACKUP_SRC/openldap/etc-ldap-slapd.d/ /mnt/data/openldap/etc-ldap-slapd.d/
sudo rsync -av $BACKUP_SRC/openldap/var-lib-ldap/      /mnt/data/openldap/var-lib-ldap/

# Gitea
sudo rsync -av $BACKUP_SRC/gitea/data/ /mnt/data/gitea/data/

# Nextcloud
sudo rsync -av $BACKUP_SRC/nextcloud/html/ /mnt/data/nextcloud/html/
# Restore postgres from pg_dump if available, otherwise restore the data dir:
sudo rsync -av $BACKUP_SRC/nextcloud/db/ /mnt/data/nextcloud/db/

Fix ownership (containers run as UID 1000 or root depending on image):

# openldap runs as root inside the container
sudo chown -R root:root /mnt/data/openldap/

# gitea runs as git (UID 1000)
sudo chown -R 1000:1000 /mnt/data/gitea/

# nextcloud runs as www-data (UID 33)
sudo chown -R 33:33 /mnt/data/nextcloud/html/
# postgres data owned by postgres (UID 999 in the postgres image)
sudo chown -R 999:999 /mnt/data/nextcloud/db/

Phase 3 — Cloudflare Tunnel Setup

3.1 Create the tunnel in Cloudflare Zero Trust

  1. Go to https://one.dash.cloudflare.com → Networks → Tunnels
  2. Click "Create a tunnel" → Cloudflared → Name it pi-main
  3. Copy the tunnel token (long string starting with eyJ...)
  4. Add it to secrets/secrets.yaml under cloudflare/tunnel_token
  5. Re-encrypt: sops secrets/secrets.yaml (the file opens in $EDITOR)

3.2 Configure public hostnames in the Cloudflare dashboard

In the tunnel's "Public Hostnames" tab, add:

Subdomain Domain Service
auth home.zakobar.com https://localhost:443
git home.zakobar.com https://localhost:443
nextcloud home.zakobar.com https://localhost:443
ldapadmin home.zakobar.com https://localhost:443
jellyfin home.zakobar.com https://localhost:443
torrent home.zakobar.com https://localhost:443

For each entry, under "Additional settings" → TLS → No TLS Verify: ON (because cloudflared connects to localhost but the cert is for the real hostname).

3.3 Update DNS in Cloudflare

Add a CNAME for home.zakobar.com pointing to your tunnel's UUID (Cloudflare creates this automatically when you add hostnames). You do not need to add home.zakobar.com to your domain's A records — Cloudflare handles it.


Phase 4 — Rebuild and Verify

After restoring data and completing Cloudflare setup, apply the final config:

# On the Pi
sudo nixos-rebuild switch --flake /path/to/homey#pi-main

Verification checklist

# All container services running?
systemctl list-units 'podman-*' --state=active

# OpenLDAP responding?
ldapsearch -x -H ldap://127.0.0.1:389 -b dc=home,dc=zakobar,dc=com -D "cn=admin,dc=home,dc=zakobar,dc=com" -W

# Authelia health?
curl -s http://localhost:9091/api/health | python3 -m json.tool

# Caddy serving TLS?
curl -I https://auth.home.zakobar.com

# Gitea login?
# Visit https://git.home.zakobar.com — should redirect to authelia if not logged in

# Nextcloud?
# Visit https://nextcloud.home.zakobar.com

# Cloudflare tunnel connected?
systemctl status cloudflared-tunnel-pi-main

To access services without going through Cloudflare on the LAN, add these records to your router's DNS or Pi-hole:

192.168.1.100  home.zakobar.com
192.168.1.100  auth.home.zakobar.com
192.168.1.100  git.home.zakobar.com
192.168.1.100  nextcloud.home.zakobar.com
192.168.1.100  ldapadmin.home.zakobar.com
192.168.1.100  jellyfin.home.zakobar.com
192.168.1.100  torrent.home.zakobar.com

Replace 192.168.1.100 with your Pi's actual LAN IP.


Day-to-day Operations

Apply config changes

sudo nixos-rebuild switch --flake /path/to/homey#pi-main

Edit secrets

sops secrets/secrets.yaml
# Save and exit — sops re-encrypts automatically
# Then copy to Pi and rebuild

Browse service data on disk

ls /mnt/data/
ls /mnt/data/gitea/data/
# No special tools needed — plain filesystem

Trigger a manual backup

sudo systemctl start restic-backups-homey.service

List backup snapshots

sudo restic -r <your-repo-url> \
  --password-file /run/secrets/restic_password \
  snapshots

Restore a single service from backup

sudo systemctl stop podman-gitea.service
sudo restic -r <repo> restore latest \
  --target / \
  --include /mnt/data/gitea
sudo systemctl start podman-gitea.service

Adding a Second Machine (future)

  1. Create hosts/pi-secondary/default.nix and hardware.nix
  2. Enable the services you want on that machine
  3. Services communicating cross-machine: reference the primary Pi's LAN IP or hostname directly in environment variables (e.g. point gitea's LDAP config at 192.168.1.100:389 rather than 127.0.0.1:389).
  4. Add the new host to flake.nix:
    pi-secondary = mkHost {
      system   = "x86_64-linux";
      hostPath = ./hosts/pi-secondary/default.nix;
    };
    
  5. Generate an age key on the new machine and add it to .sops.yaml.