Working NixOS port: all core services operational

- Fix Caddy cfProxy helper for cloudflared http:// vhosts (X-Forwarded-Proto)
- Fix Authelia LDAP bind (readonly user ACL + password sync)
- Add gitea-admin-setup oneshot service to survive rebuilds
- Update Authelia forward_auth with header_up X-Forwarded-Proto https
- Update TODO.org with completed tasks and LDAP config details
- Remove old Helm/k8s artifacts (Chart.yaml, templates/, values/, scripts)
- Add result to .gitignore

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aner Zakobar
2026-04-23 14:46:21 +03:00
parent 05619d12fc
commit 0b73d493d8
22 changed files with 1410 additions and 355 deletions
+1
View File
@@ -1,3 +1,4 @@
charts
*.lock
.agent-shell
result
+3 -4
View File
@@ -17,8 +17,7 @@
creation_rules:
- path_regex: secrets/secrets\.yaml$
key_groups:
- pgp
- pgp:
- 076AA297579A0064
# - age:
# Pi main host key — replace with output of `age-keygen -y /var/lib/sops-nix/key.txt`
# - AGE-PUBLIC-KEY-PI-MAIN-REPLACE-ME
age:
- age120j8ty7nn04l3s3kgph5ty3v9g4e52fknn8xtnmzwakq9nv2la3skgte0p
+9 -9
View File
@@ -40,16 +40,16 @@ PORTING.md # Step-by-step migration guide from the old Helm s
## Services and URLs
All services live under `home.zakobar.com`.
All services live under `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 |
| Authelia | `auth.zakobar.com` | Public (it is the auth portal) |
| Gitea | `git.zakobar.com` | Gitea-native (LDAP) |
| Nextcloud | `nextcloud.zakobar.com` | Nextcloud-native |
| phpLDAPadmin | `ldapadmin.zakobar.com` | Authelia two_factor, admins only |
| Jellyfin | `jellyfin.zakobar.com` | Jellyfin-native |
| Transmission | `torrent.zakobar.com` | Authelia two_factor, admins only |
Internal ports (all bound to `127.0.0.1`):
@@ -279,8 +279,8 @@ These items require the Pi to be built, flashed, and booted at least once.
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`
- 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
+19 -19
View File
@@ -260,21 +260,21 @@ 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` |
| `auth` | `zakobar.com` | `https://localhost:443` |
| `git` | `zakobar.com` | `https://localhost:443` |
| `nextcloud` | `zakobar.com` | `https://localhost:443` |
| `ldapadmin` | `zakobar.com` | `https://localhost:443` |
| `jellyfin` | `zakobar.com` | `https://localhost:443` |
| `torrent` | `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
Add a CNAME for `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.
`zakobar.com` to your domain's A records — Cloudflare handles it.
---
@@ -294,19 +294,19 @@ sudo nixos-rebuild switch --flake /path/to/homey#pi-main
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
ldapsearch -x -H ldap://127.0.0.1:389 -b dc=zakobar,dc=com -D "cn=admin,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
curl -I https://auth.zakobar.com
# Gitea login?
# Visit https://git.home.zakobar.com — should redirect to authelia if not logged in
# Visit https://git.zakobar.com — should redirect to authelia if not logged in
# Nextcloud?
# Visit https://nextcloud.home.zakobar.com
# Visit https://nextcloud.zakobar.com
# Cloudflare tunnel connected?
systemctl status cloudflared-tunnel-pi-main
@@ -320,13 +320,13 @@ 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
192.168.1.100 zakobar.com
192.168.1.100 auth.zakobar.com
192.168.1.100 git.zakobar.com
192.168.1.100 nextcloud.zakobar.com
192.168.1.100 ldapadmin.zakobar.com
192.168.1.100 jellyfin.zakobar.com
192.168.1.100 torrent.zakobar.com
```
Replace `192.168.1.100` with your Pi's actual LAN IP.
+300
View File
@@ -0,0 +1,300 @@
#+title: Homey
A home environment for everyone!
* NixOS Deployment (active branch: nixos-port)
** Prerequisites
Before building, make sure the following are set in the repo:
- =hosts/pi-main/default.nix= — SSH public key, static IP, WiFi SSID
- =secrets/secrets.yaml= — all secrets populated and sops-encrypted
- WiFi password secret formatted as =wifi_psk=YourPassword= (see below)
** Adding / updating secrets
#+begin_src bash
sops secrets/secrets.yaml
#+end_src
Opens your editor with the decrypted file. Save and quit to re-encrypt.
The WiFi password entry must use the =wifi_psk== prefix so wpa_supplicant
can look up the value by name:
#+begin_src yaml
wifi/psk: "wifi_psk=YourActualWifiPassword"
#+end_src
** Phase 1 — Bootstrap image (flash this first)
The full =pi-main= config requires sops secrets, which require an age key
on the Pi — but the age key doesn't exist until after first boot. To
break the chicken-and-egg problem, flash a minimal bootstrap image first.
Before building, fill in the WiFi password in =flake.nix= in the
=pi-main-bootstrap= config (search for =WIFI_PASSWORD_HERE=):
#+begin_src nix
networks."Zakobar".psk = "your-actual-wifi-password";
#+end_src
Build the bootstrap SD image (requires =aarch64-linux= build capability —
either =boot.binfmt.emulatedSystems = ["aarch64-linux"]= on your
workstation, or an aarch64 remote builder):
#+begin_src bash
nix build .#nixosConfigurations.pi-main-bootstrap.config.system.build.sdImage \
--system aarch64-linux
#+end_src
Find your SD card device, then flash (double-check =/dev/sdX=!):
#+begin_src bash
lsblk
zstdcat result/sd-image/nixos-sd-image-*.img.zst | \
sudo dd of=/dev/sdX bs=4M status=progress conv=fsync
#+end_src
The Pi will boot at =192.168.1.100=, connect to =Zakobar= WiFi, and accept
SSH connections with your key. No services run yet.
** Phase 2 — Generate age key and re-encrypt secrets
#+begin_src bash
# SSH into the Pi
ssh admin@192.168.1.100
# Generate the age key
sudo age-keygen -o /var/lib/sops-nix/key.txt
# Print the public key — copy it
sudo age-keygen -y /var/lib/sops-nix/key.txt
#+end_src
Back on your workstation, add the public key to =secrets/.sops.yaml=
alongside the existing PGP key:
#+begin_src yaml
keys:
- &pi_main age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
creation_rules:
- path_regex: secrets/secrets.yaml$
key_groups:
- pgp:
- 076AA297579A0064
age:
- *pi_main
#+end_src
Then re-encrypt so the Pi can decrypt its own secrets:
#+begin_src bash
sops updatekeys secrets/secrets.yaml
#+end_src
** Phase 3 — Deploy the full config
#+begin_src bash
nixos-rebuild switch \
--flake .#pi-main \
--target-host admin@192.168.1.100 \
--build-host admin@192.168.1.100 \
--use-remote-sudo
#+end_src
The Pi builds its own config natively (no cross-compilation). sops-nix
will now decrypt all secrets and start all services.
** Caddy plugin hash
The first deploy will fail at the Caddy build step because =lib.fakeHash=
is a placeholder. Copy the correct hash from the error output and replace
it in =modules/caddy.nix=:
#+begin_src nix
caddyWithCloudflare = pkgs.caddy.withPlugins {
plugins = [ "github.com/caddy-dns/cloudflare@..." ];
hash = "sha256-REPLACE_WITH_REAL_HASH="; # ← paste here
};
#+end_src
Then re-run the deploy command from Phase 3.
** Ongoing deploys from workstation
All future config changes follow the same pattern:
1. Edit files on workstation
2. Run:
#+begin_src bash
nixos-rebuild switch \
--flake .#pi-main \
--target-host admin@192.168.1.100 \
--build-host admin@192.168.1.100 \
--use-remote-sudo
#+end_src
NixOS activates the new config on the Pi immediately, with an automatic
rollback if activation fails.
* Installation (legacy Helm)
Install using
#+begin_src bash
helm upgrade --install homey . -n homey
#+end_src
* Backing up
Backups use [[https://restic.net/][restic]] and run automatically via systemd on a daily schedule.
** Strategy — two tiers
1. *Primary (automatic)*: Daily backup to an S3-compatible bucket (Backblaze B2,
Wasabi, AWS S3, etc.). Restic deduplicates and encrypts before upload.
Retention: 7 daily, 4 weekly, 6 monthly snapshots.
2. *Offload (manual)*: Run =scripts/offload-backup.sh --target /path/to/disk=
to clone snapshots from the S3 repo onto a local disk (USB plugged into the
Pi, or a disk on your workstation). Uses =restic copy= so deduplication is
preserved on the target.
** What is backed up
All service data under =/mnt/data/=:
- =openldap/= — LDAP database and config
- =authelia/= — Authelia config and state
- =gitea/= — Gitea repositories and data
- =nextcloud/= — Nextcloud files + a =pg_dump= of the database
- =jellyfin/= — Jellyfin metadata (media files are excluded — re-downloadable)
- =transmission/= — Torrent client config
Nextcloud is placed into maintenance mode and postgres is =pg_dump='d before
each backup to ensure a consistent snapshot.
** Configuration
Repository URL and credentials are set per-host:
#+begin_src nix
# hosts/pi-main/default.nix
homey.backup.repository = "s3:https://s3.us-west-002.backblazeb2.com/your-bucket";
#+end_src
S3 credentials live in =secrets/secrets.yaml= as =restic/s3_access_key_id= and
=restic/s3_secret_access_key=.
** Restore
#+begin_src bash
# List snapshots
restic -r s3:https://... snapshots
# Restore latest snapshot to /mnt/data
restic -r s3:https://... restore latest --target /mnt/data
# Restore a single service
restic -r s3:https://... restore latest --target /mnt/data --include /mnt/data/gitea
#+end_src
* LDAP Configuration
Logins are done to PHPLDAPADMIN
DN is like:
cn=admin,dc=,dc=io
get-secret-val.sh homey openldap-admin password
First thing we do is create an organization unit called users
To add a new user, we create a child entry to ou=users
It has to be of type inetOrgPerson
cn = Common Name, sn = Sur Name.
Select RDN = User Name (uid) (FROM DROP DOWN MENU)
UID = USERNAME, that is what is important. (In PHPLdapAdmin it is under User Name)
Now we may continue!
* GITEA
Site Title: whatever
SSH Server Domain: git.<YOUR URL>
SSH Server Port: 2222
Gitea Base URL: http://git.<YOUR URL>
Then add Administrator Account Settings:
Administrator Username: gitea-admin
Password: from gitea-admin-pass
Email address must be populated
That will work after a few minutes.
Now we go into Authentication Sources
Add a new LDAP Authentication source
Authentication name: Home LDAP
Host: openldap
Port: 389
Bind DN = cn=readonly,dc=,dc=io
Bind Password: openldap-ro password
User Search Base: ou=users,dc=,dc=io
user search filter = (uid=%s)
Admin filter (title=admin)
Username Attribute: uid
First Name Attribute: cn
Surname Attribute: sn
Email Attribute: mail
* AUTHELIA
https://github.com/authelia/authelia/blob/57d5fbd3f5c82e83296023dc1de6e4f5ff063c00/examples/compose/lite/authelia/configuration.yml
This fucking sucks
https://gist.github.com/james-d-elliott/5152d27c0781aee856a3383f1284998e
* EVERYTHING
https://www.talkingquickly.co.uk/gitea-sso-with-keycloak-openldap-openid-connect
* DRONE AND GITEA
?
https://dev.to/ruanbekker/self-hosted-cicd-with-gitea-and-drone-ci-200l
* DAV
https://gitlab.com/davical-project/davical/-/blob/master/config/example-config.php
Line 800 ish for auth from reverse proxy
* NEXTCLOUD
I ran THIS command inside
su www-data -s /bin/bash -c php occ ldap:promote-group "admins"
** When maintenence mode
#+begin_example
kubectl exec --tty --stdin -n homey deploy/nextcloud -- su -l www-data -s /bin/bash
php /var/www/html/occ maintenance:mode --off
#+end_src
* I UNDERSTAND
I need to backup Chen's stuff
And... I need to Jellyfin
* PAPERLESS
https://github.com/paperless-ngx/paperless-ngx/blob/74c44fe418a91a526b5dab1a91fde4aaebd28bb1/docker/compose/docker-compose.postgres.yml
For docker
+286
View File
@@ -0,0 +1,286 @@
#+TITLE: Homey NixOS Port — Outstanding Tasks
#+DATE: 2026-04-15
#+OPTIONS: toc:nil
* Secrets Setup
** DONE Configure sops recipients in =secrets/.sops.yaml=
GPG encryption subkey =076AA297579A0064= is already configured in
=.sops.yaml=. The Pi's age key will be added post-boot (see Deployment section).
** DONE Populate =secrets/secrets.yaml= with real values
Every key in =secrets/secrets.yaml= needs a real value. Fill in each row below.
*** Recovered from k8s backup
| Key | Source k8s secret | Value |
|----------------------------------+-------------------------+----------------------------------|
| openldap/admin_password | openldap-admin | lfQWQgBZporyJT4xFSJTnu4vQMC7UevW |
| openldap/config_password | openldap-config | ZxlWbDAeHLdHi5lgdxmyZOWzsG3qDgrT |
| openldap/ro_password | openldap-ro | CZ7JLn23vSzhVjNW7UHGZ2YLFJPDLGsF |
| gitea/admin_password | gitea-admin-pass | y5kCPeCP1e1sCzahd7QmLyJqdQvd37ek |
| nextcloud/postgres_password | nextcloud-postgres-pass | hEq4zt1B1VKYtVAoiKYDmswcUmTbknSP |
| authelia/jwt_secret | jwt-secret | YJZBnQCD4OmhJkgdr6kksmMCatrKLCl3 |
| authelia/storage_encryption_key | fek-secret | KYWRYApCWWIN60gpSi7jhLuj1Wcm5z9Q |
*** Needs generation or manual creation
| Key | Action |
|----------------------------------+---------------------------------------------------|
| authelia/session_secret | Generate fresh (64 random chars) |
| gitea/lfs_jwt_secret | Generate fresh (43-char base64url) |
| gitea/oauth2_jwt_secret | Generate fresh (43-char base64url) |
| gitea/internal_token | Generate fresh (100-char alphanumeric) |
| restic/password | Generate fresh (passphrase) |
| nextcloud/admin_password | NOT in k8s backup; try old value or reset later |
| cloudflare/api_token | Create DNS Edit token in Cloudflare dashboard |
| cloudflare/tunnel_token | Create tunnel in Cloudflare Zero Trust dashboard |
| restic/s3_access_key_id | Needs S3 provider credentials |
| restic/s3_secret_access_key | Needs S3 provider credentials |
Generate random secrets with:
#+begin_src bash
# 64-char hex string
openssl rand -hex 32
# base64url (for gitea tokens)
openssl rand -base64 32 | tr '+/' '-_' | tr -d '='
# 100-char alphanumeric (gitea internal token)
openssl rand -base64 75 | tr -dc 'A-Za-z0-9' | head -c 100
#+end_src
** DONE Encrypt =secrets/secrets.yaml= with sops
Encrypted with PGP key =076AA297579A0064=. Safe to commit.
* Pi Hardware Setup
** DONE Fill in real values in =hosts/pi-main/default.nix=
- [X] =users.users.admin.openssh.authorizedKeys.keys= — SSH public key added
- [X] =homey.storage.device==/dev/disk/by-id/usb-WD_Ext_HDD_1021_5743415A4146313531393031-0:0-part1=
- [X] =homey.backup.repository==s3:https://s3.us-east-005.backblazeb2.com/zakobar-home-backup=
** DONE Integrate nixos-raspberrypi flake
Replaced =nixos-hardware= with =nixos-raspberrypi= for vendor kernel, firmware,
u-boot bootloader, and binary cache. Both =pi-main= and =pi-main-bootstrap=
now use =nixos-raspberrypi.lib.nixosSystem= and =raspberry-pi-4.base=.
=nix flake check= passes.
** TODO Verify SD card partition labels in =hosts/pi-main/hardware.nix=
The config assumes labels =NIXOS_SD= (root) and =FIRMWARE= (boot).
After flashing, check with:
#+begin_src bash
lsblk -o NAME,LABEL
#+end_src
Update =fileSystems= entries in =hosts/pi-main/hardware.nix= if they differ.
* Caddy Build
** TODO Fix =vendorHash= in =modules/caddy.nix=
The Caddy build with the Cloudflare DNS plugin currently uses =lib.fakeHash=
as a placeholder. After the first =nix build= attempt it will fail with the
correct hash in the error message. Replace =lib.fakeHash= with that value.
* Cloudflare Setup
** DONE Create Cloudflare Tunnel
1. Go to Cloudflare Zero Trust dashboard → Networks → Tunnels → Create tunnel
2. Name it (e.g. =homey=)
3. Copy the tunnel token into =secrets/secrets.yaml= under =cloudflare/tunnel_token=
4. Configure public hostnames for each service (see service/URL table in AGENTS.md)
** DONE Create Cloudflare DNS API token
1. Cloudflare dashboard → My Profile → API Tokens → Create Token
2. Use the "Edit zone DNS" template, scope to =zakobar.com=
3. Copy into =secrets/secrets.yaml= under =cloudflare/api_token=
* Deployment
** TODO Phase 1 — Build and flash bootstrap SD card image
The bootstrap image is a minimal NixOS with SSH + WiFi only (no sops, no
services). Its sole purpose is to boot the Pi so you can generate the age key
and then deploy the full config remotely.
Build on workstation (cross-compiles for aarch64):
#+begin_src bash
# Accept the nixos-raspberrypi cache config so pre-built kernel/firmware
# are fetched instead of compiled. First build still takes ~10-20 min.
nix build .#nixosConfigurations.pi-main-bootstrap.config.system.build.sdImage \
--accept-flake-config
#+end_src
Flash to SD card (replace =/dev/sdX= with your card's device):
#+begin_src bash
# Decompress and write in one step — avoids storing the raw image on disk
zstdcat result/sd-image/*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress conv=fsync
sudo sync
#+end_src
Insert the SD card into the Pi and power it on.
It will connect to WiFi (=Zakobar=) with static IP =192.168.1.100=.
Verify SSH access (wait ~60 s for first boot):
#+begin_src bash
ssh admin@192.168.1.100
#+end_src
** TODO Phase 2 — Generate age key and add it to sops
On the Pi (over SSH):
#+begin_src bash
sudo mkdir -p /var/lib/sops-nix
sudo age-keygen -o /var/lib/sops-nix/key.txt
# Print the public key — copy this output to your workstation clipboard
sudo age-keygen -y /var/lib/sops-nix/key.txt
#+end_src
On the workstation — edit =secrets/.sops.yaml=, uncomment the age section
and replace the placeholder with the public key you just copied:
#+begin_src yaml
key_groups:
- pgp:
- 076AA297579A0064
age:
- age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # paste here
#+end_src
Re-encrypt =secrets/secrets.yaml= so the Pi's age key can decrypt it:
#+begin_src bash
sops updatekeys secrets/secrets.yaml
#+end_src
Commit and push:
#+begin_src bash
git add secrets/.sops.yaml secrets/secrets.yaml
git commit -m "add Pi age key to sops recipients"
#+end_src
** TODO Phase 3 — Fix Caddy vendorHash, then deploy full config
The full =pi-main= config includes Caddy built with the Cloudflare DNS
plugin. The first build will fail with the correct hash in the error output.
Attempt the build to get the hash:
#+begin_src bash
nix build .#nixosConfigurations.pi-main.config.system.build.toplevel \
--accept-flake-config 2>&1 | grep 'got:'
#+end_src
Copy the hash from the error message and replace =lib.fakeHash= in
=modules/caddy.nix=, then commit:
#+begin_src bash
git add modules/caddy.nix
git commit -m "fix caddy vendorHash"
#+end_src
Deploy to the Pi:
#+begin_src bash
# Dry-run first — shows what will change without applying
nixos-rebuild dry-activate \
--flake .#pi-main \
--target-host admin@192.168.1.100 \
--use-remote-sudo \
--accept-flake-config
# Apply when happy with the diff
nixos-rebuild switch \
--flake .#pi-main \
--target-host admin@192.168.1.100 \
--use-remote-sudo \
--accept-flake-config
#+end_src
After a successful switch, subsequent deploys can use the hostname:
#+begin_src bash
nixos-rebuild switch --flake .#pi-main --target-host admin@pi-main --use-remote-sudo
#+end_src
* Post-Deployment Manual Steps
** DONE Configure Gitea LDAP authentication
Admin → Site Administration → Authentication Sources → Add LDAP (via BindDN):
- Host: =127.0.0.1=, Port: =389=, Security: Unencrypted
- Bind DN: =cn=readonly,dc=zakobar,dc=com=
- Bind Password: see =openldap/ro_password= in sops
- User Search Base: =ou=users,dc=zakobar,dc=com=
- User Filter: =(&(objectClass=inetOrgPerson)(uid=%s))=
- Username attribute: =uid=
- First name attribute: =cn=
- Surname attribute: =sn=
- Email attribute: =mail=
** TODO Verify Nextcloud LDAP app configuration
After restoring the Nextcloud volume, check:
Admin → LDAP/AD Integration — confirm the LDAP Users and Contacts app is configured.
If reconfiguring from scratch, use the same settings as Gitea above but with
Nextcloud's LDAP wizard:
- Server: =127.0.0.1=, Port: =389=
- Bind DN: =cn=readonly,dc=zakobar,dc=com=
- Bind Password: see =openldap/ro_password= in sops
- Base DN: =dc=zakobar,dc=com=
- Users: filter =objectClass=inetOrgPerson=, search base =ou=users=
- Login attribute: =uid=
- Email attribute: =mail=
** TODO (Optional) Enable Jellyfin and Transmission
When ready, in =hosts/pi-main/default.nix=:
#+begin_src nix
homey.jellyfin.enable = true;
homey.transmission.enable = true;
#+end_src
* Backup Strategy
** TODO Configure S3-compatible automatic backup target
Update =homey.backup.repository= in =hosts/pi-main/default.nix= to point at
your S3-compatible bucket (Backblaze B2, Wasabi, AWS S3, etc.):
#+begin_src nix
homey.backup.repository = "s3:https://s3.us-west-002.backblazeb2.com/your-bucket-name";
# or for AWS:
# homey.backup.repository = "s3:s3.amazonaws.com/your-bucket-name";
#+end_src
Add the S3 credentials to =secrets/secrets.yaml=:
#+begin_src yaml
restic/s3_access_key_id: "YOUR_KEY_ID"
restic/s3_secret_access_key: "YOUR_SECRET_KEY"
#+end_src
Then wire them into =modules/backup.nix= via environment variables:
=AWS_ACCESS_KEY_ID= and =AWS_SECRET_ACCESS_KEY= (restic reads these natively).
The existing daily schedule + prune retention in =modules/backup.nix= will
handle the rest automatically.
** TODO Write manual offload script (=scripts/offload-backup.sh=)
A standalone script for copying backup data to an external disk — either
plugged directly into the Pi or mounted on your workstation.
Design:
- Accepts a =--target= argument: a local path to the mounted disk
(e.g. =/media/aner/backup-disk= or =/mnt/usb=)
- Uses =restic copy= to clone snapshots from the S3 repo into a local restic
repo on the target disk (deduplication is preserved, no double storage)
- Alternatively can use =rsync= for a plain directory copy if restic is not
available on the target machine
- Should be runnable from either the Pi or a workstation (with the Pi's data
disk mounted or accessible over SSH)
Example invocation:
#+begin_src bash
# On the Pi, with USB disk mounted at /mnt/usb:
./scripts/offload-backup.sh --target /mnt/usb/homey-backup
# On workstation, with Pi data disk mounted locally:
./scripts/offload-backup.sh --target /media/aner/backup-disk/homey-backup
#+end_src
This script does not exist yet — needs to be written.
* Future
** TODO Add second machine (=pi-secondary=)
When ready:
1. Create =hosts/pi-secondary/= directory with =default.nix= and =hardware.nix=
2. Uncomment the =pi-secondary= entry in =flake.nix=
3. Services communicating cross-machine should reference the primary Pi's LAN IP
instead of =127.0.0.1=
+321
View File
@@ -0,0 +1,321 @@
#+TITLE: Caddy, Cloudflare Tunnel & TLS Setup
#+DATE: 2026-04-23
#+AUTHOR: homey project
#+OPTIONS: toc:2 num:t
* Overview
This document describes the TLS and reverse-proxy architecture for the homey
self-hosted stack, the problems encountered while getting it working, and the
final configuration that resolved them. It is intended as a reference for
future debugging and for adding new services.
** Traffic flow
#+BEGIN_EXAMPLE
Browser
│ HTTPS (TLS terminated by Cloudflare edge, *.zakobar.com cert)
Cloudflare edge (anycast IP)
│ QUIC/HTTP2 tunnel (outbound from Pi, no open inbound ports)
cloudflared daemon on Pi (systemd: cloudflared-tunnel.service)
│ plain HTTP on loopback http://localhost:80
Caddy reverse proxy (systemd: caddy.service, port 80 + 443)
│ proxies to backend by Host header
Service container (podman, port on 127.0.0.1)
#+END_EXAMPLE
Key points:
- TLS to the browser is provided entirely by Cloudflare's Universal SSL cert
(~*.zakobar.com~), not by the Pi's Let's Encrypt cert.
- The Pi's Let's Encrypt cert (~*.zakobar.com~ via DNS-01) is used only for
direct LAN access (bypassing the tunnel).
- The tunnel leg (cloudflared → Caddy) is plain HTTP on loopback — this is
safe because both endpoints are the same machine.
* Components
** Caddy (~modules/caddy.nix~)
Caddy runs as a NixOS service (~services.caddy~) using a custom build that
includes the ~caddy-dns/cloudflare~ plugin for DNS-01 ACME challenges.
*** Custom build
The nixpkgs ~caddy~ package does not include the Cloudflare DNS plugin by
default. It is built using the ~withPlugins~ passthru function (backed by
xcaddy):
#+BEGIN_SRC nix
caddyWithCloudflare = pkgs.caddy.withPlugins {
plugins = [
"github.com/caddy-dns/cloudflare@v0.2.4"
];
hash = "sha256-...";
};
#+END_SRC
The ~hash~ is a fixed-output derivation hash that must be updated whenever
the plugin version changes. Use ~lib.fakeHash~ to trigger a build failure
that prints the correct hash, then substitute it.
*** API token injection
The Cloudflare API token is stored in sops (~cloudflare/api_token~) and
injected into the Caddy process via ~systemd LoadCredential~:
#+BEGIN_SRC nix
serviceConfig.LoadCredential =
"cloudflare_api_token:${config.sops.secrets."cloudflare/api_token".path}";
ExecStart = lib.mkForce [
""
(pkgs.writeShellScript "caddy-start" ''
export CLOUDFLARE_API_TOKEN=$(cat "$CREDENTIALS_DIRECTORY/cloudflare_api_token")
exec caddy run --environ --config /etc/caddy/caddy_config --adapter caddyfile
'')
];
#+END_SRC
*** Virtual hosts — dual HTTP/HTTPS entries
Each service has *two* Caddyfile vhost entries:
| Entry | Purpose |
|---|---|
| ~git.zakobar.com~ | HTTPS — for direct LAN access; Caddy handles TLS |
| ~http://git.zakobar.com~ | HTTP — for cloudflared on loopback; no redirect |
Caddy's default behaviour is to automatically redirect HTTP → HTTPS for any
hostname that has a matching HTTPS vhost. By explicitly defining an
~http://~ vhost, that redirect is suppressed and cloudflared gets a direct
200 response instead of a redirect loop.
Without the ~http://~ vhost, accessing via the tunnel produces:
~ERR_TOO_MANY_REDIRECTS~ in the browser because cloudflared follows the 308
back to HTTP indefinitely.
*** Global config
#+BEGIN_SRC caddyfile
{
email admin@zakobar.com
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
#+END_SRC
The ~acme_dns~ directive in the global block tells Caddy to use DNS-01
challenges for *all* HTTPS vhosts. This allows wildcard and multi-level
subdomain certs to be issued without any inbound port 80 requirement.
** Cloudflare Tunnel (~modules/cloudflared.nix~)
cloudflared runs as a plain systemd service using the token-based tunnel
approach (~cloudflared tunnel run --token~). No local credentials file or
config file is needed — just the tunnel token from the Zero Trust dashboard.
*** Tunnel configuration (Zero Trust dashboard)
One wildcard public hostname entry covers all services:
| Field | Value |
|---|---|
| Hostname | ~*.zakobar.com~ |
| Service | ~http://localhost:80~ |
| No TLS Verify | off (not needed for HTTP) |
| HTTP Host Header | (empty — cloudflared forwards the real Host header) |
| Origin Server Name | (empty — not needed for HTTP) |
cloudflared automatically forwards the incoming ~Host~ header (e.g.
~git.zakobar.com~) to Caddy, which uses it to select the correct vhost and
backend.
*** DNS records
A single wildcard CNAME record in Cloudflare DNS covers all subdomains:
#+BEGIN_EXAMPLE
*.zakobar.com CNAME <tunnel-id>.cfargotunnel.com (proxied, orange cloud)
#+END_EXAMPLE
This means new services require no DNS changes — only a new Caddy vhost.
*** Cloudflare SSL/TLS mode
Set to *Full (strict)* in the Cloudflare dashboard (SSL/TLS → Overview).
| Mode | Meaning |
|---|---|
| Off | No HTTPS to browser |
| Flexible | HTTPS to browser, HTTP to origin |
| Full | HTTPS to browser, HTTPS to origin (cert not validated) |
| Full (strict) | HTTPS to browser, HTTPS to origin (cert must be valid) |
Full (strict) works here because Cloudflare terminates TLS at its own edge
using its Universal cert, and the origin (cloudflared → Caddy) uses plain
HTTP which Cloudflare does not validate in this tunnel architecture.
* Problems Encountered & How They Were Resolved
** 1. ~caddy-dns/cloudflare~ rejected ~cfut_~ token format
*Symptom:*
#+BEGIN_EXAMPLE
provision dns.providers.cloudflare: API token 'cfut_...' appears invalid;
ensure it's correctly entered and not wrapped in braces nor quotes
#+END_EXAMPLE
*Cause:*
Cloudflare introduced new token formats with a ~cfut_~ (user token) or
~cfat_~ (account token) prefix. These tokens are 54 characters long. The
~caddy-dns/cloudflare~ plugin had a validation regex ~{35,50}~ that rejected
tokens longer than 50 characters, failing before even making an API call.
*Fix:*
The fix was merged into the plugin's master branch as commit ~a8737d0~ and
included in the ~v0.2.4~ tag (despite the tag previously being associated
with an older tree — the proxy confirmed ~v0.2.4~ resolves to ~a8737d0~).
Updating the ~hash~ in ~caddy.nix~ to the value produced by ~lib.fakeHash~
forced a fresh fetch of the corrected ~v0.2.4~ tree:
#+BEGIN_SRC nix
plugins = [ "github.com/caddy-dns/cloudflare@v0.2.4" ];
hash = lib.fakeHash; # replace with hash from build error output
#+END_SRC
Run ~nix build .#nixosConfigurations.pi-main.config.system.build.toplevel~,
copy the ~got:~ hash from the error, substitute it, and rebuild.
** 2. cloudflared ~tls: internal error~ (SNI mismatch)
*Symptom:*
#+BEGIN_EXAMPLE
Unable to reach the origin service: remote error: tls: internal error
originService=https://localhost:443
#+END_EXAMPLE
*Cause:*
cloudflared connected to ~https://localhost:443~ without sending an SNI
(Server Name Indication) hostname in the TLS ClientHello. Caddy could not
match any vhost, had no certificate for ~localhost~, and aborted the
handshake with a TLS internal error.
Setting the ~HTTP Host Header~ override in the dashboard fixes the HTTP
layer but does *not* affect the TLS SNI, which is negotiated before HTTP
headers are exchanged.
Setting the ~Origin Server Name~ field does set the SNI, but for a wildcard
rule (~*.zakobar.com~) the dashboard only accepts a static value, not a
dynamic placeholder — so it cannot be used for a catch-all.
*Fix:*
Switch the tunnel service from ~https://localhost:443~ to
~http://localhost:80~. The internal leg does not need TLS (loopback
interface, same machine). Caddy's HTTP vhosts handle the requests directly.
** 3. Cloudflare edge TLS handshake failure (~*.home.zakobar.com~)
*Symptom:*
#+BEGIN_EXAMPLE
TLS connect error: error:0A000410:SSL routines::ssl/tls alert handshake failure
#+END_EXAMPLE
*Cause:*
The domain was originally configured as ~home.zakobar.com~ (base domain),
making all services two levels deep: ~git.home.zakobar.com~,
~auth.home.zakobar.com~, etc. Cloudflare's free Universal SSL certificate
covers only one level of wildcard: ~*.zakobar.com~. It does *not* cover
~*.home.zakobar.com~ (two levels). The Cloudflare edge had no certificate to
present to browsers for these hostnames, causing a TLS handshake failure
before the request ever reached the tunnel.
*Fix:*
Move all services to single-level subdomains under ~zakobar.com~
(~git.zakobar.com~, ~auth.zakobar.com~, etc.). In the NixOS config this
required only one line change — the ~domain~ field in ~flake.nix~:
#+BEGIN_SRC nix
domain = "zakobar.com"; # was "home.zakobar.com"
#+END_SRC
All modules reference ~homeyConfig.domain~ and updated automatically on
rebuild. Tunnel hostnames and DNS records in the Cloudflare dashboard were
updated to match.
** 4. ~ERR_TOO_MANY_REDIRECTS~ via tunnel
*Symptom:*
Browser shows ~ERR_TOO_MANY_REDIRECTS~ when accessing any service through
the Cloudflare tunnel.
*Cause:*
cloudflared was talking to Caddy over plain HTTP (~http://localhost:80~).
Caddy's default behaviour is to issue a 308 permanent redirect from HTTP to
HTTPS for any hostname that has a matching HTTPS vhost. cloudflared followed
the redirect back to ~http://localhost:80~, which redirected again,
indefinitely.
*Fix:*
Add explicit ~http://~ vhost entries in ~caddy.nix~ for every service. When
Caddy has an explicit HTTP vhost for a hostname, it serves it directly
without redirecting:
#+BEGIN_SRC nix
"git.${domain}" = {
extraConfig = "reverse_proxy localhost:3000";
};
"http://git.${domain}" = { # ← suppresses HTTP→HTTPS redirect
extraConfig = "reverse_proxy localhost:3000";
};
#+END_SRC
* Adding a New Service
To expose a new service through the tunnel:
1. Create ~modules/services/<name>.nix~ following the module pattern.
2. Add both a plain and ~http://~ vhost in ~modules/caddy.nix~:
#+BEGIN_SRC nix
"<name>.${domain}" = {
extraConfig = "reverse_proxy localhost:<port>";
};
"http://<name>.${domain}" = {
extraConfig = "reverse_proxy localhost:<port>";
};
#+END_SRC
3. No DNS or tunnel changes needed — the wildcard CNAME and wildcard tunnel
rule (~*.zakobar.com~) cover new subdomains automatically.
4. Rebuild and switch: ~sudo nixos-rebuild switch --flake .#pi-main~
* Certificate Details
** Let's Encrypt cert (LAN access)
- Issued per-hostname by Caddy via DNS-01 ACME using the Cloudflare API.
- Covers each hostname individually (e.g. ~git.zakobar.com~).
- Stored in ~/var/lib/caddy/.local/share/caddy/certificates/~.
- Used only when accessing services directly on the LAN (bypassing tunnel).
- Auto-renewed by Caddy.
** Cloudflare Universal SSL cert (tunnel / remote access)
- Issued by Google Trust Services for ~*.zakobar.com~.
- Managed entirely by Cloudflare — no action required on the Pi.
- Covers all single-level subdomains (~git.zakobar.com~, ~auth.zakobar.com~, etc.).
- Does *not* cover two-level subdomains (~git.home.zakobar.com~) — this was
the root cause of problem #3 above.
* Quick Reference: Debugging Checklist
| Symptom | Where to look | Command |
|---|---|---|
| 502 Bad Gateway | cloudflared logs | ~journalctl -u cloudflared-tunnel -n 50~ |
| 502 Bad Gateway | Caddy → backend | ~curl http://localhost:<port>/~ |
| TLS internal error | SNI / cert issue | ~curl -sv --resolve host:443:127.0.0.1 https://host/~ |
| Too many redirects | HTTP vhost missing | check ~http://~ entries in caddy.nix |
| Handshake failure at edge | Cloudflare cert scope | check SSL/TLS → Edge Certificates |
| Token appears invalid | plugin version | check ~caddy-dns/cloudflare~ version vs token format |
| Caddy won't start | token / config error | ~journalctl -u caddy --since "5 min ago"~ |
+36 -86
View File
@@ -1,18 +1,6 @@
{
description = "Homey - self-hosted home server NixOS configuration";
# Binary cache for pre-built Raspberry Pi kernel + firmware packages.
# nixos-raspberrypi builds against its own pinned nixpkgs and publishes
# to this cache — using it avoids compiling linuxPackages_rpi4 from source.
nixConfig = {
extra-substituters = [
"https://nixos-raspberrypi.cachix.org"
];
extra-trusted-public-keys = [
"nixos-raspberrypi.cachix.org-1:4iMO9LXa8BqhU+Rpg6LQKiGa2lsNh/j2oiYLNOQ5sPI="
];
};
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
@@ -22,46 +10,53 @@
inputs.nixpkgs.follows = "nixpkgs";
};
# Raspberry Pi hardware support — provides vendor kernel, firmware,
# bootloader management, and a binary cache for pre-built aarch64 packages.
# Intentionally NOT following our nixpkgs: the cache is built against the
# flake's own pinned nixpkgs, so following would invalidate all cache hits.
nixos-raspberrypi.url = "github:nvmd/nixos-raspberrypi/main";
# nixos-hardware provides RPi4 wireless firmware.
# We use only the minimal pieces needed for a headless server —
# no display, audio, or bluetooth modules.
nixos-hardware.url = "github:NixOS/nixos-hardware/master";
};
outputs = { self, nixpkgs, sops-nix, nixos-raspberrypi, ... }@inputs:
outputs = { self, nixpkgs, sops-nix, nixos-hardware, ... }@inputs:
let
# Shared specialArgs passed to every host
commonArgs = {
inherit inputs nixos-raspberrypi;
inherit inputs;
# Top-level site config — override per-host if needed
homeyConfig = {
domain = "home.zakobar.com"; # base domain for all services
domain = "zakobar.com"; # base domain for all services
organization = "Zakobar Home Server";
timezone = "Asia/Jerusalem";
# External HD mount point — set in hardware.nix per host
# dataDir is intentionally NOT set here; each host sets it
};
};
# nixos-raspberrypi.lib.nixosSystem is a drop-in replacement for
# nixpkgs.lib.nixosSystem that:
# - injects vendor kernel/firmware overlays
# - wires up the trusted cache substituters
# - passes nixos-raspberrypi into specialArgs automatically
# It uses the flake's own pinned nixpkgs by default (currently 25.11).
# Minimal RPi4 hardware module for a headless server.
# Provides only: bootloader, initrd modules, wireless firmware, DTB filter.
# Deliberately excludes display, audio, bluetooth from the full nixos-hardware module.
rpi4Headless = { pkgs, ... }: {
boot.loader.grub.enable = false;
boot.loader.generic-extlinux-compatible.enable = true;
boot.initrd.availableKernelModules = [
"pcie-brcmstb" # PCIe bus (USB3, NVMe)
"reset-raspberrypi" # required for vl805 firmware
"usb-storage"
"usbhid"
"vc4" # VideoCore (needed even headless for boot)
];
# sd-image-aarch64.nix lists modules for many SoCs (including sun4i-drm
# for Allwinner boards) that don't exist in linux_rpi4. Allow missing.
boot.initrd.includeDefaultModules = false;
hardware.deviceTree.filter = "bcm2711-rpi-*.dtb";
hardware.firmware = [
(pkgs.callPackage "${nixos-hardware}/raspberry-pi/common/raspberry-pi-wireless-firmware.nix" {})
];
};
mkHost = { hostPath, extraModules ? [] }:
nixos-raspberrypi.lib.nixosSystem {
nixpkgs.lib.nixosSystem {
specialArgs = commonArgs;
modules = [
sops-nix.nixosModules.sops
# RPi 4 base: vendor kernel (linuxPackages_rpi4), firmware,
# bootloader (u-boot), initrd modules, config.txt management
nixos-raspberrypi.nixosModules.raspberry-pi-4.base
# SD image target — provides system.build.sdImage
({ modulesPath, ... }: {
imports = [ "${modulesPath}/installer/sd-card/sd-image-aarch64.nix" ];
})
rpi4Headless
hostPath
./modules/common.nix
./modules/storage.nix
@@ -81,62 +76,17 @@
in {
nixosConfigurations = {
# Bootstrap image — flash this first.
# Minimal: SSH key, WiFi, static IP. No sops, no services.
# Purpose: boot the Pi, generate the age key, then deploy pi-main.
pi-main-bootstrap = nixos-raspberrypi.lib.nixosSystem {
# Bootstrap image — flash this first, then deploy pi-main.
# See hosts/pi-main-bootstrap/default.nix for details.
pi-main-bootstrap = nixpkgs.lib.nixosSystem {
specialArgs = commonArgs;
modules = [
nixos-raspberrypi.nixosModules.raspberry-pi-4.base
rpi4Headless
({ modulesPath, ... }: {
imports = [ "${modulesPath}/installer/sd-card/sd-image-aarch64.nix" ];
})
./hosts/pi-main/hardware.nix
({ pkgs, lib, ... }: {
networking.hostName = "pi-main";
time.timeZone = commonArgs.homeyConfig.timezone;
i18n.defaultLocale = "en_US.UTF-8";
system.stateVersion = "25.05";
nix.settings.experimental-features = [ "nix-command" "flakes" ];
nixpkgs.config.allowUnfree = true;
# WiFi — PSK inline (bootstrap only, not in Nix store long-term)
networking.wireless = {
enable = true;
networks."Zakobar".psk = "0502711157";
};
networking.interfaces.wlan0.ipv4.addresses = [{
address = "192.168.1.100";
prefixLength = 24;
}];
networking.useDHCP = false;
networking.interfaces.wlan0.useDHCP = false;
networking.defaultGateway = "192.168.1.1";
networking.nameservers = [ "1.1.1.1" "8.8.8.8" ];
networking.firewall.allowedTCPPorts = [ 22 ];
# SSH — key only, no passwords, no root
services.openssh = {
enable = true;
settings = {
PasswordAuthentication = false;
PermitRootLogin = "no";
};
};
users.mutableUsers = false;
users.users.admin = {
isNormalUser = true;
extraGroups = [ "wheel" ];
openssh.authorizedKeys.keys = [
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDBFZRqiTsOCAJPMqUyMeLd2MbyjdGoyqDVq5/Inhb6EOaM1NUGG4b6FPmYgFLyJIm5LC9BOo6M7npiaiOs/zMqp+hoGLNQUNwm5/G0uy1bjkEfKdUTdGnJ2+M9rkxrR1c+KXrjkiqECqTbnPE4mJbGyVxBW2MwMeP5w8c0DB5KO528PetvHMPPQuEdXyZzDI4kKtVpMlJoPIrIGlNFX0G/wrgXcM4zU1snOTuYGqZnWW++4kBsgIlRKpf/bLJyUMTp30eLVr0fQ6OMBtj1tzUUBaaowU6VGYQQDU/rIh/NpkA2cEVPXZegM4OohkAqrJBFPIAg90WD9Z/SyQlz0Jn8PpAloP0Cuq2vVRr+QLEwxqGiFq91YQ2VtwksMHwJGVrXRCNegpxTZQijWMEd+o0FD2cEd7Ftw6v2L6g12GJ3QGX/q0d/u0GongLLa9fPXl4VoAu7AL+cUcbX/SS7RCG8kYAR3DwOazVbK0NWEdwvWdoSU4lZ3j2at1xqMGjHjyLiTeUqZBjm+Sl5MJWIYNg+8hnONljvggg4SzDFDAkgVLZtOCaZibsMA1ucGR7VRCM09uoaEI4/ZS5pCBtYcp8X67Bv67Og8s2NFf5sUfYBPPKpdBSs+dEPycNVff6JlmzfNiyzLawacGKIDWYSgkOl43N/5ehtpsL3HMZ+5SVNIw=="
];
};
security.sudo.wheelNeedsPassword = false;
environment.systemPackages = [ pkgs.age pkgs.vim ];
})
./hosts/pi-main-bootstrap/default.nix
];
};
+70
View File
@@ -0,0 +1,70 @@
{ pkgs, lib, homeyConfig, ... }:
# Bootstrap image for the primary Raspberry Pi 4.
#
# Flash this image first. Its only purpose is to boot the Pi so you can:
# 1. Generate the age key: sudo age-keygen -o /var/lib/sops-nix/key.txt
# 2. Print the pubkey: sudo age-keygen -y /var/lib/sops-nix/key.txt
# 3. Add the pubkey to .sops.yaml, re-encrypt secrets, then deploy pi-main.
#
# No sops, no services, no external HD — just SSH + WiFi.
#
# WiFi PSK: uncomment and fill in before building. Do not commit the password.
# networks."YourSSID".psk = "your-wifi-password";
{
networking.hostName = "pi-main";
time.timeZone = homeyConfig.timezone;
i18n.defaultLocale = "en_US.UTF-8";
system.stateVersion = "25.05";
nix.settings = {
experimental-features = [ "nix-command" "flakes" ];
substituters = [
"https://cache.nixos.org"
"https://nix-community.cachix.org"
];
trusted-public-keys = [
"cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
"nix-community.cachix.org-1:mB9FkXj6Q3Q4ohOcbM4FJ9Z1X2kCrVK4vZOqsDqqNqk="
];
};
nixpkgs.config.allowUnfree = true;
# linux_rpi4 is pre-built in cache.nixos.org — fetched, not compiled.
boot.kernelPackages = pkgs.linuxKernel.packages.linux_rpi4;
networking.wireless = {
enable = true;
# networks."Zakobar".psk = "your-wifi-password";
};
networking.interfaces.wlan0.ipv4.addresses = [{
address = "192.168.1.100";
prefixLength = 24;
}];
networking.useDHCP = false;
networking.interfaces.wlan0.useDHCP = false;
networking.defaultGateway = "192.168.1.1";
networking.nameservers = [ "1.1.1.1" "8.8.8.8" ];
networking.firewall.allowedTCPPorts = [ 22 ];
services.openssh = {
enable = true;
settings = {
PasswordAuthentication = false;
PermitRootLogin = "no";
};
};
users.mutableUsers = false;
users.users.admin = {
isNormalUser = true;
extraGroups = [ "wheel" ];
openssh.authorizedKeys.keys = [
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDfzDDO5juINctECmWlsYtGghEiX/RnTJ1cazLvOWSrPfsTyEd+B1+Ig8kFefNryjkpApfRXqj5KtLPNlpLfdVBrOIfhIveEp2MGqhgOGZFNVxQyXnZgii8Zdh4cqZ2O3pZpMsaAQBaJ9nH6dK0dJjicWT5f6TqwrVcInywRc5SuyizoSxoFmg7ch2rnlVi0j5XMVqdh8XLzHXZ7yWCzXy7+hWl/d7pwpyuzoK8dBw2EU9TauhgRDruom5Q9vWJTLStALC9pAIb0v9UFj9y+1zwx7pXsXp5F1g73EYrE4QR+QQ6z2LebuK280W0t+VA/fSCEB13DnkmofgqZQxX5MSCmrxZ5lTFp1FjW6yJo7As9FheF/GECowYkMRIx4IiQsjjHjZqlLRpLas11yAp6tGoZnw59hFo6Lu0Kva39jGVVmioYHtAeE5rD5w+v5kseJR4jlQ8aKB5yOjYUQOIz2AHQyoidgaeR2jPWqZUeRQbACI+/p3CHO45r3hrjATtGloBg0xF95Qws7Be3mjHVhbBLOoob8MdZ8nYAGnhlWrZphlkvXsHC6OUkuDJW00tmMjWXRlFwhFJ+nqUQCgLVjxVHQJ5rq9GeXBUuNXAeCm5BKBsdq+9qqVlt7D9iGyfr0lcZ7peKz/96KwPCWpG2En1Ur0/cVcbWnXEfG/xWO10tQ== cardno:24_758_470"
];
};
security.sudo.wheelNeedsPassword = false;
environment.systemPackages = [ pkgs.age pkgs.vim ];
}
+12 -8
View File
@@ -9,6 +9,10 @@
./hardware.nix
];
# linux_rpi4 is the Raspberry Pi Foundation's kernel, sourced from nixpkgs
# and pre-built in cache.nixos.org. Avoids a multi-hour native compilation.
boot.kernelPackages = pkgs.linuxKernel.packages.linux_rpi4;
# -------------------------------------------------------------------------
# Identity
# -------------------------------------------------------------------------
@@ -50,7 +54,7 @@
extraGroups = [ "wheel" "podman" ];
# Paste your SSH public key here
openssh.authorizedKeys.keys = [
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDBFZRqiTsOCAJPMqUyMeLd2MbyjdGoyqDVq5/Inhb6EOaM1NUGG4b6FPmYgFLyJIm5LC9BOo6M7npiaiOs/zMqp+hoGLNQUNwm5/G0uy1bjkEfKdUTdGnJ2+M9rkxrR1c+KXrjkiqECqTbnPE4mJbGyVxBW2MwMeP5w8c0DB5KO528PetvHMPPQuEdXyZzDI4kKtVpMlJoPIrIGlNFX0G/wrgXcM4zU1snOTuYGqZnWW++4kBsgIlRKpf/bLJyUMTp30eLVr0fQ6OMBtj1tzUUBaaowU6VGYQQDU/rIh/NpkA2cEVPXZegM4OohkAqrJBFPIAg90WD9Z/SyQlz0Jn8PpAloP0Cuq2vVRr+QLEwxqGiFq91YQ2VtwksMHwJGVrXRCNegpxTZQijWMEd+o0FD2cEd7Ftw6v2L6g12GJ3QGX/q0d/u0GongLLa9fPXl4VoAu7AL+cUcbX/SS7RCG8kYAR3DwOazVbK0NWEdwvWdoSU4lZ3j2at1xqMGjHjyLiTeUqZBjm+Sl5MJWIYNg+8hnONljvggg4SzDFDAkgVLZtOCaZibsMA1ucGR7VRCM09uoaEI4/ZS5pCBtYcp8X67Bv67Og8s2NFf5sUfYBPPKpdBSs+dEPycNVff6JlmzfNiyzLawacGKIDWYSgkOl43N/5ehtpsL3HMZ+5SVNIw=="
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDfzDDO5juINctECmWlsYtGghEiX/RnTJ1cazLvOWSrPfsTyEd+B1+Ig8kFefNryjkpApfRXqj5KtLPNlpLfdVBrOIfhIveEp2MGqhgOGZFNVxQyXnZgii8Zdh4cqZ2O3pZpMsaAQBaJ9nH6dK0dJjicWT5f6TqwrVcInywRc5SuyizoSxoFmg7ch2rnlVi0j5XMVqdh8XLzHXZ7yWCzXy7+hWl/d7pwpyuzoK8dBw2EU9TauhgRDruom5Q9vWJTLStALC9pAIb0v9UFj9y+1zwx7pXsXp5F1g73EYrE4QR+QQ6z2LebuK280W0t+VA/fSCEB13DnkmofgqZQxX5MSCmrxZ5lTFp1FjW6yJo7As9FheF/GECowYkMRIx4IiQsjjHjZqlLRpLas11yAp6tGoZnw59hFo6Lu0Kva39jGVVmioYHtAeE5rD5w+v5kseJR4jlQ8aKB5yOjYUQOIz2AHQyoidgaeR2jPWqZUeRQbACI+/p3CHO45r3hrjATtGloBg0xF95Qws7Be3mjHVhbBLOoob8MdZ8nYAGnhlWrZphlkvXsHC6OUkuDJW00tmMjWXRlFwhFJ+nqUQCgLVjxVHQJ5rq9GeXBUuNXAeCm5BKBsdq+9qqVlt7D9iGyfr0lcZ7peKz/96KwPCWpG2En1Ur0/cVcbWnXEfG/xWO10tQ== cardno:24_758_470"
];
};
@@ -62,7 +66,7 @@
homey.storage = {
# Replace with the actual by-id path of your USB drive.
# Find it: ls -la /dev/disk/by-id/ | grep -v part
device = "/dev/disk/by-id/usb-WD_Ext_HDD_1021_5743415A4146313531393031-0:0-part1";
device = "/dev/disk/by-label/homey-data";
mountPoint = "/mnt/data";
fsType = "ext4";
};
@@ -98,14 +102,14 @@
# -------------------------------------------------------------------------
# Local DNS overrides (optional — makes LAN clients hit the Pi directly
# instead of going through Cloudflare for *.home.zakobar.com)
# instead of going through Cloudflare for *.zakobar.com)
# -------------------------------------------------------------------------
# If you run Pi-hole or Adguard, add these records there instead.
# networking.extraHosts = ''
# 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 zakobar.com
# 192.168.1.100 auth.zakobar.com
# 192.168.1.100 git.zakobar.com
# 192.168.1.100 nextcloud.zakobar.com
# 192.168.1.100 ldapadmin.zakobar.com
# '';
}
+78 -22
View File
@@ -3,7 +3,7 @@
# Caddy reverse proxy.
#
# Features:
# - DNS-01 ACME via Cloudflare API → real wildcard cert for *.home.zakobar.com
# - DNS-01 ACME via Cloudflare API → real wildcard cert for *.zakobar.com
# - forward_auth to Authelia for protected vhosts
# - Plain reverse_proxy for public vhosts (authelia itself, nextcloud)
# - Listens on :80 (redirect) and :443 (TLS)
@@ -23,11 +23,23 @@ let
# under the hood to produce a fixed-output derivation.
caddyWithCloudflare = pkgs.caddy.withPlugins {
plugins = [
"github.com/caddy-dns/cloudflare@v0.2.2-0.20250724223520-f589a18c0f5d"
# v0.2.4 tag points to commit a8737d0 which includes the fix for
# cfut_/cfat_ token format validation (PR #123).
"github.com/caddy-dns/cloudflare@v0.2.4"
];
hash = "sha256-2Fb2fgM7YhWk9kBnnNGb85MJkAkgzXiI1fb6eK3ykIE=";
hash = "sha256-pRrLBlYRaAyMYwPXeTy4WqWNRu/L9K6Mn2src11dGh8=";
};
# Reverse-proxy snippet for cloudflared http:// vhosts.
# Cloudflare terminates TLS; cloudflared connects to Caddy over plain HTTP.
# We must override X-Forwarded-Proto so upstream services (especially
# Authelia) know the client is actually on HTTPS.
cfProxy = port: ''
reverse_proxy localhost:${toString port} {
header_up X-Forwarded-Proto https
}
'';
# Reusable Authelia forward_auth snippet
# Returns a Caddyfile snippet block that applies forward_auth.
# copy_headers makes Authelia's Remote-* headers available downstream.
@@ -35,6 +47,9 @@ let
forward_auth localhost:9091 {
uri /api/verify?rd=https://auth.${domain}
copy_headers Remote-User Remote-Name Remote-Groups Remote-Email
# Always tell Authelia the scheme is https (cloudflared terminates TLS
# externally; Caddy's http:// vhosts are only for the tunnel loopback).
header_up X-Forwarded-Proto https
# On auth failure, redirect to the authelia login page
@goauth status 401
handle_response @goauth {
@@ -77,7 +92,15 @@ in
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
'';
# Each virtual host
# Each virtual host.
#
# Each service gets two vhost entries:
# - "host" (no scheme) → Caddy handles HTTPS + auto cert (for LAN access)
# - "http://host" → plain HTTP for cloudflared on loopback (no redirect)
#
# Caddy auto-redirects HTTP→HTTPS only when no explicit http:// vhost exists.
# By defining http:// explicitly we suppress that redirect so cloudflared
# (which talks plain HTTP on port 80) gets a direct response.
virtualHosts = {
# ------------------------------------------------------------------
@@ -88,21 +111,25 @@ in
reverse_proxy localhost:9091
'';
};
"http://auth.${domain}" = {
extraConfig = cfProxy 9091;
};
# ------------------------------------------------------------------
# Gitea — protected behind one_factor Authelia
# Gitea — no forward_auth; git HTTP clients can't handle SSO redirects.
# Access control is handled by Gitea itself (LDAP auth + private repos).
# ------------------------------------------------------------------
"git.${domain}" = {
extraConfig = ''
${autheliaForwardAuth}
reverse_proxy localhost:3000
'';
};
"http://git.${domain}" = {
extraConfig = cfProxy 3000;
};
# ------------------------------------------------------------------
# Nextcloud — public auth (Nextcloud manages its own users + LDAP)
# Authelia is not gating nextcloud directly because NC has its own
# login flow. We still want HTTPS.
# ------------------------------------------------------------------
"nextcloud.${domain}" = {
extraConfig = ''
@@ -118,6 +145,18 @@ in
reverse_proxy localhost:8080
'';
};
"http://nextcloud.${domain}" = {
extraConfig = ''
redir /.well-known/carddav /remote.php/dav/ 301
redir /.well-known/caldav /remote.php/dav/ 301
request_body {
max_size 5GB
}
reverse_proxy localhost:8080 {
header_up X-Forwarded-Proto https
}
'';
};
# ------------------------------------------------------------------
# phpLDAPadmin — two_factor, admins only (enforced by authelia policy)
@@ -128,16 +167,25 @@ in
reverse_proxy localhost:8081
'';
};
"http://ldapadmin.${domain}" = {
extraConfig = ''
${autheliaForwardAuth}
${cfProxy 8081}
'';
};
# ------------------------------------------------------------------
# Jellyfin — one_factor (added when enabled)
# Jellyfin — no forward_auth; Jellyfin has its own login UI and
# native app clients can't handle SSO redirects.
# ------------------------------------------------------------------
"jellyfin.${domain}" = {
extraConfig = ''
${autheliaForwardAuth}
reverse_proxy localhost:8096
'';
};
"http://jellyfin.${domain}" = {
extraConfig = cfProxy 8096;
};
# ------------------------------------------------------------------
# Transmission — two_factor, admins only (enforced by authelia policy)
@@ -149,6 +197,12 @@ in
'';
# NOTE: transmission is bound to 9092 to avoid clash with authelia on 9091.
};
"http://torrent.${domain}" = {
extraConfig = ''
${autheliaForwardAuth}
${cfProxy 9092}
'';
};
};
};
@@ -163,18 +217,20 @@ in
# -----------------------------------------------------------------------
systemd.services.caddy = {
serviceConfig = {
EnvironmentFile = "/run/caddy-secrets.env";
ExecStartPre = [
(pkgs.writeShellScript "caddy-inject-cf-token" ''
install -m 0600 /dev/null /run/caddy-secrets.env
printf 'CLOUDFLARE_API_TOKEN=%s\n' \
"$(cat ${config.sops.secrets."cloudflare/api_token".path})" \
> /run/caddy-secrets.env
'')
];
ExecStopPost = [
(pkgs.writeShellScript "caddy-cleanup-env" ''
rm -f /run/caddy-secrets.env
# LoadCredential stages the sops-decrypted secret into a
# per-invocation directory ($CREDENTIALS_DIRECTORY) before any
# Exec* step. ExecStart then reads the file contents and exports
# CLOUDFLARE_API_TOKEN before exec-ing caddy, so there is no
# intermediate env file and no ordering race with EnvironmentFile.
LoadCredential = "cloudflare_api_token:${config.sops.secrets."cloudflare/api_token".path}";
# Systemd requires clearing ExecStart= before setting a new value for
# non-oneshot services. The empty string resets the list; the second
# entry is the actual start command.
ExecStart = lib.mkForce [
""
(pkgs.writeShellScript "caddy-start" ''
export CLOUDFLARE_API_TOKEN=$(cat "$CREDENTIALS_DIRECTORY/cloudflare_api_token")
exec ${caddyWithCloudflare}/bin/caddy run --environ --config /etc/caddy/caddy_config --adapter caddyfile
'')
];
};
+6 -6
View File
@@ -14,12 +14,12 @@
# 2. Name it (e.g. "pi-main")
# 3. Copy the tunnel token — add it to secrets.yaml as cloudflare/tunnel_token
# 4. In the tunnel's "Public Hostnames" config, add routes:
# auth.home.zakobar.com → http://localhost:80 (or 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
# auth.zakobar.com → http://localhost:80 (or https://localhost:443)
# git.zakobar.com → https://localhost:443
# nextcloud.zakobar.com → https://localhost:443
# ldapadmin.zakobar.com → https://localhost:443
# jellyfin.zakobar.com → https://localhost:443
# torrent.zakobar.com → https://localhost:443
# Set "No TLS Verify" = true (Caddy's cert is from Let's Encrypt but
# the hostname seen by cloudflared is localhost, so hostname verification
# would fail without this flag).
-3
View File
@@ -16,13 +16,10 @@
substituters = [
"https://cache.nixos.org"
"https://nix-community.cachix.org"
# Pre-built RPi vendor kernel + firmware (linuxPackages_rpi4, etc.)
"https://nixos-raspberrypi.cachix.org"
];
trusted-public-keys = [
"cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
"nix-community.cachix.org-1:mB9FkXj6Q3Q4ohOcbM4FJ9Z1X2kCrVK4vZOqsDqqNqk="
"nixos-raspberrypi.cachix.org-1:4iMO9LXa8BqhU+Rpg6LQKiGa2lsNh/j2oiYLNOQ5sPI="
];
};
gc = {
+2 -2
View File
@@ -23,7 +23,7 @@ let
dataDir = config.homey.storage.mountPoint;
domain = homeyConfig.domain;
# LDAP base DN derived from domain: home.zakobar.com → dc=home,dc=zakobar,dc=com
# LDAP base DN derived from domain: zakobar.com → dc=zakobar,dc=com
ldapBaseDN = lib.concatStringsSep ","
(map (p: "dc=${p}") (lib.splitString "." domain));
@@ -162,7 +162,7 @@ in
virtualisation.oci-containers.containers.authelia = {
image = cfg.image;
ports = [ "127.0.0.1:${toString cfg.port}:9091" ];
# No ports mapping — --network=host shares the host network stack directly.
environment = {
TZ = homeyConfig.timezone;
+145 -114
View File
@@ -9,7 +9,15 @@
# Volume layout:
# <dataDir>/gitea/data/ → /data (repos, sqlite db, avatars, lfs, etc.)
#
# The app.ini is rendered by Nix and bind-mounted read-only.
# Configuration strategy: all settings are passed as GITEA__<SECTION>__<KEY>
# environment variables. Gitea writes its own app.ini into /data/gitea/conf/
# on first start; the env vars override every key at runtime without touching
# that file. This avoids the bind-mount / read-only-fs problem where Gitea
# needs to rewrite its own config file on startup.
#
# Non-secret settings go in the `environment` block (they are fine in the
# Nix store). Secret settings go into /run/gitea-secrets.env via ExecStartPre
# (never in the store).
#
# Secrets consumed from sops:
# gitea/admin_password
@@ -21,98 +29,6 @@ let
cfg = config.homey.gitea;
dataDir = config.homey.storage.mountPoint;
domain = homeyConfig.domain;
# Gitea app.ini — generated at build time.
# Secrets that Gitea reads from env vars are referenced as env var names here.
# The actual values are injected by the ExecStartPre wrapper below.
giteaAppIni = ''
APP_NAME = ${homeyConfig.organization}
RUN_MODE = prod
RUN_USER = git
WORK_PATH = /data/gitea
[repository]
ROOT = /data/git/repositories
[repository.local]
LOCAL_COPY_PATH = /data/gitea/tmp/local-repo
[repository.upload]
TEMP_PATH = /data/gitea/uploads
[server]
APP_DATA_PATH = /data/gitea
DOMAIN = git.${domain}
HTTP_PORT = 3000
ROOT_URL = https://git.${domain}/
DISABLE_SSH = true
LFS_START_SERVER = true
; LFS_JWT_SECRET injected at container start via env var / startup script
LFS_JWT_SECRET = __GITEA_LFS_JWT_SECRET__
OFFLINE_MODE = false
[lfs]
PATH = /data/git/lfs
[database]
DB_TYPE = sqlite3
PATH = /data/gitea/gitea.db
LOG_SQL = false
[indexer]
ISSUE_INDEXER_PATH = /data/gitea/indexers/issues.bleve
[session]
PROVIDER_CONFIG = /data/gitea/sessions
PROVIDER = file
[picture]
AVATAR_UPLOAD_PATH = /data/gitea/avatars
REPOSITORY_AVATAR_UPLOAD_PATH = /data/gitea/repo-avatars
DISABLE_GRAVATAR = false
[attachment]
PATH = /data/gitea/attachments
[log]
MODE = console
LEVEL = info
ROUTER = console
ROOT_PATH = /data/gitea/log
[security]
INSTALL_LOCK = true
REVERSE_PROXY_LIMIT = 1
REVERSE_PROXY_TRUSTED_PROXIES = *
; INTERNAL_TOKEN injected at container start
INTERNAL_TOKEN = __GITEA_INTERNAL_TOKEN__
[service]
DISABLE_REGISTRATION = true
REQUIRE_SIGNIN_VIEW = false
REGISTER_EMAIL_CONFIRM = false
ENABLE_NOTIFY_MAIL = false
ALLOW_ONLY_EXTERNAL_REGISTRATION = true
ENABLE_CAPTCHA = false
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
DEFAULT_ENABLE_TIMETRACKING = true
NO_REPLY_ADDRESS = noreply.localhost
ENABLE_REVERSE_PROXY_AUTHENTICATION = true
ENABLE_REVERSE_PROXY_AUTO_REGISTRATION = true
[mailer]
ENABLED = false
[openid]
ENABLE_OPENID_SIGNIN = false
ENABLE_OPENID_SIGNUP = false
[oauth2]
ENABLE = false
; JWT_SECRET injected at container start
JWT_SECRET = __GITEA_OAUTH2_JWT_SECRET__
'';
in
{
options.homey.gitea = {
@@ -139,60 +55,175 @@ in
sops.secrets."gitea/oauth2_jwt_secret" = { owner = "root"; };
sops.secrets."gitea/internal_token" = { owner = "root"; };
# -----------------------------------------------------------------------
# Write the app.ini template to /etc (will be processed by ExecStartPre)
# -----------------------------------------------------------------------
environment.etc."gitea/app.ini.tpl" = {
text = giteaAppIni;
mode = "0444";
};
# -----------------------------------------------------------------------
# Container
# -----------------------------------------------------------------------
virtualisation.oci-containers.containers.gitea = {
image = cfg.image;
ports = [ "127.0.0.1:${toString cfg.port}:3000" ];
# No ports mapping — --network=host means the container shares the host
# network stack directly. Gitea binds to 0.0.0.0:3000 on the host.
# All non-secret settings via GITEA__<SECTION>__<KEY> env vars.
# These are safe to store in the Nix store.
environment = {
USER_UID = "1000";
USER_GID = "1000";
# Tell gitea where to look for the config
GITEA_CUSTOM = "/data/gitea";
# [DEFAULT]
GITEA____APP_NAME = homeyConfig.organization;
GITEA____RUN_MODE = "prod";
# [repository]
GITEA__repository__ROOT = "/data/git/repositories";
# [server]
GITEA__server__APP_DATA_PATH = "/data/gitea";
GITEA__server__DOMAIN = "git.${domain}";
GITEA__server__HTTP_PORT = toString cfg.port;
GITEA__server__ROOT_URL = "https://git.${domain}/";
GITEA__server__DISABLE_SSH = "true";
GITEA__server__START_SSH_SERVER = "false";
GITEA__server__SSH_PORT = "2222";
GITEA__server__SSH_LISTEN_PORT = "2222";
GITEA__server__LFS_START_SERVER = "true";
GITEA__server__OFFLINE_MODE = "false";
# [lfs]
GITEA__lfs__PATH = "/data/git/lfs";
# [database]
GITEA__database__DB_TYPE = "sqlite3";
GITEA__database__PATH = "/data/gitea/gitea.db";
GITEA__database__LOG_SQL = "false";
# [indexer]
GITEA__indexer__ISSUE_INDEXER_PATH = "/data/gitea/indexers/issues.bleve";
# [session]
GITEA__session__PROVIDER = "file";
GITEA__session__PROVIDER_CONFIG = "/data/gitea/sessions";
# [picture]
GITEA__picture__AVATAR_UPLOAD_PATH = "/data/gitea/avatars";
GITEA__picture__REPOSITORY_AVATAR_UPLOAD_PATH = "/data/gitea/repo-avatars";
GITEA__picture__DISABLE_GRAVATAR = "false";
# [attachment]
GITEA__attachment__PATH = "/data/gitea/attachments";
# [log]
GITEA__log__MODE = "console";
GITEA__log__LEVEL = "info";
GITEA__log__ROOT_PATH = "/data/gitea/log";
# [security]
GITEA__security__INSTALL_LOCK = "true";
GITEA__security__REVERSE_PROXY_LIMIT = "1";
GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES = "*";
# [service]
GITEA__service__DISABLE_REGISTRATION = "true";
GITEA__service__REQUIRE_SIGNIN_VIEW = "false";
GITEA__service__REGISTER_EMAIL_CONFIRM = "false";
GITEA__service__ENABLE_NOTIFY_MAIL = "false";
GITEA__service__ALLOW_ONLY_EXTERNAL_REGISTRATION = "true";
GITEA__service__ENABLE_CAPTCHA = "false";
GITEA__service__DEFAULT_ALLOW_CREATE_ORGANIZATION = "true";
GITEA__service__DEFAULT_ENABLE_TIMETRACKING = "true";
GITEA__service__NO_REPLY_ADDRESS = "noreply.localhost";
GITEA__service__ENABLE_REVERSE_PROXY_AUTHENTICATION = "true";
GITEA__service__ENABLE_REVERSE_PROXY_AUTO_REGISTRATION = "true";
# [mailer]
GITEA__mailer__ENABLED = "false";
# [openid]
GITEA__openid__ENABLE_OPENID_SIGNIN = "false";
GITEA__openid__ENABLE_OPENID_SIGNUP = "false";
# [oauth2]
GITEA__oauth2__ENABLED = "false";
};
# Secret env vars written at runtime by ExecStartPre — never in store.
environmentFiles = [ "/run/gitea-secrets.env" ];
volumes = [
"${dataDir}/gitea/data:/data"
# The processed app.ini is written by ExecStartPre into /run/gitea-conf/
"/run/gitea-conf/app.ini:/data/gitea/conf/app.ini:ro"
];
extraOptions = [ "--network=host" ];
};
# -----------------------------------------------------------------------
# ExecStartPre: substitute secret placeholders into the ini template
# ExecStartPre: write ephemeral secrets env file
# ExecStopPost: clean it up
# -----------------------------------------------------------------------
systemd.services."podman-gitea" = {
serviceConfig = {
ExecStartPre = [
(pkgs.writeShellScript "gitea-build-config" ''
(pkgs.writeShellScript "gitea-write-secrets" ''
set -euo pipefail
install -d -m 700 /run/gitea-conf
LFS=$(cat ${config.sops.secrets."gitea/lfs_jwt_secret".path})
OAUTH=$(cat ${config.sops.secrets."gitea/oauth2_jwt_secret".path})
TOKEN=$(cat ${config.sops.secrets."gitea/internal_token".path})
sed \
-e "s|__GITEA_LFS_JWT_SECRET__|$LFS|g" \
-e "s|__GITEA_OAUTH2_JWT_SECRET__|$OAUTH|g" \
-e "s|__GITEA_INTERNAL_TOKEN__|$TOKEN|g" \
/etc/gitea/app.ini.tpl > /run/gitea-conf/app.ini
chmod 444 /run/gitea-conf/app.ini
printf '%s\n' \
"GITEA__server__LFS_JWT_SECRET=$LFS" \
"GITEA__security__INTERNAL_TOKEN=$TOKEN" \
"GITEA__oauth2__JWT_SECRET=$OAUTH" \
> /run/gitea-secrets.env
chmod 600 /run/gitea-secrets.env
'')
];
ExecStopPost = [
(pkgs.writeShellScript "gitea-cleanup-secrets" ''
rm -f /run/gitea-secrets.env
'')
];
};
after = lib.mkAfter [ "mnt-data.mount" "podman-openldap.service" ];
requires = lib.mkAfter [ "mnt-data.mount" ];
};
# -----------------------------------------------------------------------
# Ensure the Gitea admin user exists with the correct password after start.
# Runs as a oneshot after podman-gitea; idempotent (create or update).
# -----------------------------------------------------------------------
systemd.services."gitea-admin-setup" = {
description = "Ensure Gitea admin user exists with correct password";
wantedBy = [ "multi-user.target" ];
after = [ "podman-gitea.service" ];
requires = [ "podman-gitea.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
set -euo pipefail
PASS=$(cat ${config.sops.secrets."gitea/admin_password".path})
# Wait until Gitea's HTTP endpoint is up (max 60 s)
for i in $(seq 1 60); do
if ${pkgs.curl}/bin/curl -sf http://127.0.0.1:${toString cfg.port}/ -o /dev/null; then
break
fi
sleep 1
done
# Sync password if admin exists; create if not.
if ! ${pkgs.podman}/bin/podman exec -u 1000 gitea \
gitea admin user change-password --username admin --password "$PASS" 2>/dev/null; then
${pkgs.podman}/bin/podman exec -u 1000 gitea \
gitea admin user create \
--username admin \
--password "$PASS" \
--email "admin@${domain}" \
--admin
fi
'';
};
};
}
+1 -1
View File
@@ -30,7 +30,7 @@ in
config = lib.mkIf cfg.enable {
virtualisation.oci-containers.containers.jellyfin = {
image = cfg.image;
ports = [ "127.0.0.1:${toString cfg.port}:8096" ];
# No ports mapping — --network=host shares the host network stack directly.
environment = {
JELLYFIN_PublishedServerUrl = "https://jellyfin.${domain}";
+24 -9
View File
@@ -58,7 +58,7 @@ in
# -----------------------------------------------------------------------
virtualisation.oci-containers.containers.nextcloud-postgres = {
image = cfg.postgresImage;
ports = [ "127.0.0.1:${toString cfg.postgresPort}:5432" ];
# No ports mapping — --network=host shares the host network stack directly.
environment = {
POSTGRES_DB = "nextcloud_db";
@@ -70,20 +70,25 @@ in
"${dataDir}/nextcloud/db:/var/lib/postgresql/data"
];
extraOptions = [ "--network=host" ];
extraOptions = [
"--network=host"
"--env-file=/run/nc-postgres-secrets.env"
];
};
systemd.services."podman-nextcloud-postgres" = {
serviceConfig = {
LoadCredential = [
"nextcloud_postgres_password:${config.sops.secrets."nextcloud/postgres_password".path}"
];
ExecStartPre = [
(pkgs.writeShellScript "nc-postgres-secrets-env" ''
set -euo pipefail
install -m 600 /dev/null /run/nc-postgres-secrets.env
echo "POSTGRES_PASSWORD=$(cat ${config.sops.secrets."nextcloud/postgres_password".path})" \
echo "POSTGRES_PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/nextcloud_postgres_password")" \
>> /run/nc-postgres-secrets.env
'')
];
EnvironmentFile = "/run/nc-postgres-secrets.env";
};
postStop = "rm -f /run/nc-postgres-secrets.env";
after = lib.mkAfter [ "mnt-data.mount" ];
@@ -95,7 +100,7 @@ in
# -----------------------------------------------------------------------
virtualisation.oci-containers.containers.nextcloud = {
image = cfg.image;
ports = [ "127.0.0.1:${toString cfg.port}:80" ];
# No ports mapping — --network=host shares the host network stack directly.
environment = {
POSTGRES_HOST = "127.0.0.1";
@@ -105,6 +110,10 @@ in
NEXTCLOUD_TRUSTED_DOMAINS = "nextcloud.${domain}";
OVERWRITEPROTOCOL = "https";
OVERWRITECLIURL = "https://nextcloud.${domain}";
# With --network=host, port mappings are ignored and the container's
# Apache binds directly on the host. Force it onto port 8080 so Caddy
# can own 80/443.
APACHE_HTTP_PORT_NUMBER = toString cfg.port;
# Passwords injected via env file
};
@@ -112,20 +121,26 @@ in
"${dataDir}/nextcloud/html:/var/www/html"
];
extraOptions = [ "--network=host" ];
extraOptions = [
"--network=host"
"--env-file=/run/nc-secrets.env"
];
};
systemd.services."podman-nextcloud" = {
serviceConfig = {
LoadCredential = [
"nextcloud_postgres_password:${config.sops.secrets."nextcloud/postgres_password".path}"
"nextcloud_admin_password:${config.sops.secrets."nextcloud/admin_password".path}"
];
ExecStartPre = [
(pkgs.writeShellScript "nc-secrets-env" ''
set -euo pipefail
install -m 600 /dev/null /run/nc-secrets.env
echo "POSTGRES_PASSWORD=$(cat ${config.sops.secrets."nextcloud/postgres_password".path})" >> /run/nc-secrets.env
echo "NEXTCLOUD_ADMIN_PASSWORD=$(cat ${config.sops.secrets."nextcloud/admin_password".path})" >> /run/nc-secrets.env
echo "POSTGRES_PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/nextcloud_postgres_password")" >> /run/nc-secrets.env
echo "NEXTCLOUD_ADMIN_PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/nextcloud_admin_password")" >> /run/nc-secrets.env
'')
];
EnvironmentFile = "/run/nc-secrets.env";
};
postStop = "rm -f /run/nc-secrets.env";
after = lib.mkAfter [ "mnt-data.mount" "podman-nextcloud-postgres.service" ];
+21 -12
View File
@@ -50,8 +50,10 @@ in
virtualisation.oci-containers.containers.openldap = {
image = cfg.image;
# Bind only to localhost — no external exposure
ports = [ "127.0.0.1:${toString cfg.port}:389" ];
# No ports mapping — --network=host means the container shares the host
# network stack. OpenLDAP binds to 0.0.0.0:389, but the firewall
# (common.nix) only opens 22/80/443, so port 389 is unreachable from
# the LAN or internet.
environment = {
LDAP_ORGANISATION = homeyConfig.organization;
@@ -76,8 +78,8 @@ in
];
extraOptions = [
"--network=host" # simplest for single-host: services talk on 127.0.0.1
"--hostname=openldap"
"--network=host"
"--env-file=/run/openldap-secrets.env"
];
};
@@ -88,18 +90,25 @@ in
# podman-<container-name>.service
systemd.services."podman-openldap" = {
serviceConfig = {
# Write an env file with secret values before the container starts,
# then pass it to podman run via EnvironmentFile.
# LoadCredential stages the sops secrets into a per-invocation
# credential directory before any Exec* step, so they are available
# when ExecStartPre runs. ExecStartPre writes the env file that
# podman --env-file reads; this avoids the EnvironmentFile ordering
# race (EnvironmentFile is evaluated before ExecStartPre).
LoadCredential = [
"openldap_admin_password:${config.sops.secrets."openldap/admin_password".path}"
"openldap_config_password:${config.sops.secrets."openldap/config_password".path}"
"openldap_ro_password:${config.sops.secrets."openldap/ro_password".path}"
];
ExecStartPre = [
(pkgs.writeShellScript "openldap-secrets-env" ''
set -euo pipefail
install -m 600 /dev/null /run/openldap-secrets.env
echo "LDAP_ADMIN_PASSWORD=$(cat ${config.sops.secrets."openldap/admin_password".path})" >> /run/openldap-secrets.env
echo "LDAP_CONFIG_PASSWORD=$(cat ${config.sops.secrets."openldap/config_password".path})" >> /run/openldap-secrets.env
echo "LDAP_READONLY_USER_PASSWORD=$(cat ${config.sops.secrets."openldap/ro_password".path})" >> /run/openldap-secrets.env
echo "LDAP_ADMIN_PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/openldap_admin_password")" >> /run/openldap-secrets.env
echo "LDAP_CONFIG_PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/openldap_config_password")" >> /run/openldap-secrets.env
echo "LDAP_READONLY_USER_PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/openldap_ro_password")" >> /run/openldap-secrets.env
'')
];
EnvironmentFile = "/run/openldap-secrets.env";
};
# Clean up the env file on stop
postStop = "rm -f /run/openldap-secrets.env";
@@ -109,8 +118,8 @@ in
};
# -----------------------------------------------------------------------
# Firewall — openldap port is NOT opened externally (localhost only)
# Firewall — openldap port is NOT opened externally
# -----------------------------------------------------------------------
# No firewall rule needed; bound to 127.0.0.1.
# No firewall rule needed; common.nix only opens 22/80/443.
};
}
+11 -3
View File
@@ -5,6 +5,11 @@
# Stateless container (no persistent volumes needed).
# Protected by Authelia two_factor, admins-only policy (defined in authelia.nix).
# Bound to localhost:8081; Caddy reverse-proxies it.
#
# Networking: uses default bridge (podman) network with a port mapping
# 127.0.0.1:8081->80 so Caddy can reach it. OpenLDAP runs on the host
# network at 127.0.0.1:389; the container reaches it via the special
# host.containers.internal DNS name that podman injects automatically.
let
cfg = config.homey.phpldapadmin;
@@ -28,14 +33,17 @@ in
config = lib.mkIf cfg.enable {
virtualisation.oci-containers.containers.phpldapadmin = {
image = cfg.image;
ports = [ "127.0.0.1:${toString cfg.port}:80" ];
environment = {
PHPLDAPADMIN_HTTPS = "false";
PHPLDAPADMIN_LDAP_HOSTS = "127.0.0.1"; # openldap on host network
# host.containers.internal resolves to the host from inside a podman
# bridge container — reaches openldap which is on --network=host at :389
PHPLDAPADMIN_LDAP_HOSTS = "host.containers.internal";
};
extraOptions = [ "--network=host" ];
# Bridge network (default) + port mapping: Apache binds inside the
# container on :80, podman maps it to 127.0.0.1:8081 on the host.
ports = [ "127.0.0.1:${toString cfg.port}:80" ];
};
systemd.services."podman-phpldapadmin" = {
+6 -1
View File
@@ -35,11 +35,16 @@ in
config = lib.mkIf cfg.enable {
virtualisation.oci-containers.containers.transmission = {
image = cfg.image;
ports = [ "127.0.0.1:${toString cfg.port}:9091" ];
# No ports mapping — --network=host shares the host network stack directly.
environment = {
PUID = "1000";
PGID = "1000";
# With --network=host, port mappings are ignored; transmission binds
# directly on the host. Force it to cfg.port (9092) to avoid
# conflicting with Authelia on 9091.
TRANSMISSION_WEB_HOME = "/usr/share/transmission/web";
WEBUI_PORT = toString cfg.port;
};
volumes = [
+2 -2
View File
@@ -85,8 +85,8 @@ in
"d ${cfg.mountPoint}/openldap/var-lib-ldap 0750 root root -"
"d ${cfg.mountPoint}/authelia 0750 root root -"
"d ${cfg.mountPoint}/authelia/config 0750 root root -"
"d ${cfg.mountPoint}/gitea 0750 root root -"
"d ${cfg.mountPoint}/gitea/data 0750 root root -"
"d ${cfg.mountPoint}/gitea 0750 1000 1000 -"
"d ${cfg.mountPoint}/gitea/data 0750 1000 1000 -"
"d ${cfg.mountPoint}/nextcloud 0750 root root -"
"d ${cfg.mountPoint}/nextcloud/html 0750 root root -"
"d ${cfg.mountPoint}/nextcloud/db 0750 root root -"
+52 -49
View File
@@ -1,57 +1,60 @@
#ENC[AES256_GCM,data:+YPw0Wd8uB5cwY/cszPqLohVhIEbju4QwYVS9vT/ci2MxO7Glw2uJbtS6dOLHqpzfU0b0mJ4IqUIaM08yMEM/TeB7hG1ASHWe3+qxyw8,iv:V49PRq4bEcYvcPMUIvBQkhuq28pVxrlRfVXUSzPxnb4=,tag:OSNXutH5EUwA3BujNZ3FoA==,type:comment]
#ENC[AES256_GCM,data:QVC3QP3em1O3SYTAuK4kBchpTiwXH10f2R4YgK+t9QaiqZ1PWvo=,iv:R0lFtvg2T/Rllt1uiriTQvNbSw54jr0otU3E6XsIs00=,tag:9fAQCmuZZPUPLuDY8LZEUA==,type:comment]
#
#ENC[AES256_GCM,data:IT6BEo5CjYm+15aeWl+S8M3B+SSmjPnhBRvYToWzezIweTl3MGBXtalvkV3NWkxH0EaHpueOMe6r,iv:7BDTiljEa59F13Pephw6MM+sZgL4jbfQafJyt0UU3hY=,tag:ia+7WUAl/45jrYrv3Pylxg==,type:comment]
#
#ENC[AES256_GCM,data:zqAQYQCg/TRNtjDIdWTsgtRnQbijjYyLdQIAe9GkTubG9PSj7E8m7HFXmfG4eFNZR4S/Ql0dsM5gvLCu,iv:xSH8LMS7vqe2N9L/TOepKWhuIhVxmKN6kuB1iqUEOUw=,tag:rFYurrqfp1Zxggr5tiPKkQ==,type:comment]
#ENC[AES256_GCM,data:+YPw0Wd8uB5cwY/cszPqLohVhIEbju4QwYVS9vT/ci2MxO7Glw2uJbtS6dOLHqpzfU0b0mJ4IqUIaM08yMEM/TeB7hG1ASHWe3+qxyw8,iv:V49PRq4bEcYvcPMUIvBQkhuq28pVxrlRfVXUSzPxnb4=,tag:OSNXutH5EUwA3BujNZ3FoA==,type:comment]
#ENC[AES256_GCM,data:yj4R8Yetc6EHWvQDu2/eaoY=,iv:Zbqfg9NRHy6ab10kxzq6qsLb7VHfLxhcpP3vUt2i4ns=,tag:udBGjJUupeADD78JQ8BwuQ==,type:comment]
openldap/admin_password: ENC[AES256_GCM,data:DtVthpJqLdkI+5wxOMnCfBdqWkg0GSwUtsUeop24kd8=,iv:4e2Xn7B0M8yYEbs0V9ozn8WHJJMCBv6G46bdThufSXc=,tag:BsjKzh8teul6yLEKbvr93g==,type:str]
openldap/config_password: ENC[AES256_GCM,data:6b9TIgOcmZfMDAVbJuqOoNS9kyrss/LMvySLyNonlRk=,iv:Jf9/triFouIDv7MY2J9W8ji7E5lUHqzwgBMqrcPuK1g=,tag:zQYZSesPiPVeNVBN1oEiHA==,type:str]
openldap/ro_password: ENC[AES256_GCM,data:EHYUlIY24kY9K8opMi9MxSSosReZm5mEmbPFz+NdaXE=,iv:3pfVn4QDvJAVmWYWyX/Kko+K7nsE1yunLXN5uao+ea0=,tag:J954cH7a7Ey6Xq24ut5Jxw==,type:str]
#ENC[AES256_GCM,data:upG3X+Z7di17BaWBQ/P0ohY=,iv:k3Kin642n4cJYwfPsQYE/4FokELFNDmMzxJ2D8S28HI=,tag:uYRnpeoCrwGQOEYWo2cBiw==,type:comment]
authelia/jwt_secret: ENC[AES256_GCM,data:pXTQ06OGEP1oYFM0mkyL+c/zNRUMgL9x1fCQsMo2bak=,iv:mnOBWBrSn4gTfMXR5PCThs0v9QRDR5pfOQA8u0cuGnI=,tag:YXGq6Hmv/chw8fcEQoNlGA==,type:str]
authelia/session_secret: ENC[AES256_GCM,data:EgIyGv/K6xDCxOZWA9tzGoNS4m+p/EOPHL64/eN1oqwar2iJFSanbUfq8doHmN8n9sADmPIKUKaL8+WJWfyjtBBcCn74q5FL+kDu6ZYo4V5cjkj8jUhRC97TIJ+e0lVKFJ4s+i+/OcOsv2TPS/haylGHVn1fnlwvEd3kn/mO73w=,iv:6VPxOkriecJdtm2EBCiKkZBTzmas3DkQuYhivfygCT8=,tag:uXW1tcyAFSkiwMGNiZ663w==,type:str]
authelia/storage_encryption_key: ENC[AES256_GCM,data:pM8oQ4t0HQLdUvuRayLOpEwdxzRQlvCOrMtSPIU8Ryo=,iv:AK2jR3Ij/dBplDc1PYXXLK8P327CYRx3kVZUCcIkO5k=,tag:kJSuyOIzT4/RNQXEal1ODA==,type:str]
#ENC[AES256_GCM,data:teUPyCgpHCpIb0hXRUg=,iv:lTdYkYxQKHcJGE7lkkcsa8u9ZsZAVqpfauf5SzTv6G0=,tag:uKydCL14BvAaOpUHAMBirg==,type:comment]
gitea/admin_password: ENC[AES256_GCM,data:/39FQYn5GQoq/a5chLd4JUvSXTU8tOdzc9uXxNqViiw=,iv:Ysq2QUgkmONGsfj6xHKN3G/eitBX1rm9LLH9REF2h8g=,tag:eiVtlaB/6VdNMEBy4mSrTg==,type:str]
gitea/lfs_jwt_secret: ENC[AES256_GCM,data:gyd2OV0qcaaD6FTT9UwLV5vGJ4b/SNtG86oCQqUqB+DlZFLYe91YFNG/wA==,iv:fxD2NFbEYAsmrXaZT030f0MiAol2cwln0mIzLPCE+Lg=,tag:xQtehnHuj18WYeR2UyYeXw==,type:str]
gitea/oauth2_jwt_secret: ENC[AES256_GCM,data:M5CzWG1FbjheX4QwDajVsAMl2nyfe4Z1u30D5hjCQbScDBtuw123ZMZjGQ==,iv:vOnMShn9nmLPzxXJqTNnCIf6GT6CrV3lAKrepmI7btc=,tag:pTdrbmZ+hntuwaLiLyUNHQ==,type:str]
gitea/internal_token: ENC[AES256_GCM,data:ZbwvPcOseUHAGDr4dwNu9u+qcr0yYYGdH2OjcuXPtgUt7HFq1a9f0Faxiphsh+3OXb1KqLj8USB/1AxSvt5kSYM/vqzSLZ+e1OKy0oO3o8YouCJLhPNkNO6q0eguQF6+,iv:E3APR8h+iNECoThrvy6v4SEdAsfnPITXvhIFT1Ug5qA=,tag:lCxReGAxJyVhwMjxNenvxg==,type:str]
#ENC[AES256_GCM,data:r/uPlqg+7UGrM0G2xhmD6Bm1,iv:m/Ineh/mNfo1yUS+B8qtbMr1zRwiE6vw3EZIepB4QUA=,tag:/tB1W2JgyUQNvVWFM9478w==,type:comment]
nextcloud/admin_password: ENC[AES256_GCM,data:KwS0kEjTKn+IAtYTD17X4Y/3hT9bUgqKBQ0vfhDK99A=,iv:AbJfw6NWRnnB8zXIO6l3sIWiXXWfM1ePJ5bodNlgjgI=,tag:XSQM8SSnuh3wjyN3IQdArA==,type:str]
nextcloud/postgres_password: ENC[AES256_GCM,data:dsdqeQhWFvidqOXopetb3G54Ft56ZhPheTB7uG2JuVc=,iv:ubKH3ihlPXZjPSkvgEYn/teG5SNSh04nb4Lh1e2cX8o=,tag:DWNXJXWjpCU8QEcnt0+phA==,type:str]
#ENC[AES256_GCM,data:riBX18BPE4XMBBv20JIEJbM6JS80e1jwiDq44KXMB6T/4Eehf2bgcFUm,iv:lDYdL1IvaBuixcw1BzPQxnM4HYZGA3YSDrJTxvz0QWs=,tag:tux8Mt56yw+7hE7BfgOXVw==,type:comment]
cloudflare/api_token: ENC[AES256_GCM,data:te8SJz3sjnWX0MsacbEwYb0IC+SAlUBcSthLmHxpURTdpE3GfeNvjj5Z+il43cpFA33PaUY=,iv:XG2dt0Wc5jDcfGvKtRB1f6CAWXBmgnw+qqzMxDtmOok=,tag:PmEqZoKvqZm2vBxYSNH3Qg==,type:str]
cloudflare/tunnel_token: ENC[AES256_GCM,data:HupdN2MFeQ+NPwynI1SM07E7yA5b66lbudKt/pNOemf9Q3l4zrYidLFpiQk6L6ajQpM0WQbEDYG2I1sxybu4fUah79MSZO7BoolYy6l/NDE5G35e3Kw9Yu1cFAyNZJ9s/RU8nG24OAMX+pMOkjk4bX4tzrWUkHmebRJf7iBZxsSys6o83arpyKcucLOfTyyLSRemXF8IXr2MGMypHkPrx+4w5MnY9tyY8JcclaiLDkpbVVDUTarbkg==,iv:sVAnAqAMdTn8HpEwcIz2B57SrPlYqV2/Oi3sYHanYzo=,tag:BmhemprKvn33Wt595MjKcQ==,type:str]
#ENC[AES256_GCM,data:GpnZDeOAyr2pZxWHVd++1TMm230hvQ==,iv:jo8kWdd0Pm3d3xewCcyhauiBhI+SYIlWvczKn0PPZTg=,tag:INK0gZhKynkiOgi2ayrSMA==,type:comment]
restic/password: ENC[AES256_GCM,data:iZNRA8qNspy7WnK+Dg1OOZj9Gt2Y/AXUG1gKTBGUt+6q7T6Lv5AqbVkN8khwlKyQWK6FNLh3/9ejsM7mybiyog==,iv:XMxMAgVMdCWnDCkdTxL72pbrg8Dy0xz2EYou7AaNgS0=,tag:KW9Tjhql0yF6h81Il1htbw==,type:str]
restic/s3_access_key_id: ENC[AES256_GCM,data:XK8GqLHSC76K6z86RbqI4uNwZgcfl5R0Bg==,iv:t9+fGwwGX8PLwr30MJMYdOm02f/+XTcnMhSY1DP+nU0=,tag:fauNjH4lVtHa+L8Bfj8TOg==,type:str]
restic/s3_secret_access_key: ENC[AES256_GCM,data:GUx4FPaHWuzNwOju7CQoZc5U2SLG+3GOn0zJvvRXzQ==,iv:Oq0q9a+esPkLygMkGaFFNZOOfMGMFVPeb+yHUcLcNZE=,tag:Rwd0NNyXt+L8IJCCiDJh8w==,type:str]
#ENC[AES256_GCM,data:H+rGxOM6euNaSOval0ZXgKlRKQ==,iv:o0kU37iQzWAvTl5T9MK5RpHJ1eqhFftfVMEGMR40Hw8=,tag:rFcrmYZXpOpVdvW/zTul1A==,type:comment]
wifi/psk: ENC[AES256_GCM,data:bkZnP8S7yQlaEfH+kN1FfjQqJw==,iv:n1wOv6rXDbGucKryV9qV0fgqXNC/GwDeDlY2k9/hSOI=,tag:LdC2ahrXVBcqLWU5nFHMlQ==,type:str]
openldap:
admin_password: ENC[AES256_GCM,data:hg+Ly1bX4ao1AT4SDvQWXiT/KMzsz0wdnRauiB+FetE=,iv:TAX+NZCVUNiwMeBrW58IeI1OJX6rzzGAhWiQ+cZXreo=,tag:MrwYKKBb1Cg2JvADtQqYrQ==,type:str]
config_password: ENC[AES256_GCM,data:qKEurb0slGnr6nES7w7fTPDCy/DARns0BorDZMwpI/w=,iv:+p6Fh9a2g0eBueOxDk1J+hnM9fMgE6/NYwz+sAovGjE=,tag:kKZVsxdxdDACD9J0NAf4gQ==,type:str]
ro_password: ENC[AES256_GCM,data:82htWXdJ07tdZ81o7o9a4hizxcn39yQidD5e9PijVpo=,iv:fDT1chX4ZPIS02IMEW02haPa2IIlLFhgFOpUwD7KL50=,tag:9pSo/3vFikKQAe8jS+3Q6A==,type:str]
authelia:
jwt_secret: ENC[AES256_GCM,data:SwXd/mMsrgXItP8QZr4z9YaN1lgSSO4Cpdwl+XxFj6I=,iv:gAkyHKP5D5RGJ3X3hoh8oEJfYaFnvYxGAoKxe+G1N0I=,tag:NlfWC2pmjCYiiRn9prgl2w==,type:str]
session_secret: ENC[AES256_GCM,data:bbbgmxYLbtteuT628O+uSVeo7Gx3hI6uWVIV5l8AhtNSvXBHKb9i2NvQqJfVG8D2YR00+OW9XtreocpBuS7YJKp58cP/EgrF9x1im/8UaaifAYqD8YXZReUsBw+/PzIvqyA1K9tFd0coc8tSHJmwTwea4sf6Tc10N6j/nhNQfpQ=,iv:wrGE8XVsTINmf5505XVI7HHqA33w+SBh9lsJXD+YnwU=,tag:lvSoU+IRy8f+6VpYqzvacg==,type:str]
storage_encryption_key: ENC[AES256_GCM,data:8KnaWBTlSStdC/uI4GUOYP9DJygjfCTu62zWTU9eeVE=,iv:DNl0L2QgT1lNUDdPNm9bXcGvrLXLDtWdJ9pPgRH20C8=,tag:hVhcCl0Vrt0ZnaVGaUokSQ==,type:str]
gitea:
admin_password: ENC[AES256_GCM,data:WncwJlqb/3X5WZYgIXAu0niI0ISP0eHfmhsKDLeAvE4=,iv:JBZNZJRSHKG+cCoFNJBI+jS+/WcLueqJ/UN/7wXfK/0=,tag:f6+Lhfcxstq6pwS9xkVwqQ==,type:str]
lfs_jwt_secret: ENC[AES256_GCM,data:i05gr2ou03w0yu6/bhlJOW1huysAAPTidFEusWkhQfpDj4Pyh8LEKb09Og==,iv:aqkblyz0oIFHwzVCzlGDdQuCbsDPrfBaJMzgRTw+pYU=,tag:6gBSerOUK8Y3la/2Bg2AZQ==,type:str]
oauth2_jwt_secret: ENC[AES256_GCM,data:BVvQJCEfHPbemd1jz7MWpIRia1wfvPMGuLqoi/xUMSoQoN5RPefQnPR4Cg==,iv:JAZQUTxHZSnMEnl+BIZ1PXlznMwKuPtiPP/17rc6lSs=,tag:mUw5RuthZmZegXCtfsFNmQ==,type:str]
internal_token: ENC[AES256_GCM,data:gnOebJbRsh2Cues9WjGQp4rWa6OuE3xSnby9jc3Hk8ywvpL7CNWmlGW7zmmOyDAfIKfm8kf1FxotWLXtGZDretzdbMRM9c6gkwSJf5MCsdm27Er+IRKS/QFBuvLSTEH0,iv:aVgRvs3T3zCg+AV/BKUXQyZDKvunHvXsdfr9sqo2cI0=,tag:U9aP+N0CWyRQ/xJ27Vo2mw==,type:str]
nextcloud:
admin_password: ENC[AES256_GCM,data:iK6VoE94vFQmn3i4XQc5r/c03u3b0knDgBNK8d1qyns=,iv:P1wax2vAjn9iwBe9T7SN+pKrtrWcOYb5OWUyHF4hlVg=,tag:ET8KU4IKzhWqIDeRihwcag==,type:str]
postgres_password: ENC[AES256_GCM,data:ga4cwhYsAgEBvr+aDVwiRZXeT+TjXzeef1r3ud6uYHs=,iv:PMHCjO4wLW6PER4oGODEG9CHqrvVpAbgTGF7p49MCL0=,tag:mTNzsDhufqLlf1LFu7Rl1A==,type:str]
cloudflare:
api_token: ENC[AES256_GCM,data:Erzom4DKiam9SHGLdT3CQRkuT5kkhcuUaLwTbt2P5pPjr1V56p733KB1kHheO/PZ+TRsZg0=,iv:eO+ryffyoSkzAgUXe0MH+FKitgHCQ3ychLWEAShAd9o=,tag:2Z8Io0ylpAI9rws5NXCvIw==,type:str]
tunnel_token: ENC[AES256_GCM,data:AFlD990L8l1Rh9i8wdyXwwyolrlw2ln1uuyCTiT7k1FVc2JjTOrgc8HiBIpxM40eqFGEnzDMs87tgzh1Pl8UThwV7WcLFWHMvtYHNez/F2+THknnW+ZinJbZnNSngicrhRIoNFhjQgjaR1LaS4kcNbkGBi655bl2uqNXoUpNThUKGAlZ4KwByEzK4B7QtCgAkqxQEFehtjdj41p8r58ViJTogXQKXmDYLjbqo8nPmUGSaiR/VCY7Gw==,iv:q755yc6wTMCuqHLvfHOZzBf3KoG4vcw431stQA78Bjs=,tag:Zt/LPG41HxSg2gSQOTC/6A==,type:str]
restic:
password: ENC[AES256_GCM,data:X/pWmwakzQzRpSaY+T/kOqdrtXyvGPa3UQc/iIQFFAzUS1jHR9IvzW7KYdm/3IKXPBlZzPkWDsoRvVAChPwfQA==,iv:4+RD9UD5daMP04ixeagxbCNkTdOPx+BqfSOheh88OUU=,tag:SmrIz7jvbM74y5RBX0nCbA==,type:str]
s3_access_key_id: ENC[AES256_GCM,data:XxElPQF28ThfYuiF4jQu7BiS8sh+c4V4ng==,iv:aLUIYnRGqFLYwlP3nFwDY5uvy8pXtX5QMKLMfRTxdNk=,tag:CscNNWnpvLuxw1DPK92GyA==,type:str]
s3_secret_access_key: ENC[AES256_GCM,data:9ZWyhGJm4t2benDrLmnyQ9ZA5Jjl6l+pza1VmymTlw==,iv:xYsG6QlxXhQNO9szmsycxP6lT0cFF7lq3iNg6j+ED0E=,tag:wOJT4Vg3DuNFWTtx3QS9IQ==,type:str]
wifi:
psk: ENC[AES256_GCM,data:znk9Wr+vsntzbJ3H0TORUrAiDw==,iv:wbl8fUuKlgTqhajwjlTgFS7ijaTwXBFPRW2AmtiTklg=,tag:IK4oe8cJcccPaQ0V0NlncQ==,type:str]
sops:
lastmodified: "2026-04-18T20:53:59Z"
mac: ENC[AES256_GCM,data:nEP5XRzdYdFBWp9tqIgxcjjR7+X9ScpUew6SGfE6bKSQjvbwKTCGW6dSOTe7FmpUKrOS+dJnwpPsWKu0jbX/Qm5EtfXaB0GWiiMjfejwshmyULuJKipuq1rC+YX+DmOXoWIiNwKIwd4tBEOfYFBJVLFcoP8DSFjettymT0idvAQ=,iv:RnWzW+2hUScofJVom+csqEhYME8/roIzdRC/YC8opyk=,tag:22rjZO28mjPsp9p3iuoHSQ==,type:str]
age:
- recipient: age120j8ty7nn04l3s3kgph5ty3v9g4e52fknn8xtnmzwakq9nv2la3skgte0p
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBZSGpPdTBIaTZ0TER2NkNO
U2ZPKzNwelJHUEpyU2VBSmd5Yjd5bEtibFZzCjlZZTRFa2FHN1JtK2JUSm51a3By
QmFyV1ZZNWI0OGJVM1NNZERjd2hWcDAKLS0tIG9VSVFTSTJBMjk5ZzBSL0ZQV2Ev
QXVkRlJHeW52NFZFYnVwaW8ycytDSzAKZt+p5QnZKcEOBghHA2xkH6d7NObtTEoE
wMwCYasnBHzy2unXRbZq/4v9NQ5HJd0Nu1iqbqKgIxMCD3dnxEdK7g==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-04-21T12:42:15Z"
mac: ENC[AES256_GCM,data:fNip/7A7iKCVZqP0EziyBG7K8SVfRJTBpn4RcDLOaciJHx5DkLLszE8we9MmzpKXQIiMcJl2BTj/uqJrgc5EHTSOHwRzNJ4s2NJfvQW+8QUDfTGzKOkP3L837RkEPzH4HZLqGlfYK7cNJU5qXRPbusKjAft7Fz3+ONXmodb/ONY=,iv:CdSs1a+74+MfzWyML2JQ/b2IKbktVdefFFYP5LOtUos=,tag:ikr9LwPnmdiPucOoBt3/Bw==,type:str]
pgp:
- created_at: "2026-04-18T20:12:39Z"
- created_at: "2026-04-21T06:39:49Z"
enc: |-
-----BEGIN PGP MESSAGE-----
hQIMAwdqopdXmgBkAQ/+OOgkrBhQBXcbxH2Rj3yQ5cDTkH3LZdbBH+vLvEFfoXLk
RI12n3y+gQo5Gbs1eD9tJOuBIqYZwG9JTHiv43d6DXRFdY9PlMWaL6HeG6le/dj7
/JpirCofXhbL+GzLxQXnEOeMYm0Rhh5a9FbvqOwVkx2cCYlaWDYrZRPXFkjTw0et
DYv9a/ZUMAEKwSEJO7kRMpWYiPGI6KkArJrPBm7C6M4j5+KBv29FRSpw/IJiOMtT
CFWepDk+RJq+pMRNB91p/OO6YdrwMQJdCRcqC94I3TdxhVKoCCagULoE3vwHzxGQ
O5kDDc1GuQbIcNg2bfyWyKv6L9A30JaQT+8t3UMSHxAoWlvZes1y3tvquQeI8m+N
JILTmMWHjAplals4u+8BX7MCVolh4zJRNr1xiFy/UamYB70UORf2rjjGvMqOHsM+
IPJ2pIqbXDYs3syjKvWQFpxZczGgSPxHPlF9Tm+hu972ub9Ex2uVWntvjnt26H6+
/JbdV/7gW95AEkJ+HPjynDvYZ1tRBFGmwBOCsOkOfKmmopKcAooT6qDzC5hZBhBE
Yvl9TlC5GEBPnV4dtIxTZrqRqvbt5CvikmCI2h3/pcMWGM8a0iN2K0iNvlKGnKey
jlGC+0nQzwLllFtGBgOGKeqG1HQ5yPf2W4Ic7uSVGI3xPHkd5gG1MAHORw/3cP3S
XgHadJRTvnNnDsZjT7P8rIYTBnpe2zx+I8N21r+Jh5/hCv8wSl819QaBA4IMC5kt
Os9nSYc1KzodkJR35O8Bdy/7H8SF34tXjpyhWvE4OEqEwN7AdI0L0PfOiGMBjms=
=7asV
hQIMAwdqopdXmgBkAQ//TzlOz/QYwiYAc6NGo2O8YJi5ERkS1+0qNpptD51g2dLF
V4iUx7400tc6IEEhZ0N54R7AO5mSX55XCWJxVQDTRJXmLDHcOR+9vThb4H571XBa
3mcmE8Dj3sN3a1K2RwajZJXl1o5d1oNvWJ83pVsCnrJegi92+GmvmOt4QZ1l5aCf
TGYgUXAz1RreqsGKjJsSXscZOvRnp+cslJ9xY8OXeKLbQvLg0Z3pSQG2QGgDmHPD
fRxYnlc2lKe32uoBlD2LXK+NoBnrRYEVrrwGf6P5GpTDpJbc0bR5BiRIYDhPxtqK
SiXWHaebg73+kbWdcm+2kiac6hW6xW/iJL4eFBT1v/NgZmNoQCnJOIA7v2vjv9vl
81Y1FM5MpIfwNiTwkJjVsgM2tHkANlbixBHJdbjlnKpo9pTS7RuttWtdCmFdmXr0
oiuKDDRPVGvykPqvHzvCLf/k5j1nYvqvb7Wn2Bycc5kIOjFYEDEeM0r37vOX9nDM
SW1HtaWoZuVceTJEit0WR63kmXYLZ/AHvXcmq6ucUw8Fmw79n+7brQiX2RMtCK1E
pfrNey3EEqvPs2RDd6XdF4/73CdMDN5s3xiFAIfLGeZ6h0Eq27fazSZNmdh4MGYb
Wzj81ur8dimoSP+W9eW1TjIfY4deH5FRnN19ldKPuHdazvikWWsdN05evNlSZsDS
XgHafkhKiNSNZLw/VVzf+1SDLhN1H5QoxZ2YsxCc+psd5CFxU1x3llIDg4hXScAR
OQvRR1VjQOLFCwdFErW7sd6nQlkS7LnAskgT/0ZJGsxfkh1gJO3YqDnEKF7+P9w=
=zKa+
-----END PGP MESSAGE-----
fp: 076AA297579A0064
unencrypted_suffix: _unencrypted