Compare commits

...

12 Commits

Author SHA1 Message Date
Aner Zakobar 261cf892dd Everything changed - major rewrite 2026-06-07 00:59:22 +03:00
Aner Zakobar 08e8b5edbe REWRITE 2026-05-20 23:21:36 +03:00
Aner Zakobar 171ff2f3bc New mealie, paperless-ngx dirs 2026-05-20 23:09:21 +03:00
Aner Zakobar 42d91012c1 Runner updated and eurovote 2026-05-20 11:12:32 +03:00
Aner Zakobar d2793904f4 Notifications sort of fixed 2026-05-10 23:56:01 +03:00
Aner Zakobar 09052e8aec Better montiring, bug fixes. 2026-05-10 13:44:27 +03:00
Aner Zakobar af744e819c Monitoring primarily 2026-05-10 11:30:43 +03:00
Aner Zakobar 0e54760e34 Better limiting on nextcloud, crossed things off todo. 2026-05-03 11:30:46 +03:00
Aner Zakobar d6aa39ff04 Added shell command for deploy, updated readme, backup script. 2026-04-29 20:23:42 +03:00
Aner Zakobar d49f0161ca Redid networking 2026-04-26 00:09:52 +03:00
Aner Zakobar a7099e7d56 Should not ignore lock 2026-04-25 21:49:42 +03:00
Aner Zakobar 5e8d5f575a Fixes and more shell 2026-04-25 21:47:42 +03:00
35 changed files with 3341 additions and 471 deletions
+1
View File
@@ -0,0 +1 @@
use flake
+1 -1
View File
@@ -1,4 +1,4 @@
charts
*.lock
.agent-shell
result
.direnv
+171 -62
View File
@@ -1,9 +1,9 @@
# AGENTS.md
Self-hosted home server configuration for a Raspberry Pi 4 (8 GB), managed
entirely through NixOS. Services run as podman containers under systemd.
Remote access is via Cloudflare Tunnel; local access goes through Caddy
with Let's Encrypt TLS (DNS-01, Cloudflare API).
entirely through NixOS. Services run as podman containers or native NixOS
services under systemd. Remote access is via Cloudflare Tunnel; local access
goes through Caddy with Let's Encrypt TLS (DNS-01, Cloudflare API).
The original Kubernetes/Helm setup is preserved on the `main` branch.
This branch (`nixos-port`) is the active NixOS port.
@@ -20,14 +20,21 @@ modules/
caddy.nix # Caddy reverse proxy (DNS-01 ACME, forward_auth)
cloudflared.nix # Cloudflare Tunnel for remote access
backup.nix # Restic daily backups (S3 primary + manual offload)
monitoring.nix # Prometheus + Grafana (native NixOS services)
services/
openldap.nix # OpenLDAP — central identity provider
authelia.nix # Authelia — SSO gateway
authelia.nix # Authelia — SSO gateway + accessControlRules option
gitea.nix # Gitea — Git server
gitea-runner.nix # Gitea Actions runner
nextcloud.nix # Nextcloud + PostgreSQL
phpldapadmin.nix # phpLDAPadmin — LDAP web UI
jellyfin.nix # Jellyfin — media server (disabled by default)
transmission.nix # Transmission — torrent client (disabled by default)
jellyfin.nix # Jellyfin — media server (disabled)
transmission.nix # Transmission — torrent client (disabled)
uptime-kuma.nix # Uptime Kuma + homey.monitoring.monitors option
ntfy.nix # Ntfy — push notification server (native NixOS)
mealie.nix # Mealie — recipe manager
paperless.nix # Paperless-ngx — document management
eurovote.nix # Eurovision Vote — Django voting app
hosts/
pi-main/
default.nix # Service selection + host-specific overrides
@@ -42,27 +49,62 @@ PORTING.md # Step-by-step migration guide from the old Helm s
All services live under `zakobar.com`.
| Service | URL | Auth |
|---------|-----|------|
| 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 |
| Service | URL | Auth | Runtime |
|---------|-----|------|---------|
| Authelia | `auth.zakobar.com` | Public (it is the auth portal) | container |
| Gitea | `git.zakobar.com` | Gitea-native (LDAP) | container |
| Nextcloud | `nextcloud.zakobar.com` | Nextcloud-native | container |
| Mealie | `mealie.zakobar.com` | Mealie-native (LDAP) | container |
| Paperless | `paperless.zakobar.com` | Authelia one_factor (SSO) | container |
| phpLDAPadmin | `ldapadmin.zakobar.com` | Authelia two_factor, admins only | container |
| Uptime Kuma | `uptime.zakobar.com` | Authelia two_factor, admins only | container |
| Grafana | `grafana.zakobar.com` | Authelia two_factor, admins only | NixOS |
| Ntfy | `ntfy.zakobar.com` | Bypass (ntfy token/password auth) | NixOS |
| Eurovision Vote | `eurovision-vote.zakobar.com` | Authelia one_factor (`/admin` two_factor) | NixOS |
| Jellyfin | `jellyfin.zakobar.com` | Jellyfin-native | container (disabled) |
| Transmission | `torrent.zakobar.com` | Authelia two_factor, admins only | container (disabled) |
Internal ports (all bound to `127.0.0.1`):
## Networking
| Container | Port |
|-----------|------|
| openldap | 389 |
| authelia | 9091 |
| gitea | 3000 |
| nextcloud | 8080 |
| nextcloud-postgres | 5432 |
| phpldapadmin | 8081 |
| jellyfin | 8096 |
| transmission | 9092 (not 9091 — avoids clash with authelia) |
All containers join a private podman network named **`homey`**, created by the
`podman-homey-network` systemd service in `common.nix`. This provides:
- **DNS isolation** — containers reach each other by name (e.g. `openldap`,
`nextcloud-postgres`) without being exposed on the host network.
- **No port conflicts** — Caddy owns host ports 80/443; service containers map
only to `127.0.0.1:<port>`.
- **Defence in depth** — even if the firewall were misconfigured, services are
not bound to `0.0.0.0`.
Native NixOS services (not containers) listen on `127.0.0.1` directly:
| Service | Host port |
|---------|-----------|
| ntfy | 2586 |
| Eurovision Vote | 8007 |
| Prometheus | 9090 |
| Grafana | 3002 |
Container host-port mappings (all bound to `127.0.0.1`):
| Container | Host port | Container port |
|-----------|-----------|----------------|
| openldap | 389 | 389 |
| authelia | 9091 | 9091 |
| gitea | 3000 | 3000 |
| nextcloud | 8080 | 80 |
| nextcloud-postgres | 5432 | 5432 |
| phpldapadmin | 8081 | 80 |
| uptime-kuma | 3001 | 3001 |
| mealie | 9093 | 9000 |
| paperless | 8083 | 8000 |
| paperless-redis | (internal only) | 6379 |
| jellyfin | 8096 | 8096 |
| transmission | 9092 | 9091 |
Inter-container communication uses container names on the `homey` network
(e.g. authelia → `ldap://openldap:389`, nextcloud → `nextcloud-postgres:5432`).
Caddy (running on the host) proxies via `127.0.0.1:<host port>`.
## Storage Layout
@@ -82,11 +124,27 @@ All persistent data lives on the external HD at `/mnt/data/`:
jellyfin/config/ → /config
media/movies|tvshows|... → shared media (read-only to jellyfin)
transmission/config/ → /config
uptime-kuma/ → /app/data
mealie/data/ → /app/data
paperless/
data/ → /usr/src/paperless/data (DB, index)
media/ → /usr/src/paperless/media (document files)
consume/ → /usr/src/paperless/consume (drop folder)
export/ → /usr/src/paperless/export
ntfy/
auth.db → ntfy user/token database (host path)
cache.db → ntfy message cache (host path)
attachments/ → file attachments (host path)
restic-cache/ → restic local cache
```
Grafana and Prometheus use system state dirs (`/var/lib/grafana`,
`/var/lib/prometheus2`) and are not backed up — dashboards are provisioned by
Nix and metrics are ephemeral.
The drive device path is set per-host in `hosts/<name>/default.nix` via
`homey.storage.device`. Use a `/dev/disk/by-id/` path for stability.
`homey.storage.device`. Use a `/dev/disk/by-label/` or `/dev/disk/by-id/`
path for stability.
## Build / Validate Commands
@@ -144,7 +202,8 @@ restic password, Cloudflare tokens) can be generated fresh.
### Nix
1. **Module pattern** — every service is an opt-in module with an `enable` option:
1. **Module pattern** — every service is an opt-in module with an `enable` option
(defaulting to `false` for optional services):
```nix
options.homey.myservice.enable = lib.mkEnableOption "My service";
config = lib.mkIf config.homey.myservice.enable { ... };
@@ -152,7 +211,7 @@ restic password, Cloudflare tokens) can be generated fresh.
2. **`homeyConfig` specialArgs** — top-level site config (domain, org name,
timezone) is passed via `specialArgs` in `flake.nix` and accessed as
`homeyConfig` in every module. Do not read domain/org from hardcoded strings.
`homeyConfig` in every module. Do not hardcode domain/org strings.
3. **No secrets in the Nix store** — secrets are always read from sops-managed
files at runtime, never embedded in the built config. Use
@@ -160,26 +219,58 @@ restic password, Cloudflare tokens) can be generated fresh.
4. **Secret injection pattern** — because `oci-containers` `environmentFiles`
is limited, use a `systemd ExecStartPre` script to write an ephemeral env
file at `/run/<service>-secrets.env` and reference it via `EnvironmentFile`.
file at `/run/<service>-secrets.env` and reference it via `environmentFiles`.
Clean it up in `postStop`.
5. **`--network=host`** — all containers use host networking for simplicity on
a single-node setup. Services communicate via `127.0.0.1:<port>`.
5. **`--network=homey`** — all containers join the private `homey` podman
network. Inter-container traffic uses container names as hostnames; host
access is via explicit `ports` mappings to `127.0.0.1:<port>`.
6. **Systemd ordering** — always express `after`/`requires` dependencies
explicitly. The external HD mount unit is `mnt-data.mount`; containers that
need storage must depend on it.
### Module Contribution Options
Several cross-cutting concerns are wired up via list options that any service
module can append to, rather than editing central files:
| Option | Declared in | Purpose |
|--------|-------------|---------|
| `homey.caddy.virtualHosts` | `caddy.nix` | Add a reverse-proxy vhost |
| `homey.storage.extraDirs` | `storage.nix` | Create tmpfiles dirs on the HD |
| `homey.backup.extraPaths` | `backup.nix` | Include a path in restic backups |
| `homey.monitoring.monitors` | `uptime-kuma.nix` | Add an Uptime Kuma HTTP monitor |
| `homey.authelia.accessControlRules` | `authelia.nix` | Add Authelia access-control rules |
Each service module declares its own entries. No central file edits needed.
**`homey.authelia.accessControlRules`** — each rule has:
- `priority` (int) — lower = earlier in the list. Authelia stops at the first
match, so more-specific rules (e.g. `subject: group:admins`) must precede
their catch-all counterparts. Assigned priority ranges by category:
- `0` — auth bypass (Authelia itself)
- `1019` — blanket bypasses (e.g. ntfy)
- `2049` — admin-only two_factor + deny pairs
- `5064` — open one_factor services
- `6579` — per-path rules (resources + subject combinations)
- `domain` (list of strings)
- `policy` — `bypass` | `one_factor` | `two_factor` | `deny`
- `subject` (optional list) — e.g. `[ "group:admins" ]`
- `resources` (optional list) — URL path regexes
### Adding a New Service
1. Create `modules/services/<name>.nix` following the existing module pattern.
2. Add `homey.<name>.enable = false` as the default option.
3. Import the new module in `flake.nix` (in the `modules` list inside `mkHost`).
4. Enable it in `hosts/pi-main/default.nix`.
5. Add a Caddy virtual host block in `modules/caddy.nix`.
6. Add the service data directory to `modules/storage.nix` `tmpfiles.rules`.
7. Add the data path to the `paths` list in `modules/backup.nix`.
8. Add any new secrets to `secrets/secrets.yaml` (plaintext) and document them.
2. Import it in `flake.nix` (in the `modules` list inside `mkHost`).
3. Enable it in `hosts/pi-main/default.nix`.
4. Inside the module's `config = lib.mkIf cfg.enable { ... }` block:
- **Caddy**: add `homey.caddy.virtualHosts = [{ subdomain = "…"; port = …; auth = true/false; }]`
- **Storage**: add `homey.storage.extraDirs = [{ path = "…"; }]` for each HD directory
- **Backup**: add `homey.backup.extraPaths = [ "${dataDir}/…" ]`
- **Authelia**: add `homey.authelia.accessControlRules = [{ priority = …; domain = […]; policy = "…"; }]`
- **Monitoring**: add `homey.monitoring.monitors = [{ name = "…"; url = "…"; interval = 60; }]`
5. Add any new secrets to `secrets/secrets.yaml` and document them.
### Updating or Regenerating Secrets
@@ -223,45 +314,48 @@ production-ready:
DNS plugin uses `lib.fakeHash` as a placeholder. After the first `nix build`,
replace it with the hash Nix reports in the error message.
- [ ] **`hosts/pi-main/default.nix` — fill in real values**:
- SSH public key in `users.users.admin.openssh.authorizedKeys.keys`
- External HD device path in `homey.storage.device`
- Backup repository URL in `homey.backup.repository` — must be an S3-compatible
URL, e.g. `"s3:https://s3.us-west-002.backblazeb2.com/your-bucket-name"`
- [ ] **`monitoring.nix` — Grafana dashboard hash**: The Node Exporter Full
dashboard `fetchurl` hash is a placeholder. Run:
```bash
nix store prefetch-file --hash-type sha256 \
https://grafana.com/api/dashboards/1860/revisions/37/download
```
and replace the hash in `modules/monitoring.nix`.
- [ ] **`secrets/secrets.yaml` — populate and encrypt**: Fill in all secret
values (old passwords from k8s + freshly generated ones, including
`restic/s3_access_key_id` and `restic/s3_secret_access_key`), then run
`sops --encrypt --in-place secrets/secrets.yaml` before committing.
- [x] **`secrets/.sops.yaml` — PGP key**: The encryption subkey
`076AA297579A0064` is already in `.sops.yaml`.
values, then run `sops --encrypt --in-place secrets/secrets.yaml` before
committing. Secrets needed:
- From old k8s deployment: openldap passwords, gitea/nextcloud passwords
- Fresh: authelia JWT/session/encryption keys, gitea JWT tokens
- New services: `uptime-kuma/admin_password`, `ntfy/admin_password`,
`grafana/secret_key`, `ntfy/web_push_private_key`
- Backup: `restic/s3_access_key_id`, `restic/s3_secret_access_key`
- WiFi: `wifi/psk`
- [ ] **Cloudflare Tunnel**: Create the tunnel in the Zero Trust dashboard,
copy the tunnel token into secrets, and configure public hostnames. See
`modules/cloudflared.nix` and Phase 3 of `PORTING.md` for details.
copy the tunnel token into secrets, and configure public hostnames for all
enabled services. See `modules/cloudflared.nix` for details.
- [ ] **Cloudflare Tunnel — add new services**: After the initial tunnel is set
up, add public hostnames for: `uptime`, `ntfy`, `grafana`, `mealie`,
`paperless`, `eurovision-vote`.
- [ ] **Second machine**: When ready, add `hosts/pi-secondary/` and uncomment
the `pi-secondary` entry in `flake.nix`. Services communicating cross-machine
should reference the primary Pi's LAN IP instead of `127.0.0.1`.
- [ ] **Jellyfin and Transmission**: Both modules are written and importable
but disabled. Enable in `hosts/pi-main/default.nix` when ready:
- [ ] **Jellyfin and Transmission**: Both modules exist but are disabled.
Enable in `hosts/pi-main/default.nix` when ready:
```nix
homey.jellyfin.enable = true;
homey.transmission.enable = true;
```
- [ ] **Backup — S3 credentials**: Add `restic/s3_access_key_id` and
`restic/s3_secret_access_key` to secrets, and set `homey.backup.repository`
to your S3-compatible bucket URL in `hosts/pi-main/default.nix`.
- [ ] **Backup — offload script**: Write `scripts/offload-backup.sh` for
manually copying snapshots to a local disk (USB attached to Pi, or a disk
on your workstation). Uses `restic copy` to clone from the S3 repo into a
local restic repo on the target path. See `TODO.org` for design notes.
manually copying snapshots to a local disk. Uses `restic copy` to clone from
the S3 repo into a local restic repo. See `TODO.org` for design notes.
### Post- Pi first boot
### Post-Pi first boot
These items require the Pi to be built, flashed, and booted at least once.
@@ -276,7 +370,6 @@ These items require the Pi to be built, flashed, and booted at least once.
- [ ] **Gitea LDAP auth**: After first start, configure LDAP authentication
in Gitea's admin panel (Admin → Authentication Sources → Add LDAP source).
The old Helm chart had this commented out; it must be done manually once.
Relevant settings:
- Host: `127.0.0.1`, Port: `389`, Security: Unencrypted
- Bind DN: `cn=readonly,dc=zakobar,dc=com`
@@ -285,3 +378,19 @@ These items require the Pi to be built, flashed, and booted at least once.
- [ ] **Nextcloud LDAP app**: After restoring the Nextcloud volume, verify
the LDAP Users and Contacts app is still configured correctly
(Admin → LDAP/AD Integration).
- [ ] **Ntfy VAPID keys**: Generate Web Push keys on the Pi:
```bash
sudo ntfy webpush keys
```
Set `homey.ntfy.webPushPublicKey` in `default.nix` and add the private key
to sops as `ntfy/web_push_private_key`.
- [ ] **Uptime Kuma monitors**: On first boot, `uptime-kuma-sync` will
automatically create all monitors declared via `homey.monitoring.monitors`.
Verify they appear correctly in the UI at `https://uptime.zakobar.com`.
- [ ] **Paperless admin token (iOS Shortcut)**: After first start, generate a
dedicated API token in the Paperless web UI (Profile → API Auth Token) for
the iOS Shortcut upload flow. The `/api/documents/post_document/` path
bypasses Authelia — the token is the only auth.
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+240 -102
View File
@@ -108,21 +108,12 @@ nixos-rebuild switch \
The Pi builds its own config natively (no cross-compilation). sops-nix
will now decrypt all secrets and start all services.
** Caddy plugin hash
You can also use the command:
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
};
#+begin_src bash
homey-deploy-rpi-main
#+end_src
Then re-run the deploy command from Phase 3.
** Ongoing deploys from workstation
All future config changes follow the same pattern:
@@ -131,24 +122,94 @@ All future config changes follow the same pattern:
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
homey-deploy-rpi-main
#+end_src
NixOS activates the new config on the Pi immediately, with an automatic
rollback if activation fails.
* Installation (legacy Helm)
* Post-deploy setup
Install using
Some services require manual one-time configuration after the first deploy.
** Nix build directory
The nix daemon is configured to use =/mnt/data/nix-build= for sandbox
builds instead of the default =/tmp= (which is a small RAM-backed tmpfs).
This directory must be created manually once — =systemd-tmpfiles= will
maintain it on subsequent boots but cannot create it on the very first deploy
because the nix build itself needs the directory to already exist.
#+begin_src bash
helm upgrade --install homey . -n homey
sudo mkdir -p /mnt/data/nix-build
#+end_src
** Ntfy — push notifications
Ntfy's admin user is created automatically from sops on first start.
*** Step 1 — Generate VAPID keys (Web Push)
Run on the Pi *before* the first full deploy:
#+begin_src bash
ssh admin@192.168.1.100 'sudo ntfy webpush keys'
#+end_src
This prints a public key and a private key.
- Copy the *public key* into =hosts/pi-main/default.nix=:
#+begin_src nix
homey.ntfy.webPushPublicKey = "<public-key>";
homey.ntfy.webPushEmail = "mailto:you@zakobar.com";
#+end_src
- Add the *private key* to sops:
#+begin_src bash
sops secrets/secrets.yaml
# add: ntfy/web_push_private_key: <private-key>
#+end_src
The private key is injected at boot and never lands in the nix store.
*** Step 2 — Subscribe via Safari PWA (recommended for iOS)
1. Visit =https://ntfy.zakobar.com= in Safari and log in with the admin
password (=ntfy/admin_password= in =secrets/secrets.yaml=).
2. Go to *Account → Access Tokens → Create token* — give it a name and
copy the value.
3. Log in with the token, then tap *Share → Add to Home Screen*.
4. Open the app from the Home Screen (must be launched from there, not
Safari, to get push permission).
5. Subscribe to the =alerts= topic and grant notification permission when
prompted.
Web Push via the PWA uses Apple's APNs directly and is more reliable on
iOS than the native ntfy app's upstream relay.
** Uptime Kuma — notifications (two-deploy process)
Uptime Kuma monitors are created automatically by the sync script on first
deploy, but notification channels must be configured in the UI before they
can be attached to monitors. This requires two deploys:
*Deploy 1* — services are up, monitors exist, but no notifications assigned yet.
Then, in the Uptime Kuma UI (=https://uptime.zakobar.com=):
1. Go to *Settings → Notifications → Add Notification*.
2. Choose *ntfy* as the type and fill in:
- *Server URL*: =https://ntfy.zakobar.com=
- *Topic*: =alerts=
- *Token*: use the admin token (or create a dedicated one in ntfy)
3. Save — you do *not* need to manually assign it to any monitor.
*Deploy 2* — run =homey-deploy-rpi-main= again. The sync script will detect
the newly configured notification channel and attach it to every monitor
automatically.
Any notifications added to Uptime Kuma in the future will also be picked up
on the next deploy.
* Backing up
Backups use [[https://restic.net/][restic]] and run automatically via systemd on a daily schedule.
@@ -178,6 +239,30 @@ All service data under =/mnt/data/=:
Nextcloud is placed into maintenance mode and postgres is =pg_dump='d before
each backup to ensure a consistent snapshot.
** First-time setup — initialize the repository
Restic requires a one-time =init= before the first backup can run. The
automated job will fail with "repository does not exist" until this is done.
Run on the Pi after the first deploy:
#+begin_src bash
# Note: use single quotes around the remote script to prevent local shell expansion
ssh admin@192.168.1.100 'sudo bash -c '"'"'
export AWS_ACCESS_KEY_ID=$(cat /run/secrets/restic/s3_access_key_id)
export AWS_SECRET_ACCESS_KEY=$(cat /run/secrets/restic/s3_secret_access_key)
export RESTIC_PASSWORD=$(cat /run/secrets/restic/password)
restic -r s3:https://s3.us-east-005.backblazeb2.com/zakobar-home-backup init
'"'"''
#+end_src
You only need to do this once. After =init= succeeds, the daily timer will
run normally. To trigger a backup immediately without waiting for 03:00:
#+begin_src bash
ssh admin@192.168.1.100 "sudo systemctl start restic-backups-homey.service"
#+end_src
** Configuration
Repository URL and credentials are set per-host:
@@ -203,98 +288,151 @@ restic -r s3:https://... restore latest --target /mnt/data
restic -r s3:https://... restore latest --target /mnt/data --include /mnt/data/gitea
#+end_src
* LDAP Configuration
* Disaster Recovery
Logins are done to PHPLDAPADMIN
Full recovery from total host failure (dead Pi, dead SD card), assuming this
git repo and your workstation PGP key (=076AA297579A0064=) survive.
DN is like:
** Step 1 — Flash and boot a new Pi
cn=admin,dc=,dc=io
get-secret-val.sh homey openldap-admin password
Follow Phase 1 above to build and flash a fresh bootstrap image, then SSH in.
First thing we do is create an organization unit called users
** Step 2 — Regenerate the age key and re-encrypt secrets
To add a new user, we create a child entry to ou=users
The old Pi's age key is gone with the dead machine. Your workstation PGP key
is the fallback and can still decrypt =secrets/secrets.yaml=.
It has to be of type inetOrgPerson
On the Pi:
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
#+begin_src bash
sudo age-keygen -o /var/lib/sops-nix/key.txt
sudo age-keygen -y /var/lib/sops-nix/key.txt # copy this public key
#+end_src
* I UNDERSTAND
On the workstation — replace the old age key in =secrets/.sops.yaml= with the
new public key, then re-encrypt:
I need to backup Chen's stuff
And... I need to Jellyfin
#+begin_src bash
sops updatekeys secrets/secrets.yaml
git add secrets/.sops.yaml secrets/secrets.yaml
git commit -m "replace Pi age key after host failure"
#+end_src
* PAPERLESS
** Step 3 — Deploy the full NixOS config
https://github.com/paperless-ngx/paperless-ngx/blob/74c44fe418a91a526b5dab1a91fde4aaebd28bb1/docker/compose/docker-compose.postgres.yml
#+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
This brings up the OS and mounts =/mnt/data=. Services will fail to start
until data is restored — that is expected.
** Step 4 — Restore data from restic
Credentials are in =secrets/secrets.yaml= (=restic/password=,
=restic/s3_access_key_id=, =restic/s3_secret_access_key=).
#+begin_src bash
ssh admin@192.168.1.100
export RESTIC_REPOSITORY="s3:https://s3.us-east-005.backblazeb2.com/zakobar-home-backup"
export RESTIC_PASSWORD="..." # restic/password from secrets
export AWS_ACCESS_KEY_ID="..." # restic/s3_access_key_id
export AWS_SECRET_ACCESS_KEY="..." # restic/s3_secret_access_key
restic snapshots # verify repo is reachable
sudo restic restore latest --target /mnt/data
#+end_src
If restoring from a USB offload disk instead of S3:
#+begin_src bash
sudo restic -r /mnt/usb/homey-backup restore latest --target /mnt/data
#+end_src
** Step 5 — Restore the Nextcloud database
The raw Postgres data dir is excluded from restic; only the =pg_dump= SQL file
is backed up. After the data restore you will have
=/mnt/data/nextcloud/db-dump/nextcloud.sql= but an empty database. Import it:
#+begin_src bash
sudo systemctl start podman-nextcloud-postgres
# Wait ~10 s for Postgres to be ready, then:
podman exec -i nextcloud-postgres \
psql -U postgres nextcloud_db \
< /mnt/data/nextcloud/db-dump/nextcloud.sql
#+end_src
** Step 6 — Start services and verify
#+begin_src bash
sudo systemctl start podman-openldap podman-authelia podman-gitea podman-nextcloud
#+end_src
Manual checks after restart:
- *Gitea*: Admin → Authentication Sources — verify the LDAP source is present.
It lives in Gitea's database (restored from restic) so it should survive
automatically. Confirm by logging in with an LDAP user.
- *Nextcloud*: Admin → LDAP/AD Integration — confirm the LDAP app is still
configured. If not, re-enter the settings from the LDAP Configuration
section of this file.
** Key risks
| Risk | Consequence |
|------+-------------|
| External HD also fails | Restore all data from restic — Nextcloud files may be large |
| Workstation PGP key lost | Cannot decrypt =secrets/secrets.yaml= — passwords must be reset manually per service |
| USB offload not yet implemented | =scripts/offload-backup.sh= does not exist yet; S3 is the only working backup tier |
* Running commands in containers
All services run as podman containers. Use =podman exec= to run commands
inside them.
** General pattern
Containers are started by systemd as root, so they live in root's podman
context. All =podman= commands must be run with =sudo=.
#+begin_src bash
# List running containers
sudo podman ps
# Run a command in a container
sudo podman exec <container-name> <command>
# Run as a specific user
sudo podman exec -u <user> <container-name> <command>
# Interactive shell
sudo podman exec -it <container-name> sh
#+end_src
Container names match the service: =openldap=, =authelia=, =gitea=,
=nextcloud=, =nextcloud-postgres=, =jellyfin=, =transmission=.
** Nextcloud — running occ commands
=occ= must run as =www-data= inside the =nextcloud= container.
#+begin_src bash
# General form
sudo podman exec -u www-data nextcloud php occ <command>
# Examples
sudo podman exec -u www-data nextcloud php occ status
sudo podman exec -u www-data nextcloud php occ maintenance:mode --off
sudo podman exec -u www-data nextcloud php occ preview:generate-all -vvv
sudo podman exec -u www-data nextcloud php occ ldap:promote-group "admins"
#+end_src
Running without =-u www-data= will create files owned by root inside the
container, which breaks Nextcloud's file access.
For docker
+11 -9
View File
@@ -64,7 +64,7 @@
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=
** DONE 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
@@ -74,7 +74,7 @@
* Caddy Build
** TODO Fix =vendorHash= in =modules/caddy.nix=
** DONE 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.
@@ -94,7 +94,7 @@
* Deployment
** TODO Phase 1 — Build and flash bootstrap SD card image
** DONE 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
@@ -123,7 +123,7 @@
ssh admin@192.168.1.100
#+end_src
** TODO Phase 2 — Generate age key and add it to sops
** DONE Phase 2 — Generate age key and add it to sops
On the Pi (over SSH):
#+begin_src bash
@@ -154,7 +154,7 @@
git commit -m "add Pi age key to sops recipients"
#+end_src
** TODO Phase 3 — Fix Caddy vendorHash, then deploy full config
** DONE 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.
@@ -198,7 +198,8 @@
** DONE Configure Gitea LDAP authentication
Admin → Site Administration → Authentication Sources → Add LDAP (via BindDN):
- Host: =127.0.0.1=, Port: =389=, Security: Unencrypted
- Host: =openldap=, Port: =389=, Security: Unencrypted
(containers talk via the =homey= podman network — use container name, not =127.0.0.1=)
- 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=
@@ -208,12 +209,13 @@
- Surname attribute: =sn=
- Email attribute: =mail=
** TODO Verify Nextcloud LDAP app configuration
** DONE 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=
- Server: =openldap=, Port: =389=
(container name on the =homey= network — not =127.0.0.1=)
- Bind DN: =cn=readonly,dc=zakobar,dc=com=
- Bind Password: see =openldap/ro_password= in sops
- Base DN: =dc=zakobar,dc=com=
@@ -230,7 +232,7 @@
* Backup Strategy
** TODO Configure S3-compatible automatic backup target
** DONE 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
+317
View File
@@ -0,0 +1,317 @@
#+TITLE: Gitea Actions Runner — Workflows & Usage Guide
#+DATE: 2026-05-04
#+AUTHOR: homey project
#+OPTIONS: toc:2 num:t
* Overview
This document covers the Gitea Actions runner setup on pi-main, how the runner
works, the label system, and example workflows for both host-based ("ubuntu")
and Nix-native jobs.
** Architecture
The runner is configured in =modules/services/gitea-runner.nix= and uses the
NixOS native =services.gitea-actions-runner= module. Jobs run with the *host*
executor: each step executes directly on the Pi 4 as the =gitea-runner-pi-main=
system user. There is no container isolation per job.
#+BEGIN_EXAMPLE
Gitea (podman container)
│ HTTPS → Cloudflare tunnel → Caddy → git.zakobar.com
│ (runner connects outbound via HTTPS, same path as a browser)
gitea-actions-runner (systemd service)
│ host executor
Jobs run as: gitea-runner-pi-main (unprivileged system user)
PATH includes: nix, git, bash + system packages
#+END_EXAMPLE
** Runner labels
Labels are advertised to Gitea and matched against =runs-on:= in workflow
files. The default labels configured in this project are:
| Label | Executor | Notes |
|---------------+----------+--------------------------------------------|
| =native:host= | host | Canonical label for "run on this machine" |
| =ubuntu-latest= | host | Matches common GitHub Actions workflows |
| =debian-latest= | host | Alternative for Debian-targeting workflows |
| =nix:host= | host | Explicit label for Nix-native jobs |
All four labels route to the same runner process and the same host environment.
The difference is purely semantic — pick the label that makes your workflow's
intent clear.
** Nix daemon trust
The runner user is added to =nix.settings.trusted-users=, which means it can:
- Evaluate flakes (=nix flake check=, =nix build=)
- Write derivation outputs to the Nix store
- Pass =--extra-experimental-features= flags to the daemon
- Use =nix copy= to push/pull store paths to a remote cache
It cannot modify NixOS system configuration or run privileged operations.
* Example workflows
Workflow files live in =.gitea/workflows/= inside each repository (or
=.github/workflows/= — Gitea Actions supports both paths).
** Minimal smoke test (host)
The simplest possible workflow — runs a shell command on the runner.
#+BEGIN_SRC yaml
# .gitea/workflows/smoke.yaml
on: [push]
jobs:
smoke:
runs-on: native:host
steps:
- uses: actions/checkout@v3
- run: echo "Runner is alive on $(hostname)"
#+END_SRC
** Standard shell-based CI (ubuntu-latest label)
Use this for repos that want to stay compatible with GitHub Actions. The
workflow looks identical to what you'd push to GitHub; it just runs on your Pi.
#+BEGIN_SRC yaml
# .gitea/workflows/ci.yaml
on:
push:
branches: [main, master]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install dependencies
run: |
# On the host executor, use nix-shell or system packages.
# apt/yum are NOT available — this is NixOS, not Ubuntu.
# Use nix-shell -p for one-off tools:
nix-shell -p nodejs --run "node --version"
- name: Run tests
run: |
nix-shell -p nodejs --run "npm test"
#+END_SRC
*Important:* Despite the label =ubuntu-latest=, the host is NixOS. =apt=,
=yum=, and FHS paths like =/usr/bin/python3= are not available. Use
=nix-shell -p <pkg>= to bring in any tool you need.
** Nix flake check
Validate a flake on every push — the most common use case for this runner.
#+BEGIN_SRC yaml
# .gitea/workflows/flake-check.yaml
on: [push, pull_request]
jobs:
check:
runs-on: nix:host
steps:
- uses: actions/checkout@v3
- name: Check flake
run: nix flake check --no-build
- name: Build default package
run: nix build
#+END_SRC
** Nix build with caching
Build a derivation and push the result to a binary cache so subsequent builds
are fast. Requires a Cachix account or an S3-compatible cache configured in
=nix.settings=.
#+BEGIN_SRC yaml
# .gitea/workflows/build-and-cache.yaml
on:
push:
branches: [main]
jobs:
build:
runs-on: nix:host
steps:
- uses: actions/checkout@v3
- name: Build
run: nix build --print-build-logs
- name: Push to cache
# nix copy requires the runner user to be in trusted-users (already set).
# Replace the URI with your actual cache.
run: |
nix copy --to "s3://your-cache-bucket?region=us-east-1" ./result
#+END_SRC
** NixOS configuration check (this repo)
Check that the homey flake evaluates cleanly on every change. Add this to the
homey repo itself.
#+BEGIN_SRC yaml
# .gitea/workflows/nixos-check.yaml
on: [push, pull_request]
jobs:
eval:
runs-on: nix:host
steps:
- uses: actions/checkout@v3
- name: Evaluate NixOS configurations
run: |
nix flake check --no-build
# Optionally build a specific host config (slow on Pi):
# nix build .#nixosConfigurations.pi-main.config.system.build.toplevel
- name: Check formatting (optional)
run: |
nix-shell -p nixpkgs-fmt --run "nixpkgs-fmt --check ."
#+END_SRC
** Multi-step pipeline with artifacts
#+BEGIN_SRC yaml
# .gitea/workflows/pipeline.yaml
on:
push:
tags: ['v*']
jobs:
build:
runs-on: nix:host
steps:
- uses: actions/checkout@v3
- name: Build release
run: nix build --out-link result
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: release-binary
path: result/bin/
deploy:
needs: build
runs-on: native:host
steps:
- name: Download artifact
uses: actions/download-artifact@v3
with:
name: release-binary
- name: Deploy
run: ./deploy.sh
#+END_SRC
* Caveats and gotchas
** No apt/brew/yum
The host is NixOS. Package managers from other distros do not work. Use
=nix-shell -p <pkg> --run "..."= for ad-hoc tools, or add a =shell.nix= /
=flake.nix= devShell to your repo and enter it with =nix develop=.
** No Docker/Podman per job
The host executor does not launch a fresh container per job. All jobs share the
same filesystem (under =/home/gitea-runner-pi-main/=) and the same running
system. This means:
- No isolation between concurrent jobs (though concurrency defaults to 1).
- Side effects (files written, packages installed with nix) persist between
runs unless you clean up explicitly.
- Use =nix build= output symlinks (=./result=) rather than writing to system
paths.
** actions/checkout and git
The =actions/checkout@v3= action works fine on the host executor. It clones
into the runner's working directory. Subsequent steps run in that directory by
default.
If you use =actions/checkout@v4=, note that it requires a newer Node.js. On
NixOS you can't rely on a system Node, so either pin to v3 or use:
#+BEGIN_SRC yaml
- uses: actions/checkout@v3 # v3 bundles its own Node runtime
#+END_SRC
** Nix experimental features
Flake commands require =nix-command= and =flakes= experimental features. These
are typically enabled system-wide in =nix.settings.experimental-features= in
=modules/common.nix=. If a job fails with "experimental feature not enabled",
you can pass it inline:
#+BEGIN_SRC yaml
- run: nix --extra-experimental-features "nix-command flakes" flake check
#+END_SRC
Or ensure =common.nix= has:
#+BEGIN_SRC nix
nix.settings.experimental-features = [ "nix-command" "flakes" ];
#+END_SRC
** Token rotation
The registration token in =gitea/runner_token= is consumed on first
registration. The runner then stores its own credentials in
=/var/lib/gitea-runner/pi-main/.runner=. If you need to re-register (e.g.
after wiping the state directory), generate a new token from Gitea's admin UI
and update the sops secret before restarting the service.
** Pi 4 performance
The Pi 4 is capable but not fast for heavy builds. Tips:
- Enable the Nix binary cache (=nixos-cache.nixos.org= is on by default) so
pre-built derivations are fetched instead of compiled.
- Set =nix.settings.max-jobs= to =4= to use all cores for parallel builds.
- Avoid building large packages (LLVM, Chromium) locally — push to a remote
builder or use Cachix.
* Debugging
** Check runner status
#+BEGIN_SRC sh
systemctl status gitea-runner-pi-main
journalctl -u gitea-runner-pi-main -f
#+END_SRC
** Runner registration state
#+BEGIN_SRC sh
cat /var/lib/gitea-runner/pi-main/.runner
#+END_SRC
** Force re-registration
#+BEGIN_SRC sh
# Stop, wipe state, restart (runner will re-register using the token file)
systemctl stop gitea-runner-pi-main
rm /var/lib/gitea-runner/pi-main/.runner
systemctl start gitea-runner-pi-main
#+END_SRC
** Test a workflow locally
Use =act= (the local runner) to test workflow files before pushing:
#+BEGIN_SRC sh
nix-shell -p act --run "act push"
#+END_SRC
Note: =act= spins up Docker containers for each job, so results may differ
slightly from the host-executor runner, but it is useful for syntax checking
and logic testing.
Generated
+86
View File
@@ -0,0 +1,86 @@
{
"nodes": {
"eurovote": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1778959671,
"narHash": "sha256-MR70Q1lNOX7lqO7PwQUtJdB4+exZr8R10YPQanc5SwE=",
"owner": "anerisgreat",
"repo": "eurovote",
"rev": "245d9b1f3e182653e5cfa0d9689a97f263eb4354",
"type": "github"
},
"original": {
"owner": "anerisgreat",
"repo": "eurovote",
"type": "github"
}
},
"nixos-hardware": {
"locked": {
"lastModified": 1776983936,
"narHash": "sha256-ZOQyNqSvJ8UdrrqU1p7vaFcdL53idK+LOM8oRWEWh6o=",
"owner": "NixOS",
"repo": "nixos-hardware",
"rev": "2096f3f411ce46e88a79ae4eafcfc9df8ed41c61",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "master",
"repo": "nixos-hardware",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1767313136,
"narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"eurovote": "eurovote",
"nixos-hardware": "nixos-hardware",
"nixpkgs": "nixpkgs",
"sops-nix": "sops-nix"
}
},
"sops-nix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1776771786,
"narHash": "sha256-DRFGPfFV6hbrfO9a1PH1FkCi7qR5FgjSqsQGGvk1rdI=",
"owner": "Mic92",
"repo": "sops-nix",
"rev": "bef289e2248991f7afeb95965c82fbcd8ff72598",
"type": "github"
},
"original": {
"owner": "Mic92",
"repo": "sops-nix",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+20 -1
View File
@@ -14,9 +14,15 @@
# 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";
# Eurovision voting app
eurovote = {
url = "github:anerisgreat/eurovote";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, sops-nix, nixos-hardware, ... }@inputs:
outputs = { self, nixpkgs, sops-nix, nixos-hardware, eurovote, ... }@inputs:
let
# Shared specialArgs passed to every host
commonArgs = {
@@ -70,6 +76,15 @@
./modules/services/phpldapadmin.nix
./modules/services/jellyfin.nix
./modules/services/transmission.nix
./modules/services/gitea-runner.nix
./modules/services/paperless.nix
./modules/services/attic.nix
./modules/services/mealie.nix
./modules/services/uptime-kuma.nix
./modules/services/ntfy.nix
./modules/monitoring.nix
eurovote.nixosModules.default
./modules/services/eurovote.nix
] ++ extraModules;
};
@@ -101,5 +116,9 @@
# };
};
devShells = nixpkgs.lib.genAttrs [ "x86_64-linux" "aarch64-linux" ] (system:
(import ./shells) { pkgs = nixpkgs.legacyPackages.${system}; }
);
};
}
+112
View File
@@ -88,10 +88,36 @@
homey.jellyfin.enable = false;
homey.transmission.enable = false;
# Documents and recipes
homey.paperless.enable = true;
homey.mealie.enable = true;
# Reverse proxy + Cloudflare
homey.caddy.enable = true;
homey.cloudflared.enable = true;
# Nix binary cache
homey.attic.enable = true;
nix.settings = {
substituters = lib.mkAfter [ "https://attic.zakobar.com/main" ];
trusted-public-keys = lib.mkAfter [ "main:9SZt/6plBU7jjQzz90J7O011I13hmJvOMYouxNqExNQ=" ];
};
# CI/CD
homey.giteaRunner.enable = true;
# Eurovision voting app
homey.eurovote.enable = true;
# Monitoring stack
homey.uptimeKuma.enable = true;
homey.ntfy.enable = true;
# Generate with: ssh admin@192.168.1.100 'sudo ntfy webpush keys'
# Add private key to sops: ntfy/web_push_private_key
homey.ntfy.webPushPublicKey = "BE2qZVa3JEF741WTPtLevyhfP0I8bV0sD2a9-_y9NoyC40sgLpQi7bcoZesBwZEpRz8oiTVuoUFnHbckAsBQI5U";
homey.ntfy.webPushEmail = "aner@zakobar.com";
homey.monitoring.enable = true;
# Backups
homey.backup.enable = true;
# Where to send restic backups — set to your backup destination:
@@ -100,6 +126,92 @@
# "rclone:remote:homey"
homey.backup.repository = "s3:https://s3.us-east-005.backblazeb2.com/zakobar-home-backup";
# -------------------------------------------------------------------------
# Reliability hardening
# -------------------------------------------------------------------------
# Hardware watchdog — auto-reboot if the system hangs (e.g. blocked USB I/O).
# bcm2835_wdt exposes /dev/watchdog; systemd pets it every runtimeTime/2.
# If systemd itself stops responding, the hardware resets the Pi after 20s.
boot.kernelModules = [ "bcm2835_wdt" ];
systemd.watchdog = {
runtimeTime = "300s"; # 5 min — generous window for boot I/O storm on USB drive
rebootTime = "360s";
};
# Disable WiFi power save — the brcmfmac driver on RPi4 lets the chip sleep,
# causing it to miss packets and drop the connection under low traffic.
# Run once when the wlan0 interface appears (and on every re-plug/reconnect).
systemd.services.wifi-disable-power-save = {
description = "Disable WiFi power management on wlan0";
wantedBy = [ "multi-user.target" ];
after = [ "sys-subsystem-net-devices-wlan0.device" ];
bindsTo = [ "sys-subsystem-net-devices-wlan0.device" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = "${pkgs.iw}/bin/iw dev wlan0 set power_save off";
};
};
# Network watchdog — if the LAN gateway becomes unreachable, restart
# wpa_supplicant to force a fresh association. If the link is still
# dead 30 s later, reboot so the hardware watchdog doesn't have to.
# Runs every 2 min starting 5 min after boot.
systemd.services.network-watchdog = {
description = "Network connectivity watchdog";
after = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot";
ExecStart = pkgs.writeShellScript "network-watchdog" ''
gateway="192.168.1.1"
if ! ${pkgs.iputils}/bin/ping -c 3 -W 10 "$gateway" > /dev/null 2>&1; then
echo "Gateway $gateway unreachable restarting wpa_supplicant"
systemctl restart wpa_supplicant.service
sleep 30
if ! ${pkgs.iputils}/bin/ping -c 3 -W 10 "$gateway" > /dev/null 2>&1; then
echo "Still unreachable after wpa_supplicant restart rebooting"
systemctl reboot
fi
fi
'';
};
};
systemd.timers.network-watchdog = {
description = "Periodic network connectivity check";
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = "5min";
OnUnitActiveSec = "2min";
Persistent = true;
};
};
# Compressed in-RAM swap via zstd. Pages evicted from RAM are compressed
# (~3:1 ratio) and stored in a 25% RAM region (~2 GB) rather than written
# to disk. Gives the OOM killer breathing room under PHP upload spikes.
# CPU overhead is negligible during normal operation.
zramSwap = {
enable = true;
algorithm = "zstd";
memoryPercent = 25;
};
# hdparm -B udev rule removed: USB-SATA bridges often don't support APM
# commands and hdparm can hang indefinitely, causing boot-time crashes.
environment.systemPackages = [ pkgs.hdparm pkgs.tmux ];
systemd.services.nextcloud-generate-previews = {
description = "Generate missing Nextcloud preview thumbnails";
after = [ "podman-nextcloud.service" ];
requires = [ "podman-nextcloud.service" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.podman}/bin/podman exec -u www-data nextcloud php occ preview:generate-all";
};
};
# -------------------------------------------------------------------------
# Local DNS overrides (optional — makes LAN clients hit the Pi directly
# instead of going through Cloudflare for *.zakobar.com)
+41 -51
View File
@@ -42,6 +42,18 @@ in
options.homey.backup = {
enable = lib.mkEnableOption "Restic backup jobs";
extraPaths = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [];
description = "Paths to include in the restic backup. Each service module contributes its own entries.";
};
extraExcludePaths = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [];
description = "Paths to exclude from the restic backup. Each service module contributes its own entries.";
};
repository = lib.mkOption {
type = lib.types.str;
example = "sftp:user@nas.local:/backups/homey";
@@ -82,21 +94,34 @@ in
# Pre-backup hook: pg_dump + nextcloud maintenance mode
# -----------------------------------------------------------------------
systemd.services."homey-backup-pre" = {
description = "Pre-backup hooks (pg_dump, NC maintenance mode)";
description = "Pre-backup hooks (pg_dump, NC maintenance mode, secrets env)";
serviceConfig = {
Type = "oneshot";
ExecStart = pkgs.writeShellScript "backup-pre" ''
set -euo pipefail
podman="${pkgs.podman}/bin/podman"
# Write S3 credentials env file now, before restic-backups-homey.service
# starts — systemd loads EnvironmentFile= before ExecStartPre runs, so
# the file must already exist when the restic unit activates.
install -m 0600 /dev/null /run/restic-homey-secrets.env
{
printf 'AWS_ACCESS_KEY_ID=%s\n' \
"$(cat ${config.sops.secrets."restic/s3_access_key_id".path})"
printf 'AWS_SECRET_ACCESS_KEY=%s\n' \
"$(cat ${config.sops.secrets."restic/s3_secret_access_key".path})"
printf 'RESTIC_CACHE_DIR=%s\n' "${dataDir}/restic-cache"
} >> /run/restic-homey-secrets.env
# Put Nextcloud into maintenance mode (if running)
if systemctl is-active --quiet podman-nextcloud.service; then
podman exec nextcloud php occ maintenance:mode --on || true
$podman exec nextcloud php occ maintenance:mode --on || true
fi
# Dump postgres (if running)
if systemctl is-active --quiet podman-nextcloud-postgres.service; then
install -d -m 700 ${dataDir}/nextcloud/db-dump
podman exec nextcloud-postgres \
$podman exec nextcloud-postgres \
pg_dump -U postgres nextcloud_db \
> ${dataDir}/nextcloud/db-dump/nextcloud.sql
fi
@@ -104,19 +129,6 @@ in
};
};
systemd.services."homey-backup-post" = {
description = "Post-backup hooks (take NC out of maintenance mode)";
serviceConfig = {
Type = "oneshot";
ExecStart = pkgs.writeShellScript "backup-post" ''
set -euo pipefail
if systemctl is-active --quiet podman-nextcloud.service; then
podman exec nextcloud php occ maintenance:mode --off || true
fi
'';
};
};
# -----------------------------------------------------------------------
# Restic backup service
# -----------------------------------------------------------------------
@@ -124,25 +136,18 @@ in
repository = cfg.repository;
passwordFile = config.sops.secrets."restic/password".path;
# Runtime env file written by ExecStartPre (see systemd override below)
# Runtime env file written by homey-backup-pre.service (which runs first)
environmentFile = "/run/restic-homey-secrets.env";
paths = [
"${dataDir}/openldap"
"${dataDir}/authelia"
"${dataDir}/gitea"
"${dataDir}/nextcloud"
# media and transmission config included when those services are enabled:
"${dataDir}/jellyfin"
"${dataDir}/transmission"
# Deliberately excluded: media/* (large, can be re-downloaded)
];
# Paths are contributed by individual service modules via homey.backup.extraPaths.
paths = config.homey.backup.extraPaths;
# Exclude Nextcloud's raw DB directory in favour of the pg_dump file
exclude = [
"${dataDir}/nextcloud/db"
# restic's own local cache is never worth backing up
"${dataDir}/restic-cache"
];
# media is large and can be re-downloaded; services exclude their own consume dirs
"${dataDir}/media"
] ++ config.homey.backup.extraExcludePaths;
timerConfig = {
OnCalendar = cfg.schedule;
@@ -156,36 +161,21 @@ in
];
};
# Wire the pre/post hooks around the restic job and inject secrets
# Wire the pre/post hooks around the restic job
systemd.services."restic-backups-homey" = {
requires = [ "homey-backup-pre.service" ];
after = [ "homey-backup-pre.service" ];
serviceConfig = {
# Write runtime env file with actual secret values (restic needs the
# raw values; it does not support _FILE suffix env vars).
ExecStartPre = [
(pkgs.writeShellScript "restic-inject-secrets" ''
install -m 0600 /dev/null /run/restic-homey-secrets.env
{
printf 'AWS_ACCESS_KEY_ID=%s\n' \
"$(cat ${config.sops.secrets."restic/s3_access_key_id".path})"
printf 'AWS_SECRET_ACCESS_KEY=%s\n' \
"$(cat ${config.sops.secrets."restic/s3_secret_access_key".path})"
printf 'RESTIC_CACHE_DIR=%s\n' "${dataDir}/restic-cache"
} >> /run/restic-homey-secrets.env
'')
];
ExecStopPost = [
(pkgs.writeShellScript "restic-cleanup-secrets" ''
(pkgs.writeShellScript "restic-post-hooks" ''
# Always runs on stop, success or failure
rm -f /run/restic-homey-secrets.env
if systemctl is-active --quiet podman-nextcloud.service; then
${pkgs.podman}/bin/podman exec nextcloud php occ maintenance:mode --off || true
fi
'')
];
};
};
systemd.services."homey-backup-post" = {
after = [ "restic-backups-homey.service" ];
wantedBy = [ "restic-backups-homey.service" ];
};
};
}
+55 -118
View File
@@ -42,19 +42,16 @@ let
# Reusable Authelia forward_auth snippet
# Returns a Caddyfile snippet block that applies forward_auth.
# Uses the v4.38+ /api/authz/forward-auth endpoint which correctly honours
# one_factor policy without forcing TOTP enrollment on new users.
# copy_headers makes Authelia's Remote-* headers available downstream.
autheliaForwardAuth = ''
forward_auth localhost:9091 {
uri /api/verify?rd=https://auth.${domain}
uri /api/authz/forward-auth?authelia_url=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 {
redir https://auth.${domain}?rm={method} 302
}
}
'';
@@ -68,6 +65,38 @@ in
default = "admin@zakobar.com";
description = "Email for Let's Encrypt ACME registration.";
};
virtualHosts = lib.mkOption {
type = lib.types.listOf (lib.types.submodule {
options = {
subdomain = lib.mkOption {
type = lib.types.str;
description = "Subdomain under homeyConfig.domain (e.g. \"mealie\" mealie.zakobar.com).";
};
port = lib.mkOption {
type = lib.types.port;
description = "Host port to reverse-proxy to.";
};
auth = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Prepend Authelia forward_auth to this vhost.";
};
extraConfig = lib.mkOption {
type = lib.types.str;
default = "";
description = "Replaces the auto-generated 'reverse_proxy localhost:<port>' for HTTPS. Empty = use default.";
};
extraHttpConfig = lib.mkOption {
type = lib.types.str;
default = "";
description = "Replaces the auto-generated cfProxy for the HTTP loopback vhost. Empty = use default.";
};
};
});
default = [];
description = "Virtual hosts to generate. Each service module contributes its own entries.";
};
};
config = lib.mkIf cfg.enable {
@@ -92,119 +121,27 @@ in
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
'';
# Each virtual host.
# Each virtual host is generated from homey.caddy.virtualHosts entries.
# Each service module contributes its own entries to that list.
#
# 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 = {
# ------------------------------------------------------------------
# Authelia — public, no auth gate (it IS the auth gate)
# ------------------------------------------------------------------
"auth.${domain}" = {
extraConfig = ''
reverse_proxy localhost:9091
'';
};
"http://auth.${domain}" = {
extraConfig = cfProxy 9091;
};
# ------------------------------------------------------------------
# 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 = ''
reverse_proxy localhost:3000
'';
};
"http://git.${domain}" = {
extraConfig = cfProxy 3000;
};
# ------------------------------------------------------------------
# Nextcloud — public auth (Nextcloud manages its own users + LDAP)
# ------------------------------------------------------------------
"nextcloud.${domain}" = {
extraConfig = ''
# Redirect CardDAV/CalDAV discovery
redir /.well-known/carddav /remote.php/dav/ 301
redir /.well-known/caldav /remote.php/dav/ 301
# Large uploads (5 GB)
request_body {
max_size 5GB
}
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)
# ------------------------------------------------------------------
"ldapadmin.${domain}" = {
extraConfig = ''
${autheliaForwardAuth}
reverse_proxy localhost:8081
'';
};
"http://ldapadmin.${domain}" = {
extraConfig = ''
${autheliaForwardAuth}
${cfProxy 8081}
'';
};
# ------------------------------------------------------------------
# Jellyfin — no forward_auth; Jellyfin has its own login UI and
# native app clients can't handle SSO redirects.
# ------------------------------------------------------------------
"jellyfin.${domain}" = {
extraConfig = ''
reverse_proxy localhost:8096
'';
};
"http://jellyfin.${domain}" = {
extraConfig = cfProxy 8096;
};
# ------------------------------------------------------------------
# Transmission — two_factor, admins only (enforced by authelia policy)
# ------------------------------------------------------------------
"torrent.${domain}" = {
extraConfig = ''
${autheliaForwardAuth}
reverse_proxy localhost:9092
'';
# NOTE: transmission is bound to 9092 to avoid clash with authelia on 9091.
};
"http://torrent.${domain}" = {
extraConfig = ''
${autheliaForwardAuth}
${cfProxy 9092}
'';
};
};
# Each entry produces two Caddy vhosts:
# - "subdomain.domain" → HTTPS (LAN access + Let's Encrypt cert)
# - "http://subdomain.domain" → plain HTTP for cloudflared loopback
virtualHosts = lib.listToAttrs (
lib.concatMap (vh:
let
d = "${vh.subdomain}.${domain}";
authSnip = lib.optionalString vh.auth autheliaForwardAuth;
httpsBody = if vh.extraConfig != "" then vh.extraConfig
else "reverse_proxy localhost:${toString vh.port}\n";
httpBody = if vh.extraHttpConfig != "" then vh.extraHttpConfig
else cfProxy vh.port;
in [
{ name = d; value.extraConfig = "${authSnip}${httpsBody}"; }
{ name = "http://${d}"; value.extraConfig = "${authSnip}${httpBody}"; }
]
) cfg.virtualHosts
);
};
# -----------------------------------------------------------------------
+3
View File
@@ -20,6 +20,9 @@
# ldapadmin.zakobar.com → https://localhost:443
# jellyfin.zakobar.com → https://localhost:443
# torrent.zakobar.com → https://localhost:443
# uptime.zakobar.com → https://localhost:443
# ntfy.zakobar.com → https://localhost:443
# grafana.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).
+32
View File
@@ -21,6 +21,14 @@
"cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
"nix-community.cachix.org-1:mB9FkXj6Q3Q4ohOcbM4FJ9Z1X2kCrVK4vZOqsDqqNqk="
];
# Trigger GC automatically when free space drops below 2 GB;
# stop once 5 GB is free. Prevents CI builds from filling the disk
# between weekly GC runs.
min-free = 2147483648; # 2 GiB
max-free = 5368709120; # 5 GiB
# Use the external drive for sandbox builds — the default /tmp is a
# small RAM-backed tmpfs that fills up during large builds (e.g. wrangler).
build-dir = "/mnt/data/nix-build";
};
gc = {
automatic = true;
@@ -32,6 +40,10 @@
# Allow unfree packages (e.g. cloudflared binary)
nixpkgs.config.allowUnfree = true;
systemd.tmpfiles.rules = [
"d /mnt/data/nix-build 0755 root root -"
];
# -------------------------------------------------------------------------
# Boot — set in hardware.nix; this is just a safe default
# -------------------------------------------------------------------------
@@ -80,6 +92,26 @@
defaultNetwork.settings.dns_enabled = true;
};
# Create the shared "homey" podman network that all service containers join.
# DNS is enabled by default on netavark-backed networks, so containers can
# reach each other by container name (e.g. "openldap", "nextcloud-postgres").
systemd.services.podman-homey-network = {
description = "Create homey podman network";
wantedBy = [ "multi-user.target" ];
before = [ "podman-openldap.service" "podman-authelia.service"
"podman-gitea.service" "podman-nextcloud-postgres.service"
"podman-nextcloud.service" "podman-phpldapadmin.service"
"podman-jellyfin.service" "podman-transmission.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = pkgs.writeShellScript "create-homey-network" ''
${pkgs.podman}/bin/podman network exists homey \
|| ${pkgs.podman}/bin/podman network create homey
'';
};
};
# -------------------------------------------------------------------------
# Core packages available on every host
# -------------------------------------------------------------------------
+249
View File
@@ -0,0 +1,249 @@
{ config, lib, pkgs, homeyConfig, ... }:
# Prometheus + Grafana — metrics collection and dashboarding.
#
# Uses native NixOS services (not containers) for tight integration with
# the host OS and declarative dashboard/datasource provisioning.
#
# Architecture:
# node_exporter → Prometheus ← systemd_exporter
# ↓
# Grafana (pre-provisioned dashboard: Node Exporter Full)
#
# Auth (Grafana):
# Authelia enforces two_factor + admins-only before any request reaches
# Grafana. Caddy then maps the Authelia Remote-User header to
# X-WEBAUTH-USER, and Grafana's proxy auth auto-signs the user in —
# no second login required.
#
# Prometheus is internal-only (127.0.0.1:9090); only Grafana reads it.
# Grafana is exposed at 127.0.0.1:3002 and reverse-proxied by Caddy.
#
# Data dirs:
# Prometheus: /var/lib/prometheus2 (system drive — metrics are ephemeral)
# Grafana: /var/lib/grafana (system drive — dashboards provisioned by Nix)
#
# Secrets consumed from sops:
# grafana/secret_key (session signing key)
# openldap/ro_password (for Grafana → LDAP auth, shared with other modules)
let
cfg = config.homey.monitoring;
domain = homeyConfig.domain;
# LDAP base DN derived from domain (e.g. zakobar.com → dc=zakobar,dc=com)
ldapBaseDN = lib.concatStringsSep ","
(map (p: "dc=${p}") (lib.splitString "." domain));
in
{
options.homey.monitoring = {
enable = lib.mkEnableOption "Prometheus + Grafana monitoring stack" // { default = true; };
prometheusPort = lib.mkOption {
type = lib.types.port;
default = 9090;
description = "Prometheus listen port (localhost only).";
};
grafanaPort = lib.mkOption {
type = lib.types.port;
default = 3002;
description = "Grafana listen port (localhost only, reverse-proxied by Caddy).";
};
};
config = lib.mkIf cfg.enable {
# -----------------------------------------------------------------------
# Secrets
# -----------------------------------------------------------------------
sops.secrets."grafana/secret_key" = { owner = "grafana"; };
sops.secrets."openldap/ro_password" = { owner = "root"; };
# -----------------------------------------------------------------------
# Prometheus
# -----------------------------------------------------------------------
services.prometheus = {
enable = true;
listenAddress = "127.0.0.1";
port = cfg.prometheusPort;
globalConfig = {
scrape_interval = "30s";
evaluation_interval = "30s";
};
# Scrape node and systemd metrics from local exporters
scrapeConfigs = [
{
job_name = "node";
static_configs = [{
targets = [ "127.0.0.1:${toString config.services.prometheus.exporters.node.port}" ];
}];
}
{
job_name = "systemd";
static_configs = [{
targets = [ "127.0.0.1:${toString config.services.prometheus.exporters.systemd.port}" ];
}];
}
];
exporters = {
node = {
enable = true;
port = 9100;
# Enable extra collectors beyond the defaults
enabledCollectors = [
"cpu"
"diskstats"
"filesystem"
"loadavg"
"meminfo"
"netdev"
"stat"
"time"
"uname"
"pressure" # CPU/memory/IO pressure stall info (Linux PSI)
"hwmon" # temperature sensors (RPi4 has a CPU temp sensor)
];
};
systemd = {
enable = true;
port = 9558;
};
};
};
# -----------------------------------------------------------------------
# Grafana
# -----------------------------------------------------------------------
services.grafana = {
enable = true;
settings = {
server = {
http_addr = "127.0.0.1";
http_port = cfg.grafanaPort;
domain = "grafana.${domain}";
root_url = "https://grafana.${domain}";
};
# Session signing key — read from sops at runtime via Grafana's
# $__file{} interpolation syntax.
security = {
secret_key = "$__file{${config.sops.secrets."grafana/secret_key".path}}";
# Disable Grafana's own login form — Authelia is the auth gate,
# and proxy auth auto-signs users in via the X-WEBAUTH-USER header.
disable_initial_admin_creation = false;
};
# Proxy auth: trust the X-WEBAUTH-USER header set by Caddy after
# Authelia verifies the user's identity and TOTP.
"auth.proxy" = {
enabled = true;
header_name = "X-WEBAUTH-USER";
header_property = "username";
auto_sign_up = true;
# All users that reach Grafana are already confirmed admins
# (Authelia enforces the admins group + two_factor policy).
headers = "";
};
# Disable Grafana's own login UI — all auth goes via Authelia.
# Set to false to keep a fallback login form (useful for recovery).
"auth" = {
disable_login_form = true;
};
# Assign all proxy-auth users the Admin role automatically.
# Safe because Authelia already restricts access to the admins group.
users = {
auto_assign_org_role = "Admin";
};
analytics.reporting_enabled = false;
};
# -----------------------------------------------------------------------
# Provision Prometheus as a datasource
# -----------------------------------------------------------------------
provision = {
enable = true;
datasources.settings.datasources = [{
name = "Prometheus";
type = "prometheus";
url = "http://127.0.0.1:${toString cfg.prometheusPort}";
isDefault = true;
access = "proxy";
}];
# Pre-load the Node Exporter Full community dashboard (ID 1860).
# The JSON is downloaded via Nix so it's available at build time.
dashboards.settings.providers = [{
name = "default";
options.path = "/etc/grafana/dashboards";
}];
};
};
# -----------------------------------------------------------------------
# Download the Node Exporter Full dashboard JSON at build time.
#
# If the hash is wrong, `nix build` will print the correct one.
# Run: nix store prefetch-file --hash-type sha256 \
# https://grafana.com/api/dashboards/1860/revisions/37/download
# and replace the hash below.
# -----------------------------------------------------------------------
environment.etc."grafana/dashboards/node-exporter-full.json" = {
source = pkgs.fetchurl {
url = "https://grafana.com/api/dashboards/1860/revisions/37/download";
hash = "sha256-1DE1aaanRHHeCOMWDGdOS1wBXxOF84UXAjJzT5Ek6mM=";
};
mode = "0444";
};
# -----------------------------------------------------------------------
# Authelia access control — admins only, two_factor; all others denied.
# -----------------------------------------------------------------------
homey.authelia.accessControlRules = [
{ priority = 35; domain = [ "grafana.${domain}" ]; subject = [ "group:admins" ]; policy = "two_factor"; }
{ priority = 36; domain = [ "grafana.${domain}" ]; policy = "deny"; }
];
# -----------------------------------------------------------------------
# Caddy virtual host — forward_auth; Caddy maps Remote-User → X-WEBAUTH-USER
# so Grafana's proxy auth auto-signs the user in
# -----------------------------------------------------------------------
homey.caddy.virtualHosts = [{
subdomain = "grafana";
port = cfg.grafanaPort;
auth = true;
extraConfig = ''
reverse_proxy localhost:${toString cfg.grafanaPort} {
header_up X-WEBAUTH-USER {http.request.header.Remote-User}
}
'';
extraHttpConfig = ''
reverse_proxy localhost:${toString cfg.grafanaPort} {
header_up X-Forwarded-Proto https
header_up X-WEBAUTH-USER {http.request.header.Remote-User}
}
'';
}];
# Grafana and Prometheus use system state dirs (/var/lib/grafana,
# /var/lib/prometheus2) — no extraDirs or backup entries needed.
# -----------------------------------------------------------------------
# Uptime Kuma monitor for Grafana
# -----------------------------------------------------------------------
homey.monitoring.monitors = [{
name = "Grafana";
url = "https://grafana.${domain}";
interval = 60;
}];
};
}
+160
View File
@@ -0,0 +1,160 @@
# Attic — Post-Deployment Setup
Steps to run once after the first `nixos-rebuild switch` with `homey.attic.enable = true`.
**Status as of 2026-05-30:** all steps complete. Cache `main` is live at
`https://attic.zakobar.com/main`. Lauretta is logged in and can push/pull.
---
## Known values
| Item | Value |
|------|-------|
| Server URL | `https://attic.zakobar.com` |
| Cache name | `main` |
| Binary cache endpoint | `https://attic.zakobar.com/main` |
| Public signing key | `main:9SZt/6plBU7jjQzz90J7O011I13hmJvOMYouxNqExNQ=` |
| Cache visibility | Private (token required to pull) |
| GC retention | 90 days |
| Attic login (lauretta) | `~/.config/attic/config.toml` → server `homey` |
---
## Token reference
Tokens are stateless signed JWTs — the server does not store them. If you lose
one, regenerate it with the same command; it will work identically to the original.
### Admin token (full access)
```bash
ssh admin@192.168.1.100 \
"sudo podman exec attic atticadm -f /etc/attic/server.toml make-token \
--sub admin \
--validity '10y' \
--pull '*' \
--push '*' \
--delete '*' \
--create-cache '*' \
--configure-cache '*' \
--configure-cache-retention '*' \
--destroy-cache '*'"
```
### Pull-only token (for non-admin clients)
```bash
ssh admin@192.168.1.100 \
"sudo podman exec attic atticadm -f /etc/attic/server.toml make-token \
--sub nixos-client \
--validity '10y' \
--pull '*'"
```
### Push-only token (e.g. for CI)
```bash
ssh admin@192.168.1.100 \
"sudo podman exec attic atticadm -f /etc/attic/server.toml make-token \
--sub ci \
--validity '10y' \
--push 'main'"
```
---
## Configuring a new client machine
### 1. Add to `~/.config/nix/nix.conf`
```
extra-substituters = https://attic.zakobar.com/main
extra-trusted-public-keys = main:9SZt/6plBU7jjQzz90J7O011I13hmJvOMYouxNqExNQ=
```
### 2. Add pull token to `~/.netrc`
Generate a pull-only token (see above), then append to `~/.netrc`:
```
machine attic.zakobar.com
login token
password <pull-token>
```
### 3. Log in for pushing (optional)
```bash
nix run github:zhaofengli/attic -- login homey https://attic.zakobar.com <admin-or-push-token>
```
### 4. Verify
```bash
nix store ping --store https://attic.zakobar.com/main
```
---
## Pushing builds
```bash
# Push a specific path and its closure
nix run github:zhaofengli/attic -- push homey:main <path>
# Push the current system closure
nix run github:zhaofengli/attic -- push homey:main /run/current-system
# Push after a nix build
nix build .#nixosConfigurations.pi-main.config.system.build.toplevel
nix run github:zhaofengli/attic -- push homey:main ./result
# Watch the store and push all new paths as they are built
nix run github:zhaofengli/attic -- watch-store homey:main
```
Paths already signed by `cache.nixos.org` are skipped automatically.
---
## Monitoring
- **Uptime Kuma**: monitor configured automatically via the NixOS module (5 min interval)
- **Disk usage**: `ssh admin@192.168.1.100 "du -sh /mnt/data/attic/"`
- **Grafana**: node exporter tracks `/mnt/data` filesystem usage
- **Logs**: `ssh admin@192.168.1.100 "journalctl -u podman-attic -n 50"`
### Manual GC
```bash
ssh admin@192.168.1.100 \
"sudo podman exec attic atticadm -f /etc/attic/server.toml run-gc"
```
---
## Signing key rotation
If the signing key is ever compromised or needs rotating:
```bash
nix run github:zhaofengli/attic -- cache configure homey:main --regenerate-keypair
nix run github:zhaofengli/attic -- cache info homey:main # get new public key
```
Then update `trusted-public-keys` in `hosts/pi-main/default.nix` and on all client machines.
---
## Initial setup steps (completed 2026-05-30)
For reference — these were run once during first deployment.
1. Deployed NixOS config with `homey.attic.enable = true`
2. Added `attic.zakobar.com` to Cloudflare Tunnel dashboard
3. Generated admin token via `atticadm` inside container
4. Logged in: `attic login homey https://attic.zakobar.com <token>`
5. Created cache: `attic cache create homey:main` (Attic generates signing key server-side)
6. Added public key and substituter to `hosts/pi-main/default.nix`
7. Configured lauretta: `~/.config/nix/nix.conf` + `~/.netrc`
+166
View File
@@ -0,0 +1,166 @@
{ config, lib, pkgs, homeyConfig, ... }:
# Attic — self-hosted Nix binary cache (cachix alternative).
#
# Auth model: JWT token-based. No Authelia forward_auth — Attic manages its
# own token issuance and verification. Use `attic make-token` to create tokens.
# Push requires a write-scoped token; pull visibility is per-cache (public or
# token-gated, configurable via `attic cache configure` after first deploy).
#
# Volume layout:
# <dataDir>/attic/ → /data (SQLite DB)
# <dataDir>/attic/cache/ → /data/cache (content-addressed NAR store)
#
# NOT backed up: NAR content is fully reproducible from source.
#
# Secrets consumed from sops:
# attic/jwt_secret (base64-encoded HS256 secret for JWT token signing)
# attic/pull_token (JWT with pull:* scope — used by the local Nix daemon)
#
# See attic-setup.md for post-deploy steps and token generation commands.
let
cfg = config.homey.attic;
dataDir = config.homey.storage.mountPoint;
domain = homeyConfig.domain;
in
{
options.homey.attic = {
enable = lib.mkEnableOption "Attic Nix binary cache";
image = lib.mkOption {
type = lib.types.str;
default = "ghcr.io/zhaofengli/attic:latest";
};
port = lib.mkOption {
type = lib.types.port;
default = 8200;
description = "Host port Attic listens on (bound to 127.0.0.1).";
};
};
config = lib.mkIf cfg.enable {
# -----------------------------------------------------------------------
# Secrets
# -----------------------------------------------------------------------
sops.secrets."attic/jwt_secret" = { owner = "root"; };
sops.secrets."attic/pull_token" = { owner = "root"; };
# -----------------------------------------------------------------------
# Container
# If the container fails to start, check the expected config path with:
# podman inspect ghcr.io/zhaofengli/attic:latest | jq '.[].Config.Cmd'
# and adjust `cmd` below accordingly.
# -----------------------------------------------------------------------
virtualisation.oci-containers.containers.attic = {
image = cfg.image;
ports = [ "127.0.0.1:${toString cfg.port}:8080" ];
cmd = [ "--config" "/etc/attic/server.toml" ];
volumes = [
"${dataDir}/attic:/data"
"/run/attic-config.toml:/etc/attic/server.toml:ro"
];
extraOptions = [ "--network=homey" ];
};
# -----------------------------------------------------------------------
# ExecStartPre: write ephemeral TOML config with JWT secret interpolated
# -----------------------------------------------------------------------
systemd.services."podman-attic" = {
serviceConfig = {
ExecStartPre = [
(pkgs.writeShellScript "attic-write-config" ''
set -euo pipefail
JWT=$(cat ${config.sops.secrets."attic/jwt_secret".path})
install -m 600 /dev/null /run/attic-config.toml
printf '%s\n' \
'listen = "0.0.0.0:8080"' \
"" \
'[jwt.signing]' \
"token-hs256-secret-base64 = \"$JWT\"" \
"" \
'[database]' \
'url = "sqlite:///data/server.db?mode=rwc"' \
"" \
'[storage]' \
'type = "local"' \
'path = "/data/cache"' \
"" \
'[chunking]' \
'nar-size-threshold = 65536' \
'min-size = 16384' \
'avg-size = 65536' \
'max-size = 262144' \
"" \
'[garbage-collection]' \
'default-retention-period = "90 days"' \
"" \
'[compression]' \
'type = "zstd"' \
'level = 8' \
>> /run/attic-config.toml
'')
];
};
postStop = "rm -f /run/attic-config.toml";
after = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
requires = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
};
# -----------------------------------------------------------------------
# Caddy virtual host — no forward_auth; Attic handles its own auth
# -----------------------------------------------------------------------
homey.caddy.virtualHosts = [{
subdomain = "attic";
port = cfg.port;
auth = false;
}];
# -----------------------------------------------------------------------
# Storage directories (not backed up — no backup.extraPaths entry)
# -----------------------------------------------------------------------
homey.storage.extraDirs = [
{ path = "attic"; }
{ path = "attic/cache"; mode = "0755"; }
];
# -----------------------------------------------------------------------
# Nix daemon pull auth
# Writes a netrc file from the pull token so the system Nix daemon (and
# anything using it, e.g. the Gitea runner) can fetch from the private cache.
# -----------------------------------------------------------------------
systemd.services.attic-nix-netrc = {
description = "Write Attic pull token to netrc for Nix daemon";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = pkgs.writeShellScript "attic-write-netrc" ''
set -euo pipefail
TOKEN=$(cat ${config.sops.secrets."attic/pull_token".path})
install -m 600 /dev/null /run/attic-netrc
printf 'machine attic.${domain}\n login token\n password %s\n' "$TOKEN" \
> /run/attic-netrc
'';
};
postStop = "rm -f /run/attic-netrc";
};
nix.extraOptions = ''
netrc-file = /run/attic-netrc
'';
# -----------------------------------------------------------------------
# Uptime Kuma monitor
# -----------------------------------------------------------------------
homey.monitoring.monitors = [{
name = "Attic";
url = "https://attic.${domain}";
interval = 300;
}];
};
}
+110 -36
View File
@@ -17,6 +17,10 @@
# authelia/session_secret
# authelia/storage_encryption_key
# openldap/ro_password (shared with openldap module)
#
# Access control rules are NOT declared here. Each service module contributes
# its own rules via homey.authelia.accessControlRules, which are sorted by
# priority and merged into the final config at build time.
let
cfg = config.homey.authelia;
@@ -27,9 +31,29 @@ let
ldapBaseDN = lib.concatStringsSep ","
(map (p: "dc=${p}") (lib.splitString "." domain));
# Render a single access_control rule attrset to a YAML list item.
# Indented for insertion into the access_control.rules block (4 spaces
# before "- domain:", matching the 2-space indent of "rules:").
renderRule = rule:
let
domainLines = lib.concatMapStringsSep "\n" (d: " - \"${d}\"") rule.domain;
subjectBlock = lib.optionalString (rule.subject != []) (
"\n subject:\n" +
lib.concatMapStringsSep "\n" (s: " - \"${s}\"") rule.subject
);
resourcesBlock = lib.optionalString (rule.resources != []) (
"\n resources:\n" +
lib.concatMapStringsSep "\n" (r: " - \"${r}\"") rule.resources
);
in
" - domain:\n${domainLines}${subjectBlock}${resourcesBlock}\n policy: \"${rule.policy}\"\n";
sortedRules = lib.sort (a: b: a.priority < b.priority) cfg.accessControlRules;
rulesYaml = lib.concatStrings (map renderRule sortedRules);
# The authelia config is written as a Nix string so all values are
# resolved at build time except for secrets, which are injected at
# runtime via a wrapper script (same pattern as openldap).
# runtime via environment variables.
autheliaConfig = ''
###############################################################
# Authelia configuration #
@@ -43,7 +67,7 @@ let
authentication_backend:
ldap:
implementation: "custom"
url: "ldap://127.0.0.1:389"
url: "ldap://openldap:389"
timeout: "5s"
start_tls: false
base_dn: "${ldapBaseDN}"
@@ -79,35 +103,7 @@ let
access_control:
default_policy: "deny"
rules:
- domain:
- "auth.${domain}"
policy: "bypass"
- domain:
- "ldapadmin.${domain}"
subject:
- "group:admins"
policy: "two_factor"
- domain:
- "ldapadmin.${domain}"
policy: "deny"
- domain:
- "torrent.${domain}"
subject:
- "group:admins"
policy: "two_factor"
- domain:
- "torrent.${domain}"
policy: "deny"
- domain:
- "git.${domain}"
policy: "one_factor"
- domain:
- "nextcloud.${domain}"
policy: "one_factor"
- domain:
- "jellyfin.${domain}"
policy: "one_factor"
${rulesYaml}
notifier:
filesystem:
filename: "/config/emails.txt"
@@ -123,7 +119,41 @@ let
in
{
options.homey.authelia = {
enable = lib.mkEnableOption "Authelia SSO gateway";
# Declared unconditionally so any service module can contribute rules
# even when Authelia itself is disabled.
accessControlRules = lib.mkOption {
type = lib.types.listOf (lib.types.submodule {
options = {
priority = lib.mkOption {
type = lib.types.int;
default = 100;
description = "Order within access_control.rules lower values appear first. Authelia evaluates rules top-to-bottom and stops at the first match.";
};
domain = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = "Domain glob(s) this rule matches.";
};
policy = lib.mkOption {
type = lib.types.enum [ "bypass" "one_factor" "two_factor" "deny" ];
description = "Authelia policy applied when the rule matches.";
};
subject = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [];
description = "Optional subject constraints (e.g. \"group:admins\").";
};
resources = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [];
description = "Optional URL path regex constraints.";
};
};
});
default = [];
description = "Access control rules contributed by service modules. Merged and sorted by priority at build time.";
};
enable = lib.mkEnableOption "Authelia SSO gateway" // { default = true; };
image = lib.mkOption {
type = lib.types.str;
@@ -138,6 +168,15 @@ in
};
config = lib.mkIf cfg.enable {
# -----------------------------------------------------------------------
# Authelia's own bypass rule — must be first so the login UI is reachable.
# -----------------------------------------------------------------------
homey.authelia.accessControlRules = [{
priority = 0;
domain = [ "auth.${domain}" ];
policy = "bypass";
}];
# -----------------------------------------------------------------------
# Secrets
# -----------------------------------------------------------------------
@@ -162,7 +201,7 @@ in
virtualisation.oci-containers.containers.authelia = {
image = cfg.image;
# No ports mapping — --network=host shares the host network stack directly.
ports = [ "127.0.0.1:${toString cfg.port}:9091" ];
environment = {
TZ = homeyConfig.timezone;
@@ -171,6 +210,10 @@ in
AUTHELIA_SESSION_SECRET_FILE = "/run/secrets/session_secret";
AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE = "/run/secrets/storage_encryption_key";
AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE = "/run/secrets/ldap_ro_password";
# Changing this forces a container restart when the config changes.
# NixOS bind-mounts resolve symlinks at container start, so the running
# container would otherwise keep the old nix-store config until restarted.
NIXOS_CONFIG_HASH = builtins.hashString "sha256" autheliaConfig;
};
volumes = [
@@ -184,7 +227,7 @@ in
];
extraOptions = [
"--network=host"
"--network=homey"
"--hostname=authelia"
];
};
@@ -193,8 +236,39 @@ in
# Systemd — wait for openldap and external HD
# -----------------------------------------------------------------------
systemd.services."podman-authelia" = {
after = lib.mkAfter [ "mnt-data.mount" "podman-openldap.service" ];
requires = lib.mkAfter [ "mnt-data.mount" "podman-openldap.service" ];
after = lib.mkAfter [ "mnt-data.mount" "podman-openldap.service" "podman-homey-network.service" ];
requires = lib.mkAfter [ "mnt-data.mount" "podman-openldap.service" "podman-homey-network.service" ];
};
# -----------------------------------------------------------------------
# Caddy virtual host — no forward_auth (Authelia IS the auth gateway)
# -----------------------------------------------------------------------
homey.caddy.virtualHosts = [{
subdomain = "auth";
port = cfg.port;
auth = false;
}];
# -----------------------------------------------------------------------
# Storage directories
# -----------------------------------------------------------------------
homey.storage.extraDirs = [
{ path = "authelia"; }
{ path = "authelia/config"; }
];
# -----------------------------------------------------------------------
# Backup
# -----------------------------------------------------------------------
homey.backup.extraPaths = [ "${dataDir}/authelia" ];
# -----------------------------------------------------------------------
# Uptime Kuma monitor for this service
# -----------------------------------------------------------------------
homey.monitoring.monitors = [{
name = "Authelia";
url = "https://auth.${domain}/api/health";
interval = 60;
}];
};
}
+98
View File
@@ -0,0 +1,98 @@
{ config, lib, pkgs, homeyConfig, ... }:
# Eurovision Vote — Django app for ranking Eurovision performances.
#
# Uses the NixOS module from the eurovote flake (eurovote.nixosModules.default).
# This wrapper wires it into the homey module system: enable flag, sops secret,
# and uptime monitoring.
#
# The app uses DynamicUser + StateDirectory so systemd owns /var/lib/eurovote/;
# no tmpfiles.rules entry needed.
#
# Authentication: Caddy forward_auth → Authelia; the app reads the
# X-Remote-User header set by Caddy (from Authelia's Remote-User).
# All authenticated users get app access; /admin/* is restricted to
# group:admins by Authelia's access_control rules (defined in this file).
#
# Secrets consumed from sops:
# eurovote/secret_key
let
cfg = config.homey.eurovote;
domain = homeyConfig.domain;
in
{
options.homey.eurovote = {
enable = lib.mkEnableOption "Eurovision Vote app" // { default = true; };
};
config = lib.mkIf cfg.enable {
# -----------------------------------------------------------------------
# Secrets
# -----------------------------------------------------------------------
# mode 0444: the service runs as a DynamicUser (random UID) so it cannot
# read a root-owned 0400 file. /run/secrets/ itself is not world-listable
# (mode 0751), so world-readable here is acceptable on a home server.
sops.secrets."eurovote/secret_key" = { owner = "root"; mode = "0444"; };
# -----------------------------------------------------------------------
# Service (options provided by eurovote.nixosModules.default)
# -----------------------------------------------------------------------
services.eurovote = {
enable = true;
port = 8007;
allowedHosts = "localhost 127.0.0.1 eurovision-vote.${domain}";
secretKeyFile = config.sops.secrets."eurovote/secret_key".path;
trustedOrigins = "https://eurovision-vote.${domain}";
# After SSO logout, send the user back to Authelia's logout page
logoutRedirectUrl = "https://auth.${domain}/logout";
};
# -----------------------------------------------------------------------
# Authelia access control — /admin/* requires two_factor + admins group;
# all other paths require one_factor.
# -----------------------------------------------------------------------
homey.authelia.accessControlRules = [
{ priority = 65; domain = [ "eurovision-vote.${domain}" ]; resources = [ "^/admin.*$" ]; subject = [ "group:admins" ]; policy = "two_factor"; }
{ priority = 66; domain = [ "eurovision-vote.${domain}" ]; resources = [ "^/admin.*$" ]; policy = "deny"; }
{ priority = 67; domain = [ "eurovision-vote.${domain}" ]; policy = "one_factor"; }
];
# -----------------------------------------------------------------------
# Caddy virtual host — forward_auth; X-Remote-User passed to Django's
# RemoteUserMiddleware for automatic SSO login
# -----------------------------------------------------------------------
homey.caddy.virtualHosts = [{
subdomain = "eurovision-vote";
port = 8007;
auth = true;
extraConfig = ''
reverse_proxy localhost:8007 {
header_up X-Remote-User {http.request.header.Remote-User}
}
'';
extraHttpConfig = ''
reverse_proxy localhost:8007 {
header_up X-Forwarded-Proto https
header_up X-Remote-User {http.request.header.Remote-User}
}
'';
}];
# Eurovision Vote uses DynamicUser + /var/lib/eurovote — no extraDirs needed.
# -----------------------------------------------------------------------
# Backup — /var/lib/eurovote holds the SQLite DB with votes
# -----------------------------------------------------------------------
homey.backup.extraPaths = [ "/var/lib/eurovote" ];
# -----------------------------------------------------------------------
# Uptime Kuma monitor
# -----------------------------------------------------------------------
homey.monitoring.monitors = [{
name = "Eurovision Vote";
url = "https://eurovision-vote.${domain}";
interval = 60;
}];
};
}
+72
View File
@@ -0,0 +1,72 @@
{ config, lib, pkgs, homeyConfig, ... }:
# Gitea Actions Runner — executes CI/CD jobs triggered by Gitea Actions.
#
# Uses the NixOS native services.gitea-actions-runner module (act runner).
# Jobs run directly on the host ("host" executor) — no container isolation.
# This is appropriate for a trusted home server and avoids the overhead of
# nested containers on a Pi 4.
#
# The service uses DynamicUser=true so there is no persistent system user.
# Job step PATH is controlled by hostPackages (not the service PATH).
# nix is not in the NixOS module's default hostPackages and must be added.
#
# Setup (one-time):
# 1. In Gitea: Site Administration → Actions → Runners → Create Runner Token
# 2. Store the token in sops with KEY=VALUE format:
# gitea/runner_token: "TOKEN=<your-token-here>"
# 3. Enable homey.giteaRunner in the host config and deploy.
#
# After first start the runner registers itself and stores credentials in
# /var/lib/gitea-runner/<name>/.runner — the token file is only needed for
# (re-)registration.
#
# Secrets consumed from sops:
# gitea/runner_token (must contain: TOKEN=<value>)
let
cfg = config.homey.giteaRunner;
domain = homeyConfig.domain;
in
{
options.homey.giteaRunner = {
enable = lib.mkEnableOption "Gitea Actions runner" // { default = true; };
name = lib.mkOption {
type = lib.types.str;
default = config.networking.hostName;
description = "Runner name as shown in Gitea's runner list.";
};
labels = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "native:host" "ubuntu-latest:host" "debian-latest:host" "nix:host" ];
description = ''
Labels advertised to Gitea. The "host" executor runs jobs directly on
this machine. Workflow files targeting any of these labels will be
picked up by this runner.
'';
};
};
config = lib.mkIf cfg.enable {
# The NixOS module reads tokenFile as a systemd EnvironmentFile (root reads
# it before DynamicUser privilege drop), so owner=root / mode=0400 is correct.
# The file must contain: TOKEN=<registration-token>
sops.secrets."gitea/runner_token" = { owner = "root"; mode = "0400"; };
services.gitea-actions-runner.instances.${cfg.name} = {
enable = true;
url = "https://git.${domain}";
tokenFile = config.sops.secrets."gitea/runner_token".path;
name = cfg.name;
labels = cfg.labels;
# hostPackages controls the PATH available to job steps (host executor).
# nix is not in the default list so must be added explicitly.
hostPackages = with pkgs; [
bash coreutils curl gawk gitMinimal gnused nodejs wget
nix
];
};
};
}
+50 -6
View File
@@ -32,7 +32,7 @@ let
in
{
options.homey.gitea = {
enable = lib.mkEnableOption "Gitea Git server";
enable = lib.mkEnableOption "Gitea Git server" // { default = true; };
image = lib.mkOption {
type = lib.types.str;
@@ -60,8 +60,7 @@ in
# -----------------------------------------------------------------------
virtualisation.oci-containers.containers.gitea = {
image = cfg.image;
# 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.
ports = [ "127.0.0.1:${toString cfg.port}:3000" ];
# All non-secret settings via GITEA__<SECTION>__<KEY> env vars.
# These are safe to store in the Nix store.
@@ -144,6 +143,9 @@ in
# [oauth2]
GITEA__oauth2__ENABLED = "false";
# [actions]
GITEA__actions__ENABLED = "true";
};
# Secret env vars written at runtime by ExecStartPre — never in store.
@@ -153,7 +155,7 @@ in
"${dataDir}/gitea/data:/data"
];
extraOptions = [ "--network=host" ];
extraOptions = [ "--network=homey" ];
};
# -----------------------------------------------------------------------
@@ -182,10 +184,52 @@ in
'')
];
};
after = lib.mkAfter [ "mnt-data.mount" "podman-openldap.service" ];
requires = lib.mkAfter [ "mnt-data.mount" ];
after = lib.mkAfter [ "mnt-data.mount" "podman-openldap.service" "podman-homey-network.service" ];
requires = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
};
# -----------------------------------------------------------------------
# Authelia access control — one_factor for all authenticated users.
# Caddy does not apply forward_auth (git clients can't handle SSO redirects)
# but the rule is here for completeness/Cloudflare Tunnel path.
# -----------------------------------------------------------------------
homey.authelia.accessControlRules = [{
priority = 50;
domain = [ "git.${domain}" ];
policy = "one_factor";
}];
# -----------------------------------------------------------------------
# Caddy virtual host — no forward_auth; git clients can't handle SSO redirects
# -----------------------------------------------------------------------
homey.caddy.virtualHosts = [{
subdomain = "git";
port = cfg.port;
auth = false;
}];
# -----------------------------------------------------------------------
# Storage directories (UID 1000 = Gitea's internal user)
# -----------------------------------------------------------------------
homey.storage.extraDirs = [
{ path = "gitea"; user = "1000"; group = "1000"; }
{ path = "gitea/data"; user = "1000"; group = "1000"; }
];
# -----------------------------------------------------------------------
# Backup
# -----------------------------------------------------------------------
homey.backup.extraPaths = [ "${dataDir}/gitea" ];
# -----------------------------------------------------------------------
# Uptime Kuma monitor for this service
# -----------------------------------------------------------------------
homey.monitoring.monitors = [{
name = "Gitea";
url = "https://git.${domain}";
interval = 60;
}];
# -----------------------------------------------------------------------
# Ensure the Gitea admin user exists with the correct password after start.
# Runs as a oneshot after podman-gitea; idempotent (create or update).
+36 -5
View File
@@ -14,7 +14,7 @@ let
in
{
options.homey.jellyfin = {
enable = lib.mkEnableOption "Jellyfin media server";
enable = lib.mkEnableOption "Jellyfin media server" // { default = true; };
image = lib.mkOption {
type = lib.types.str;
@@ -30,7 +30,7 @@ in
config = lib.mkIf cfg.enable {
virtualisation.oci-containers.containers.jellyfin = {
image = cfg.image;
# No ports mapping — --network=host shares the host network stack directly.
ports = [ "127.0.0.1:${toString cfg.port}:8096" ];
environment = {
JELLYFIN_PublishedServerUrl = "https://jellyfin.${domain}";
@@ -44,12 +44,43 @@ in
"${dataDir}/media/tvshows:/data/tvshows:ro"
];
extraOptions = [ "--network=host" ];
extraOptions = [ "--network=homey" ];
};
systemd.services."podman-jellyfin" = {
after = lib.mkAfter [ "mnt-data.mount" ];
requires = lib.mkAfter [ "mnt-data.mount" ];
after = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
requires = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
};
# -----------------------------------------------------------------------
# Authelia access control — one_factor; Jellyfin has its own login UI.
# -----------------------------------------------------------------------
homey.authelia.accessControlRules = [{
priority = 60;
domain = [ "jellyfin.${domain}" ];
policy = "one_factor";
}];
# -----------------------------------------------------------------------
# Caddy virtual host — no forward_auth; Jellyfin has its own login UI
# -----------------------------------------------------------------------
homey.caddy.virtualHosts = [{
subdomain = "jellyfin";
port = cfg.port;
auth = false;
}];
# -----------------------------------------------------------------------
# Storage directories
# -----------------------------------------------------------------------
homey.storage.extraDirs = [
{ path = "jellyfin"; }
{ path = "jellyfin/config"; }
];
# -----------------------------------------------------------------------
# Backup
# -----------------------------------------------------------------------
homey.backup.extraPaths = [ "${dataDir}/jellyfin" ];
};
}
+135
View File
@@ -0,0 +1,135 @@
{ config, lib, pkgs, homeyConfig, ... }:
# Mealie — recipe manager and meal planner.
#
# Auth model: LDAP. Users log in with the same uid/password as the rest of
# the stack (OpenLDAP). No Authelia forward_auth — Mealie's own login page
# handles authentication via django-auth-ldap.
#
# Volume layout:
# <dataDir>/mealie/data/ → /app/data (SQLite DB, images, backups)
#
# Secrets consumed from sops:
# mealie/secret_key
# openldap/ro_password (shared with openldap module — used as LDAP_QUERY_PASSWORD)
let
cfg = config.homey.mealie;
dataDir = config.homey.storage.mountPoint;
domain = homeyConfig.domain;
# LDAP base DN derived from domain (zakobar.com → dc=zakobar,dc=com)
ldapBaseDn = lib.concatStringsSep ","
(map (p: "dc=${p}") (lib.splitString "." domain));
in
{
options.homey.mealie = {
enable = lib.mkEnableOption "Mealie recipe manager" // { default = true; };
image = lib.mkOption {
type = lib.types.str;
default = "ghcr.io/mealie-recipes/mealie:latest";
};
port = lib.mkOption {
type = lib.types.port;
default = 9093;
description = "Host port Mealie listens on (bound to 127.0.0.1).";
};
};
config = lib.mkIf cfg.enable {
# -----------------------------------------------------------------------
# Secrets
# -----------------------------------------------------------------------
sops.secrets."mealie/secret_key" = { owner = "root"; };
sops.secrets."openldap/ro_password" = { owner = "root"; };
# -----------------------------------------------------------------------
# Container
# -----------------------------------------------------------------------
virtualisation.oci-containers.containers.mealie = {
image = cfg.image;
ports = [ "127.0.0.1:${toString cfg.port}:9000" ];
environment = {
BASE_URL = "https://mealie.${domain}";
ALLOW_SIGNUP = "false";
TZ = homeyConfig.timezone;
# LDAP auth — Mealie binds as the readonly service account to search,
# then re-binds as the user to verify the password.
# LDAP_QUERY_PASSWORD is injected via the secrets env file.
LDAP_AUTH_ENABLED = "true";
LDAP_SERVER_URL = "ldap://openldap:389";
LDAP_ENABLE_STARTTLS = "false";
LDAP_BASE_DN = "ou=users,${ldapBaseDn}";
LDAP_QUERY_BIND = "cn=readonly,${ldapBaseDn}";
LDAP_BIND_TEMPLATE = "uid={username},ou=users,${ldapBaseDn}";
LDAP_ID_ATTRIBUTE = "uid";
LDAP_NAME_ATTRIBUTE = "cn";
LDAP_MAIL_ATTRIBUTE = "mail";
};
environmentFiles = [ "/run/mealie-secrets.env" ];
volumes = [
"${dataDir}/mealie/data:/app/data"
];
extraOptions = [ "--network=homey" ];
};
# -----------------------------------------------------------------------
# ExecStartPre: write ephemeral secrets env file
# -----------------------------------------------------------------------
systemd.services."podman-mealie" = {
serviceConfig = {
ExecStartPre = [
(pkgs.writeShellScript "mealie-write-secrets" ''
set -euo pipefail
install -m 600 /dev/null /run/mealie-secrets.env
printf '%s\n' \
"SECRET_KEY=$(cat ${config.sops.secrets."mealie/secret_key".path})" \
"LDAP_QUERY_PASSWORD=$(cat ${config.sops.secrets."openldap/ro_password".path})" \
>> /run/mealie-secrets.env
'')
];
};
postStop = "rm -f /run/mealie-secrets.env";
after = lib.mkAfter [ "mnt-data.mount" "podman-openldap.service" "podman-homey-network.service" ];
requires = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
};
# -----------------------------------------------------------------------
# Caddy virtual host — no forward_auth; Mealie uses LDAP login page
# -----------------------------------------------------------------------
homey.caddy.virtualHosts = [{
subdomain = "mealie";
port = cfg.port;
auth = false;
}];
# -----------------------------------------------------------------------
# Storage directories
# -----------------------------------------------------------------------
homey.storage.extraDirs = [
{ path = "mealie"; }
{ path = "mealie/data"; mode = "0755"; }
];
# -----------------------------------------------------------------------
# Backup
# -----------------------------------------------------------------------
homey.backup.extraPaths = [ "${dataDir}/mealie" ];
# -----------------------------------------------------------------------
# Uptime Kuma monitor
# -----------------------------------------------------------------------
homey.monitoring.monitors = [{
name = "Mealie";
url = "https://mealie.${domain}";
interval = 60;
}];
};
}
+123 -14
View File
@@ -18,10 +18,41 @@ let
cfg = config.homey.nextcloud;
dataDir = config.homey.storage.mountPoint;
domain = homeyConfig.domain;
# Custom Nextcloud config mounted into the container as an extra config file.
# Nextcloud auto-loads all *.config.php files in /var/www/html/config/.
nextcloudCustomConfig = pkgs.writeText "zakobar.config.php" ''
<?php
$CONFIG = [
// Throttle preview generation during bulk uploads.
// Generating thumbnails re-reads every uploaded file and writes preview
// files, roughly doubling disk I/O. Limiting concurrency to 1 prevents
// the drive from being hit by simultaneous read+write storms.
'preview_concurrency_new' => 1,
'preview_concurrency_all' => 1,
// Cap preview dimensions to reduce per-preview write size.
'preview_max_x' => 1024,
'preview_max_y' => 1024,
'jpeg_quality' => 75,
];
'';
# Limit Apache's prefork MPM so at most 4 PHP processes write to the USB
# drive simultaneously. Default is often 150, which causes an I/O storm
# on slow USB HDDs. Lower = fewer concurrent writers = more stable I/O.
apacheMpmConfig = pkgs.writeText "mpm_prefork.conf" ''
<IfModule mpm_prefork_module>
StartServers 2
MinSpareServers 1
MaxSpareServers 3
MaxRequestWorkers 4
MaxConnectionsPerChild 500
</IfModule>
'';
in
{
options.homey.nextcloud = {
enable = lib.mkEnableOption "Nextcloud file server";
enable = lib.mkEnableOption "Nextcloud file server" // { default = true; };
image = lib.mkOption {
type = lib.types.str;
@@ -58,7 +89,9 @@ in
# -----------------------------------------------------------------------
virtualisation.oci-containers.containers.nextcloud-postgres = {
image = cfg.postgresImage;
# No ports mapping — --network=host shares the host network stack directly.
# Exposed on localhost for debugging; nextcloud reaches it via the
# container name "nextcloud-postgres" on the homey network.
ports = [ "127.0.0.1:${toString cfg.postgresPort}:5432" ];
environment = {
POSTGRES_DB = "nextcloud_db";
@@ -71,7 +104,7 @@ in
];
extraOptions = [
"--network=host"
"--network=homey"
"--env-file=/run/nc-postgres-secrets.env"
];
};
@@ -91,8 +124,8 @@ in
];
};
postStop = "rm -f /run/nc-postgres-secrets.env";
after = lib.mkAfter [ "mnt-data.mount" ];
requires = lib.mkAfter [ "mnt-data.mount" ];
after = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
requires = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
};
# -----------------------------------------------------------------------
@@ -100,33 +133,109 @@ in
# -----------------------------------------------------------------------
virtualisation.oci-containers.containers.nextcloud = {
image = cfg.image;
# No ports mapping — --network=host shares the host network stack directly.
# Apache inside the container listens on port 80; map it to cfg.port on
# the host so Caddy can reach it. Postgres is reachable by container name.
ports = [ "127.0.0.1:${toString cfg.port}:80" ];
environment = {
POSTGRES_HOST = "127.0.0.1";
POSTGRES_HOST = "nextcloud-postgres";
POSTGRES_DB = "nextcloud_db";
POSTGRES_USER = "postgres";
NEXTCLOUD_ADMIN_USER = "admin";
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;
OVERWRITEHOST = "nextcloud.${domain}";
# Trust the reverse proxy (Caddy on the host reaches the container
# via the podman bridge; cover all RFC-1918 ranges to be robust).
TRUSTED_PROXIES = "10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 127.0.0.1 ::1";
# Passwords injected via env file
};
volumes = [
"${dataDir}/nextcloud/html:/var/www/html"
# Extra config auto-loaded by Nextcloud (throttles preview generation)
"${nextcloudCustomConfig}:/var/www/html/config/zakobar.config.php:ro"
# Apache MPM limits (caps concurrent PHP processes / disk writers)
"${apacheMpmConfig}:/etc/apache2/mods-available/mpm_prefork.conf:ro"
];
extraOptions = [
"--network=host"
"--network=homey"
"--env-file=/run/nc-secrets.env"
];
};
# -----------------------------------------------------------------------
# Authelia access control — one_factor; Nextcloud manages its own login UI.
# -----------------------------------------------------------------------
homey.authelia.accessControlRules = [{
priority = 55;
domain = [ "nextcloud.${domain}" ];
policy = "one_factor";
}];
# -----------------------------------------------------------------------
# Caddy virtual host — no forward_auth; Nextcloud manages its own auth
# -----------------------------------------------------------------------
homey.caddy.virtualHosts = [{
subdomain = "nextcloud";
port = cfg.port;
auth = false;
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:${toString cfg.port} {
header_up X-Forwarded-For {remote_host}
}
'';
extraHttpConfig = ''
redir /.well-known/carddav /remote.php/dav/ 301
redir /.well-known/caldav /remote.php/dav/ 301
request_body {
max_size 5GB
}
reverse_proxy localhost:${toString cfg.port} {
header_up X-Forwarded-Proto https
header_up X-Forwarded-For {remote_host}
}
'';
}];
# -----------------------------------------------------------------------
# Storage directories
# UID 33 = www-data in the Nextcloud container
# UID 999 = postgres — must own the db dir (creates files directly in it)
# -----------------------------------------------------------------------
homey.storage.extraDirs = [
{ path = "nextcloud"; }
{ path = "nextcloud/html"; user = "33"; group = "33"; }
{ path = "nextcloud/db"; mode = "0700"; user = "999"; group = "999"; }
{ path = "nextcloud/db-dump"; mode = "0700"; }
];
# -----------------------------------------------------------------------
# Backup — exclude raw DB dir (pg_dump file in db-dump/ is used instead)
# -----------------------------------------------------------------------
homey.backup.extraPaths = [ "${dataDir}/nextcloud" ];
homey.backup.extraExcludePaths = [ "${dataDir}/nextcloud/db" ];
# -----------------------------------------------------------------------
# Uptime Kuma monitor for this service
# -----------------------------------------------------------------------
homey.monitoring.monitors = [{
name = "Nextcloud";
url = "https://nextcloud.${domain}/status.php";
interval = 60;
keyword = "\"maintenance\":false";
# Nightly maintenance is expected — only alert if stuck for 4+ hours.
# 240 retries × 60s = 4 hours of consecutive failures before notifying.
maxretries = 240;
}];
systemd.services."podman-nextcloud" = {
serviceConfig = {
LoadCredential = [
@@ -143,8 +252,8 @@ in
];
};
postStop = "rm -f /run/nc-secrets.env";
after = lib.mkAfter [ "mnt-data.mount" "podman-nextcloud-postgres.service" ];
requires = lib.mkAfter [ "mnt-data.mount" "podman-nextcloud-postgres.service" ];
after = lib.mkAfter [ "mnt-data.mount" "podman-nextcloud-postgres.service" "podman-homey-network.service" ];
requires = lib.mkAfter [ "mnt-data.mount" "podman-nextcloud-postgres.service" "podman-homey-network.service" ];
};
};
}
+220
View File
@@ -0,0 +1,220 @@
{ config, lib, pkgs, homeyConfig, ... }:
# Ntfy — self-hosted push notification server.
#
# Mobile app (Android/iOS) connects to https://ntfy.zakobar.com with a token
# and subscribes to the "alerts" topic. Uptime Kuma and Grafana send alerts
# to that topic when services go down.
#
# Auth model:
# - Web UI: public-facing but ntfy enforces its own auth (deny-all by default)
# - Caddy does NOT put forward_auth here; ntfy has native token/password auth
# so the mobile app can connect without Authelia SSO complications.
#
# Web Push (PWA via Safari "Add to Home Screen"):
# Generate VAPID keys on the Pi:
# sudo ntfy webpush keys
# Set homey.ntfy.webPushPublicKey and homey.ntfy.webPushEmail in default.nix.
# Add the private key to sops: ntfy/web_push_private_key
#
# Setup after first deploy:
# 1. Visit https://ntfy.zakobar.com — log in with the admin password from sops.
# 2. Create an access token for your phone (Admin → Users & Tokens).
# 3. PWA: open https://ntfy.zakobar.com in Safari → Share → Add to Home Screen,
# then open from Home Screen and subscribe to "alerts".
#
# Volume layout:
# <dataDir>/ntfy/auth.db ← user/token database
# <dataDir>/ntfy/cache.db ← message cache (for missed messages)
# <dataDir>/ntfy/attachments/ ← file attachments
#
# Secrets consumed from sops:
# ntfy/admin_password
# ntfy/web_push_private_key
let
cfg = config.homey.ntfy;
dataDir = config.homey.storage.mountPoint;
domain = homeyConfig.domain;
# All ntfy settings in one place. The private key is NOT here — it is
# injected at runtime via ExecStartPre so it never lands in the nix store.
ntfySettings = {
listen-http = "127.0.0.1:${toString cfg.port}";
base-url = "https://ntfy.${domain}";
auth-default-access = "deny-all";
auth-file = "${dataDir}/ntfy/auth.db";
cache-file = "${dataDir}/ntfy/cache.db";
attachment-root = "${dataDir}/ntfy/attachments";
upstream-base-url = "https://ntfy.sh";
cache-duration = "12h";
attachment-total-size-limit = "5G";
attachment-file-size-limit = "15M";
attachment-expiry-duration = "3h";
web-push-public-key = cfg.webPushPublicKey;
web-push-email-address = cfg.webPushEmail;
web-push-file = "${dataDir}/ntfy/webpush.db";
};
# Build-time base config (no private key). ExecStartPre copies this to
# /run/ntfy-sh/server.yml and appends web-push-private-key from the credential.
baseConfigFile = (pkgs.formats.yaml {}).generate "ntfy-server-base.yml" ntfySettings;
in
{
options.homey.ntfy = {
enable = lib.mkEnableOption "Ntfy push notification server" // { default = true; };
port = lib.mkOption {
type = lib.types.port;
default = 2586;
description = "Host port ntfy listens on (bound to 127.0.0.1).";
};
webPushPublicKey = lib.mkOption {
type = lib.types.str;
description = "VAPID public key for Web Push (generate with: sudo ntfy webpush keys).";
};
webPushEmail = lib.mkOption {
type = lib.types.str;
description = "Contact e-mail sent in VAPID headers (e.g. mailto:you@example.com).";
};
};
config = lib.mkIf cfg.enable {
# -----------------------------------------------------------------------
# Secrets
# -----------------------------------------------------------------------
sops.secrets."ntfy/admin_password" = { owner = "root"; };
sops.secrets."ntfy/web_push_private_key" = { owner = "root"; };
# -----------------------------------------------------------------------
# ntfy-sh native NixOS service
# -----------------------------------------------------------------------
services.ntfy-sh = {
enable = true;
settings = ntfySettings;
};
# Minimal config for the `ntfy user` CLI — the NixOS module puts its
# generated config in the nix store under an unpredictable path, so we
# write a separate file just containing the auth-file path. The server
# ignores this file (it uses the module-generated one via -c flag).
environment.etc."ntfy-sh/user-cli.yml" = {
text = "auth-file: ${dataDir}/ntfy/auth.db\n";
mode = "0444";
};
# Ensure ntfy-sh starts after the HD is mounted and dirs are ready.
# Widen ReadWritePaths so ntfy-sh can write to the external HD.
# Inject the VAPID private key at runtime: ExecStartPre copies the
# build-time base config to /run/ntfy-sh/server.yml and appends the key,
# then we override ExecStart to use that runtime config file.
systemd.services.ntfy-sh = {
after = lib.mkAfter [ "mnt-data.mount" "systemd-tmpfiles-setup.service" ];
requires = lib.mkAfter [ "mnt-data.mount" ];
serviceConfig = {
ReadWritePaths = lib.mkAfter [ "${dataDir}/ntfy" ];
RuntimeDirectory = "ntfy-sh"; # creates /run/ntfy-sh, owned by ntfy-sh user
# Run as root (+) so the module's sandbox hardening can't block the write.
# Read the sops secret directly — no LoadCredential needed.
ExecStartPre = "+" + toString (pkgs.writeShellScript "ntfy-write-config" ''
set -euo pipefail
mkdir -p /run/ntfy-sh
cp ${baseConfigFile} /run/ntfy-sh/server.yml
printf 'web-push-private-key: %s\n' \
"$(cat ${config.sops.secrets."ntfy/web_push_private_key".path})" \
>> /run/ntfy-sh/server.yml
chown ntfy-sh:ntfy-sh /run/ntfy-sh/server.yml
chmod 600 /run/ntfy-sh/server.yml
'');
ExecStart = lib.mkForce "${pkgs.ntfy-sh}/bin/ntfy serve -c /run/ntfy-sh/server.yml";
};
};
# -----------------------------------------------------------------------
# Create the admin user on first start (idempotent)
# -----------------------------------------------------------------------
systemd.services.ntfy-sh-setup = {
description = "Create Ntfy admin user";
wantedBy = [ "multi-user.target" ];
after = [ "ntfy-sh.service" "mnt-data.mount" ];
requires = [ "ntfy-sh.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
LoadCredential = "ntfy_admin_password:${config.sops.secrets."ntfy/admin_password".path}";
ExecStart = pkgs.writeShellScript "ntfy-create-admin" ''
set -euo pipefail
# Wait until ntfy HTTP endpoint is ready (max 60 s)
for i in $(seq 1 30); do
if ${pkgs.curl}/bin/curl -sf http://127.0.0.1:${toString cfg.port}/v1/health > /dev/null 2>&1; then
break
fi
sleep 2
done
PASS=$(cat "$CREDENTIALS_DIRECTORY/ntfy_admin_password")
# Use the minimal CLI config (just has auth-file path).
NTFY="${pkgs.ntfy-sh}/bin/ntfy user --config /etc/ntfy-sh/user-cli.yml"
# ntfy user add reads password + confirmation from stdin (two lines).
# If the user already exists ntfy exits 1 with "already exists" — treat that as success.
if out=$(printf '%s\n%s\n' "$PASS" "$PASS" | $NTFY add --role=admin admin 2>&1); then
echo "ntfy-sh-setup: admin user created"
elif echo "$out" | grep -q "already exists"; then
echo "ntfy-sh-setup: admin user already exists (ok)"
else
echo "$out" >&2
exit 1
fi
'';
};
};
# -----------------------------------------------------------------------
# Authelia access control — bypass so the mobile app can connect without
# an Authelia session; ntfy enforces its own token/password auth.
# -----------------------------------------------------------------------
homey.authelia.accessControlRules = [{
priority = 10;
domain = [ "ntfy.${domain}" ];
policy = "bypass";
}];
# -----------------------------------------------------------------------
# Caddy virtual host — no forward_auth; ntfy uses its own token auth
# -----------------------------------------------------------------------
homey.caddy.virtualHosts = [{
subdomain = "ntfy";
port = cfg.port;
auth = false;
}];
# -----------------------------------------------------------------------
# Storage directories (owned by the ntfy-sh system user)
# -----------------------------------------------------------------------
homey.storage.extraDirs = [
{ path = "ntfy"; user = "ntfy-sh"; group = "ntfy-sh"; }
{ path = "ntfy/attachments"; user = "ntfy-sh"; group = "ntfy-sh"; }
];
# -----------------------------------------------------------------------
# Backup
# -----------------------------------------------------------------------
homey.backup.extraPaths = [ "${dataDir}/ntfy" ];
# -----------------------------------------------------------------------
# Uptime Kuma monitor for this service
# -----------------------------------------------------------------------
homey.monitoring.monitors = [{
name = "Ntfy";
url = "https://ntfy.${domain}/v1/health";
interval = 60;
}];
};
}
+19 -8
View File
@@ -21,7 +21,7 @@ let
in
{
options.homey.openldap = {
enable = lib.mkEnableOption "OpenLDAP identity provider";
enable = lib.mkEnableOption "OpenLDAP identity provider" // { default = true; };
image = lib.mkOption {
type = lib.types.str;
@@ -50,10 +50,7 @@ in
virtualisation.oci-containers.containers.openldap = {
image = cfg.image;
# 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.
ports = [ "127.0.0.1:${toString cfg.port}:389" ];
environment = {
LDAP_ORGANISATION = homeyConfig.organization;
@@ -78,7 +75,7 @@ in
];
extraOptions = [
"--network=host"
"--network=homey"
"--env-file=/run/openldap-secrets.env"
];
};
@@ -113,10 +110,24 @@ in
# Clean up the env file on stop
postStop = "rm -f /run/openldap-secrets.env";
# Wait for the external HD to be mounted before starting
after = lib.mkAfter [ "mnt-data.mount" ];
requires = lib.mkAfter [ "mnt-data.mount" ];
after = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
requires = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
};
# -----------------------------------------------------------------------
# Storage directories
# -----------------------------------------------------------------------
homey.storage.extraDirs = [
{ path = "openldap"; }
{ path = "openldap/etc-ldap-slapd.d"; }
{ path = "openldap/var-lib-ldap"; }
];
# -----------------------------------------------------------------------
# Backup
# -----------------------------------------------------------------------
homey.backup.extraPaths = [ "${dataDir}/openldap" ];
# -----------------------------------------------------------------------
# Firewall — openldap port is NOT opened externally
# -----------------------------------------------------------------------
+178
View File
@@ -0,0 +1,178 @@
{ config, lib, pkgs, homeyConfig, ... }:
# Paperless-ngx — document management with OCR.
#
# Auth model: HTTP Remote User SSO. Authelia authenticates via Caddy
# forward_auth and sets the Remote-User header; Paperless trusts it and
# auto-creates/logs in the user. No separate Paperless login needed.
#
# The admin user (set via homey.paperless.adminUser) is created as a
# superuser on first start. Its password is randomly generated and never
# used — all logins go through Authelia.
#
# Requires a Redis sidecar for Celery task workers.
#
# iOS Shortcut upload: POST /api/documents/post_document/ with
# Authorization: Token <token>. Generate a dedicated token in the Paperless
# web UI (Profile → API Auth Token) and use it only for the Shortcut so it
# can be revoked independently. The /api/documents/post_document/ path bypasses
# Authelia (see accessControlRules below) — all other paths remain behind one_factor.
#
# Volume layout:
# <dataDir>/paperless/data/ → /usr/src/paperless/data (DB, index)
# <dataDir>/paperless/media/ → /usr/src/paperless/media (document files)
# <dataDir>/paperless/consume/ → /usr/src/paperless/consume (drop folder)
# <dataDir>/paperless/export/ → /usr/src/paperless/export (export output)
#
# Secrets consumed from sops:
# paperless/secret_key
let
cfg = config.homey.paperless;
dataDir = config.homey.storage.mountPoint;
domain = homeyConfig.domain;
in
{
options.homey.paperless = {
enable = lib.mkEnableOption "Paperless-ngx document management" // { default = true; };
image = lib.mkOption {
type = lib.types.str;
default = "ghcr.io/paperless-ngx/paperless-ngx:latest";
};
redisImage = lib.mkOption {
type = lib.types.str;
default = "docker.io/redis:7-alpine";
};
port = lib.mkOption {
type = lib.types.port;
default = 8083;
description = "Host port Paperless listens on (bound to 127.0.0.1).";
};
};
config = lib.mkIf cfg.enable {
# -----------------------------------------------------------------------
# Secrets
# -----------------------------------------------------------------------
sops.secrets."paperless/secret_key" = { owner = "root"; };
# -----------------------------------------------------------------------
# Redis — Celery task queue, stateless (no persistent storage)
# -----------------------------------------------------------------------
virtualisation.oci-containers.containers.paperless-redis = {
image = cfg.redisImage;
extraOptions = [ "--network=homey" ];
};
systemd.services."podman-paperless-redis" = {
after = lib.mkAfter [ "podman-homey-network.service" ];
requires = lib.mkAfter [ "podman-homey-network.service" ];
};
# -----------------------------------------------------------------------
# Paperless container
# -----------------------------------------------------------------------
virtualisation.oci-containers.containers.paperless = {
image = cfg.image;
ports = [ "127.0.0.1:${toString cfg.port}:8000" ];
environment = {
PAPERLESS_REDIS = "redis://paperless-redis:6379";
PAPERLESS_URL = "https://paperless.${domain}";
PAPERLESS_ALLOWED_HOSTS = "paperless.${domain}";
PAPERLESS_CORS_ALLOWED_HOSTS = "https://paperless.${domain}";
PAPERLESS_TIME_ZONE = homeyConfig.timezone;
PAPERLESS_OCR_LANGUAGE = "eng";
USERMAP_UID = "1000";
USERMAP_GID = "1000";
# SSO via Authelia: Caddy's forward_auth copies Remote-User from
# Authelia's response; Gunicorn/WSGI exposes it as HTTP_REMOTE_USER.
PAPERLESS_ENABLE_HTTP_REMOTE_USER = "true";
PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME = "HTTP_REMOTE_USER";
# Redirect to Authelia on logout so the SSO session is also cleared.
PAPERLESS_LOGOUT_REDIRECT_URL = "https://auth.${domain}";
};
environmentFiles = [ "/run/paperless-secrets.env" ];
volumes = [
"${dataDir}/paperless/data:/usr/src/paperless/data"
"${dataDir}/paperless/media:/usr/src/paperless/media"
"${dataDir}/paperless/consume:/usr/src/paperless/consume"
"${dataDir}/paperless/export:/usr/src/paperless/export"
];
extraOptions = [ "--network=homey" ];
};
# -----------------------------------------------------------------------
# ExecStartPre: write ephemeral secrets env file
# -----------------------------------------------------------------------
systemd.services."podman-paperless" = {
serviceConfig = {
ExecStartPre = [
(pkgs.writeShellScript "paperless-write-secrets" ''
set -euo pipefail
install -m 600 /dev/null /run/paperless-secrets.env
printf '%s\n' \
"PAPERLESS_SECRET_KEY=$(cat ${config.sops.secrets."paperless/secret_key".path})" \
>> /run/paperless-secrets.env
'')
];
};
postStop = "rm -f /run/paperless-secrets.env";
after = lib.mkAfter [ "mnt-data.mount" "podman-paperless-redis.service" "podman-homey-network.service" ];
requires = lib.mkAfter [ "mnt-data.mount" "podman-paperless-redis.service" "podman-homey-network.service" ];
};
# -----------------------------------------------------------------------
# Authelia access control — bypass the upload API so token-authenticated
# clients (e.g. iOS Shortcut) can POST without an Authelia session;
# all other paths require one_factor.
# -----------------------------------------------------------------------
homey.authelia.accessControlRules = [
{ priority = 70; domain = [ "paperless.${domain}" ]; resources = [ "^/api/documents/post_document/$" ]; policy = "bypass"; }
{ priority = 71; domain = [ "paperless.${domain}" ]; policy = "one_factor"; }
];
# -----------------------------------------------------------------------
# Caddy virtual host — forward_auth; Remote-User passed to Paperless for SSO
# -----------------------------------------------------------------------
homey.caddy.virtualHosts = [{
subdomain = "paperless";
port = cfg.port;
auth = true;
}];
# -----------------------------------------------------------------------
# Storage directories (UID 1000 = USERMAP_UID in container)
# -----------------------------------------------------------------------
homey.storage.extraDirs = [
{ path = "paperless"; }
{ path = "paperless/data"; user = "1000"; group = "1000"; }
{ path = "paperless/media"; user = "1000"; group = "1000"; }
{ path = "paperless/consume"; user = "1000"; group = "1000"; }
{ path = "paperless/export"; user = "1000"; group = "1000"; }
];
# -----------------------------------------------------------------------
# Backup — exclude consume dir (unprocessed drops, usually empty)
# -----------------------------------------------------------------------
homey.backup.extraPaths = [ "${dataDir}/paperless" ];
homey.backup.extraExcludePaths = [ "${dataDir}/paperless/consume" ];
# -----------------------------------------------------------------------
# Uptime Kuma monitor
# -----------------------------------------------------------------------
homey.monitoring.monitors = [{
name = "Paperless";
url = "https://paperless.${domain}";
interval = 60;
}];
};
}
+37 -9
View File
@@ -3,7 +3,7 @@
# phpLDAPadmin — web UI for OpenLDAP management.
#
# Stateless container (no persistent volumes needed).
# Protected by Authelia two_factor, admins-only policy (defined in authelia.nix).
# Protected by Authelia two_factor, admins-only policy.
# Bound to localhost:8081; Caddy reverse-proxies it.
#
# Networking: uses default bridge (podman) network with a port mapping
@@ -13,10 +13,11 @@
let
cfg = config.homey.phpldapadmin;
domain = homeyConfig.domain;
in
{
options.homey.phpldapadmin = {
enable = lib.mkEnableOption "phpLDAPadmin web interface";
enable = lib.mkEnableOption "phpLDAPadmin web interface" // { default = true; };
image = lib.mkOption {
type = lib.types.str;
@@ -36,19 +37,46 @@ in
environment = {
PHPLDAPADMIN_HTTPS = "false";
# 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";
# "openldap" resolves to the OpenLDAP container via homey network DNS.
PHPLDAPADMIN_LDAP_HOSTS = "openldap";
};
# 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" ];
extraOptions = [ "--network=homey" ];
};
systemd.services."podman-phpldapadmin" = {
after = lib.mkAfter [ "podman-openldap.service" ];
wants = lib.mkAfter [ "podman-openldap.service" ];
after = lib.mkAfter [ "podman-openldap.service" "podman-homey-network.service" ];
wants = lib.mkAfter [ "podman-openldap.service" "podman-homey-network.service" ];
};
# -----------------------------------------------------------------------
# Authelia access control — admins only, two_factor; all others denied.
# -----------------------------------------------------------------------
homey.authelia.accessControlRules = [
{ priority = 20; domain = [ "ldapadmin.${domain}" ]; subject = [ "group:admins" ]; policy = "two_factor"; }
{ priority = 21; domain = [ "ldapadmin.${domain}" ]; policy = "deny"; }
];
# -----------------------------------------------------------------------
# Caddy virtual host — forward_auth + reverse_proxy
# -----------------------------------------------------------------------
homey.caddy.virtualHosts = [{
subdomain = "ldapadmin";
port = cfg.port;
auth = true;
}];
# phpLDAPadmin is stateless (no persistent volumes) — no storage or backup entries needed.
# -----------------------------------------------------------------------
# Uptime Kuma monitor for this service
# -----------------------------------------------------------------------
homey.monitoring.monitors = [{
name = "phpLDAPadmin";
url = "http://phpldapadmin:80";
interval = 60;
}];
};
}
+38 -9
View File
@@ -15,10 +15,11 @@
let
cfg = config.homey.transmission;
dataDir = config.homey.storage.mountPoint;
domain = homeyConfig.domain;
in
{
options.homey.transmission = {
enable = lib.mkEnableOption "Transmission torrent client";
enable = lib.mkEnableOption "Transmission torrent client" // { default = true; };
image = lib.mkOption {
type = lib.types.str;
@@ -35,16 +36,14 @@ in
config = lib.mkIf cfg.enable {
virtualisation.oci-containers.containers.transmission = {
image = cfg.image;
# No ports mapping — --network=host shares the host network stack directly.
# Map host cfg.port (9092) → container 9091 so Caddy can reach it
# without conflicting with Authelia's host port (also 9091).
ports = [ "127.0.0.1:${toString cfg.port}:9091" ];
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 = [
@@ -55,12 +54,42 @@ in
"${dataDir}/media/complete:/downloads/complete"
];
extraOptions = [ "--network=host" ];
extraOptions = [ "--network=homey" ];
};
systemd.services."podman-transmission" = {
after = lib.mkAfter [ "mnt-data.mount" ];
requires = lib.mkAfter [ "mnt-data.mount" ];
after = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
requires = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
};
# -----------------------------------------------------------------------
# Authelia access control — admins only, two_factor; all others denied.
# -----------------------------------------------------------------------
homey.authelia.accessControlRules = [
{ priority = 30; domain = [ "torrent.${domain}" ]; subject = [ "group:admins" ]; policy = "two_factor"; }
{ priority = 31; domain = [ "torrent.${domain}" ]; policy = "deny"; }
];
# -----------------------------------------------------------------------
# Caddy virtual host — forward_auth, admins only
# -----------------------------------------------------------------------
homey.caddy.virtualHosts = [{
subdomain = "torrent";
port = cfg.port;
auth = true;
}];
# -----------------------------------------------------------------------
# Storage directories
# -----------------------------------------------------------------------
homey.storage.extraDirs = [
{ path = "transmission"; }
{ path = "transmission/config"; }
];
# -----------------------------------------------------------------------
# Backup
# -----------------------------------------------------------------------
homey.backup.extraPaths = [ "${dataDir}/transmission" ];
};
}
+326
View File
@@ -0,0 +1,326 @@
{ config, lib, pkgs, homeyConfig, ... }:
# Uptime Kuma — endpoint uptime monitoring with a status-page UI.
#
# This module does two things:
#
# 1. Declares the shared homey.monitoring.monitors option that any service
# module can contribute to. Adding your service's URL there means it
# automatically appears in Uptime Kuma — no manual UI work needed.
#
# 2. Runs Uptime Kuma as an OCI container and syncs the monitor list via
# the Socket.IO API on startup using the uptime-kuma-api Python library.
#
# Example (in nextcloud.nix):
# homey.monitoring.monitors = [{
# name = "Nextcloud";
# url = "https://nextcloud.zakobar.com/status.php";
# interval = 60;
# }];
#
# Auth: Authelia two_factor, admins-only (enforced in authelia.nix + caddy.nix).
#
# Volume layout:
# <dataDir>/uptime-kuma/ → /app/data (SQLite DB, config)
#
# Secrets consumed from sops:
# uptime-kuma/admin_password
let
cfg = config.homey.uptimeKuma;
dataDir = config.homey.storage.mountPoint;
domain = homeyConfig.domain;
# Serialise the NixOS monitor list to JSON at build time.
# The setup script reads this at runtime to know what to create.
monitorsJson = pkgs.writeText "uptime-kuma-monitors.json"
(builtins.toJSON config.homey.monitoring.monitors);
# Python environment for the monitor-sync script.
# uptime-kuma-api's transitive deps (requests, socketio, websocket-client)
# are listed explicitly because withPackages doesn't always pull propagated
# deps transitively in all nixpkgs versions.
pythonEnv = pkgs.python3.withPackages (ps: [
ps."uptime-kuma-api"
ps.requests
ps."python-socketio"
ps."websocket-client"
]);
# Monitor-sync script: idempotent, hash-gated, uses Socket.IO API
syncScript = pkgs.writeText "uptime-kuma-sync.py" ''
#!/usr/bin/env python3
"""
Sync monitors declared in /etc/uptime-kuma/monitors.json into Uptime Kuma.
Runs as a oneshot systemd service after podman-uptime-kuma.service.
Tracks a hash of the monitor list so it only re-syncs when the NixOS
config changes.
Uptime Kuma v1 has no REST API everything is Socket.IO. Initial admin
creation uses api.setup() which raises if already done (we ignore that).
"""
import hashlib
import json
import os
import sys
import time
import urllib.request
MONITORS_PATH = "/etc/uptime-kuma/monitors.json"
HASH_PATH = "/var/lib/uptime-kuma-setup/last-hash"
KUMA_URL = "http://localhost:3001"
CREDS_DIR = os.environ.get("CREDENTIALS_DIRECTORY", "")
def wait_for_kuma(timeout=120):
"""Wait until Uptime Kuma HTTP responds (any non-5xx just checks it's up)."""
deadline = time.time() + timeout
while time.time() < deadline:
try:
with urllib.request.urlopen(KUMA_URL, timeout=5) as r:
if r.status < 500:
return True
except Exception:
pass
time.sleep(3)
return False
def main():
with open(MONITORS_PATH) as f:
monitors = json.load(f)
config_hash = hashlib.sha256(
json.dumps(monitors, sort_keys=True).encode()
).hexdigest()
# Skip sync if config hasn't changed
try:
with open(HASH_PATH) as f:
if f.read().strip() == config_hash:
print("uptime-kuma-sync: config unchanged, skipping")
return
except FileNotFoundError:
pass
password_file = os.path.join(CREDS_DIR, "uptime_kuma_password")
with open(password_file) as f:
password = f.read().strip()
print("uptime-kuma-sync: waiting for Uptime Kuma to be ready...")
if not wait_for_kuma():
print("uptime-kuma-sync: timed out waiting for Uptime Kuma", file=sys.stderr)
sys.exit(1)
from uptime_kuma_api import UptimeKumaApi, MonitorType
api = UptimeKumaApi(KUMA_URL)
# Initial admin setup via Socket.IO — idempotent (raises if already done, ignore it)
try:
api.setup("admin", password)
print("uptime-kuma-sync: initial admin user created")
except Exception as e:
print(f"uptime-kuma-sync: setup skipped (already configured): {e}")
# Login
try:
api.login("admin", password)
except Exception as e:
print(f"uptime-kuma-sync: login failed: {e}", file=sys.stderr)
api.disconnect()
sys.exit(1)
# Sync monitors: add missing, update changed
try:
existing = {m["name"]: m for m in api.get_monitors()}
for m in monitors:
keyword = m.get("keyword")
maxretries = m.get("maxretries", 0)
monitor_type = MonitorType.KEYWORD if keyword else MonitorType.HTTP
extra = {"keyword": keyword} if keyword else {}
if m["name"] not in existing:
api.add_monitor(
type=monitor_type,
name=m["name"],
url=m["url"],
interval=m.get("interval", 60),
maxretries=maxretries,
**extra,
)
print(f"uptime-kuma-sync: created monitor: {m['name']}")
elif (existing[m["name"]].get("url") != m["url"]
or existing[m["name"]].get("keyword") != keyword
or existing[m["name"]].get("maxretries") != maxretries):
api.edit_monitor(
existing[m["name"]]["id"],
type=monitor_type,
url=m["url"],
interval=m.get("interval", 60),
maxretries=maxretries,
**extra,
)
print(f"uptime-kuma-sync: updated monitor: {m['name']}")
finally:
api.disconnect()
# Persist hash so we don't re-sync on every boot
os.makedirs(os.path.dirname(HASH_PATH), exist_ok=True)
with open(HASH_PATH, "w") as f:
f.write(config_hash)
print("uptime-kuma-sync: done")
if __name__ == "__main__":
main()
'';
in
{
# ---------------------------------------------------------------------------
# Shared monitor-list option — declared unconditionally so any service module
# can contribute monitors even when Uptime Kuma itself is disabled.
# ---------------------------------------------------------------------------
options.homey.monitoring.monitors = lib.mkOption {
type = lib.types.listOf (lib.types.submodule {
options = {
name = lib.mkOption {
type = lib.types.str;
description = "Display name shown in Uptime Kuma.";
};
url = lib.mkOption {
type = lib.types.str;
description = "URL to check (HTTP/HTTPS).";
};
interval = lib.mkOption {
type = lib.types.int;
default = 60;
description = "Check interval in seconds.";
};
keyword = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "If set, use a keyword monitor that checks for this string in the response body.";
};
maxretries = lib.mkOption {
type = lib.types.int;
default = 0;
description = "Consecutive failures before a DOWN alert is sent. 0 = alert immediately.";
};
};
});
default = [];
description = ''
List of HTTP endpoints to monitor in Uptime Kuma.
Each service module contributes its own entries here.
'';
};
options.homey.uptimeKuma = {
enable = lib.mkEnableOption "Uptime Kuma uptime monitoring" // { default = true; };
image = lib.mkOption {
type = lib.types.str;
default = "docker.io/louislam/uptime-kuma:1";
};
port = lib.mkOption {
type = lib.types.port;
default = 3001;
description = "Host port Uptime Kuma listens on (bound to 127.0.0.1).";
};
};
config = lib.mkIf cfg.enable {
# -----------------------------------------------------------------------
# Secrets
# -----------------------------------------------------------------------
sops.secrets."uptime-kuma/admin_password" = { owner = "root"; };
# -----------------------------------------------------------------------
# Write monitor list to /etc at build time
# -----------------------------------------------------------------------
environment.etc."uptime-kuma/monitors.json" = {
source = monitorsJson;
mode = "0444";
};
# -----------------------------------------------------------------------
# Uptime Kuma container
# -----------------------------------------------------------------------
virtualisation.oci-containers.containers.uptime-kuma = {
image = cfg.image;
ports = [ "127.0.0.1:${toString cfg.port}:3001" ];
volumes = [
"${dataDir}/uptime-kuma:/app/data"
];
# Join the homey network so monitors can reach other containers by name
# (e.g. phpldapadmin:80) without going through the host loopback.
extraOptions = [ "--network=homey" ];
};
systemd.services."podman-uptime-kuma" = {
after = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
requires = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
};
# -----------------------------------------------------------------------
# Monitor-sync service: runs after Uptime Kuma is up, syncs monitors
# -----------------------------------------------------------------------
systemd.services."uptime-kuma-sync" = {
description = "Sync Uptime Kuma monitors from NixOS config";
wantedBy = [ "multi-user.target" ];
after = [ "podman-uptime-kuma.service" ];
requires = [ "podman-uptime-kuma.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
LoadCredential = "uptime_kuma_password:${config.sops.secrets."uptime-kuma/admin_password".path}";
ExecStart = pkgs.writeShellScript "uptime-kuma-sync-runner" ''
set -euo pipefail
exec ${pythonEnv}/bin/python3 ${syncScript}
'';
};
};
# -----------------------------------------------------------------------
# Authelia access control — admins only, two_factor; all others denied.
# -----------------------------------------------------------------------
homey.authelia.accessControlRules = [
{ priority = 25; domain = [ "uptime.${domain}" ]; subject = [ "group:admins" ]; policy = "two_factor"; }
{ priority = 26; domain = [ "uptime.${domain}" ]; policy = "deny"; }
];
# -----------------------------------------------------------------------
# Caddy virtual host — forward_auth, admins only
# -----------------------------------------------------------------------
homey.caddy.virtualHosts = [{
subdomain = "uptime";
port = cfg.port;
auth = true;
}];
# -----------------------------------------------------------------------
# Storage directories
# -----------------------------------------------------------------------
homey.storage.extraDirs = [
{ path = "uptime-kuma"; }
];
# -----------------------------------------------------------------------
# Backup
# -----------------------------------------------------------------------
homey.backup.extraPaths = [ "${dataDir}/uptime-kuma" ];
# -----------------------------------------------------------------------
# Uptime Kuma self-monitor
# -----------------------------------------------------------------------
homey.monitoring.monitors = [{
name = "Uptime Kuma";
url = "https://uptime.${domain}";
interval = 60;
}];
};
}
+28 -19
View File
@@ -29,6 +29,11 @@
# complete/
# transmission/
# config/
# uptime-kuma/ ← /app/data in uptime-kuma container (SQLite DB, config)
# ntfy/
# auth.db ← user/token auth database
# cache.db ← message cache
# attachments/ ← file attachments
# restic-cache/ ← restic local cache (not the backup destination)
let
@@ -58,6 +63,22 @@ in
default = "ext4";
description = "Filesystem type of the external drive.";
};
extraDirs = lib.mkOption {
type = lib.types.listOf (lib.types.submodule {
options = {
path = lib.mkOption {
type = lib.types.str;
description = "Path relative to mountPoint (e.g. \"gitea/data\").";
};
mode = lib.mkOption { type = lib.types.str; default = "0750"; };
user = lib.mkOption { type = lib.types.str; default = "root"; };
group = lib.mkOption { type = lib.types.str; default = "root"; };
};
});
default = [];
description = "Per-service directories to create under mountPoint. Each service module contributes its own entries.";
};
};
config = lib.mkIf (cfg.device != "") {
@@ -74,32 +95,20 @@ in
];
};
# Ensure the mount point directory exists
# Mount point root + shared infrastructure dirs (restic cache, shared media).
# Per-service dirs are contributed via homey.storage.extraDirs by each
# service module, so adding a new service only requires editing that module.
systemd.tmpfiles.rules = [
"d ${cfg.mountPoint} 0755 root root -"
# Service subdirectories — created on boot so containers can start
# even before any data is restored into them.
"d ${cfg.mountPoint}/openldap 0750 root root -"
"d ${cfg.mountPoint}/openldap/etc-ldap-slapd.d 0750 root root -"
"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 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 -"
"d ${cfg.mountPoint}/jellyfin 0750 root root -"
"d ${cfg.mountPoint}/jellyfin/config 0750 root root -"
# Shared media directories used by both Jellyfin and Transmission.
"d ${cfg.mountPoint}/media 0755 root root -"
"d ${cfg.mountPoint}/media/movies 0755 root root -"
"d ${cfg.mountPoint}/media/tvshows 0755 root root -"
"d ${cfg.mountPoint}/media/general 0755 root root -"
"d ${cfg.mountPoint}/media/complete 0755 root root -"
"d ${cfg.mountPoint}/transmission 0750 root root -"
"d ${cfg.mountPoint}/transmission/config 0750 root root -"
"d ${cfg.mountPoint}/restic-cache 0700 root root -"
];
] ++ (map
(d: "d ${cfg.mountPoint}/${d.path} ${d.mode} ${d.user} ${d.group} -")
config.homey.storage.extraDirs);
};
}
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
# offload-backup.sh — back up /mnt/data directly to a USB drive using restic.
#
# Run on the Pi (see homey-offload-backup in the dev shell).
# Scans for plugged-in USB partitions, lets you pick one, mounts it if needed,
# initialises a restic repo on it, and runs a backup of all service data dirs.
#
# The restic password is read from the sops-managed secret at runtime;
# no S3 credentials are needed — this is a direct local backup.
#
# Usage: sudo bash offload-backup.sh
set -euo pipefail
REPO_NAME="homey-backup"
DATA_DIR="/mnt/data"
PASSWORD_FILE="/run/secrets/restic/password"
BACKUP_PATHS=(
"$DATA_DIR/openldap"
"$DATA_DIR/authelia"
"$DATA_DIR/gitea"
"$DATA_DIR/nextcloud"
"$DATA_DIR/jellyfin"
"$DATA_DIR/transmission"
)
EXCLUDE_ARGS=(
--exclude "$DATA_DIR/nextcloud/db"
--exclude "$DATA_DIR/restic-cache"
)
# ---------------------------------------------------------------------------
# Find USB partitions
# ---------------------------------------------------------------------------
echo "Scanning for USB drives..."
mapfile -t USB_PARTS < <(
lsblk -o NAME,SIZE,TRAN,LABEL,MOUNTPOINT -rn \
| awk '$3 == "usb" && $2 != "" {print $1, $2, $4, $5}'
)
if [ "${#USB_PARTS[@]}" -eq 0 ]; then
echo "No USB partitions found. Plug in a USB drive and try again." >&2
exit 1
fi
echo ""
echo "Available USB partitions:"
for i in "${!USB_PARTS[@]}"; do
read -r dev size label mount <<< "${USB_PARTS[$i]}"
label="${label:-(no label)}"
mount="${mount:-(not mounted)}"
printf " [%d] /dev/%s %s label=%s mount=%s\n" \
"$((i + 1))" "$dev" "$size" "$label" "$mount"
done
echo ""
read -rp "Select a partition [1-${#USB_PARTS[@]}]: " CHOICE
if ! [[ "$CHOICE" =~ ^[0-9]+$ ]] \
|| [ "$CHOICE" -lt 1 ] \
|| [ "$CHOICE" -gt "${#USB_PARTS[@]}" ]; then
echo "Invalid selection." >&2
exit 1
fi
read -r SELECTED_DEV _ _ EXISTING_MOUNT <<< "${USB_PARTS[$((CHOICE - 1))]}"
SELECTED_DEV="/dev/$SELECTED_DEV"
# ---------------------------------------------------------------------------
# Mount if needed
# ---------------------------------------------------------------------------
MOUNTED_HERE=false
MOUNT_DIR=""
if [ -n "$EXISTING_MOUNT" ]; then
MOUNT_DIR="$EXISTING_MOUNT"
echo "Using existing mount at $MOUNT_DIR"
else
MOUNT_DIR=$(mktemp -d)
echo "Mounting $SELECTED_DEV at $MOUNT_DIR..."
mount "$SELECTED_DEV" "$MOUNT_DIR"
MOUNTED_HERE=true
fi
cleanup() {
if [ "$MOUNTED_HERE" = true ] && [ -n "$MOUNT_DIR" ]; then
echo "Unmounting $MOUNT_DIR..."
umount "$MOUNT_DIR"
rmdir "$MOUNT_DIR"
fi
}
trap cleanup EXIT
# ---------------------------------------------------------------------------
# Initialise restic repo if this is the first run
# ---------------------------------------------------------------------------
REPO="$MOUNT_DIR/$REPO_NAME"
if ! restic -r "$REPO" --password-file "$PASSWORD_FILE" snapshots &>/dev/null 2>&1; then
echo "Initialising restic repository at $REPO..."
restic -r "$REPO" --password-file "$PASSWORD_FILE" init
fi
# ---------------------------------------------------------------------------
# Run the backup
# ---------------------------------------------------------------------------
echo ""
echo "Backing up to $REPO..."
restic -r "$REPO" \
--password-file "$PASSWORD_FILE" \
--cache-dir "$DATA_DIR/restic-cache" \
backup \
"${BACKUP_PATHS[@]}" \
"${EXCLUDE_ARGS[@]}"
echo ""
echo "Snapshots on this drive:"
restic -r "$REPO" --password-file "$PASSWORD_FILE" snapshots
+19 -2
View File
@@ -1,3 +1,10 @@
uptime-kuma:
admin_password: ENC[AES256_GCM,data:tPKWxWmxRcVJeywY3J4eXAWWnAinLwMn3X68TrV/4emonvRiuyPmiwhn2fjDxwB/kT78y/iDDmpdQY229yJrkQ==,iv:YSL40PDbRTgtSYZCwqHzfJTcEAiILIDbGRA2kfamiw8=,tag:pMM0AWkjkcS9XOaSHG1oUQ==,type:str]
ntfy:
admin_password: ENC[AES256_GCM,data:P5pjnt00lyeGVlrBvUlJWWeTi3evFZPJIxjcsndbo4LZOLk6hbbrh8RwCAGzr1ump0A5fRXqynByRFdaS6++wA==,iv:Uxeh0/mygR++4S//O/RO2bouH2J0qcSCYtjjyZNooNk=,tag:LGIDaq4RzBuzrWFqVDr8ow==,type:str]
web_push_private_key: ENC[AES256_GCM,data:BggPo7uYjda48iV3G8TaPk7mPZXHv+H6MW3BeMYFaxYCVAok0zT7Tzko7A==,iv:qPX8N4mzD4DWX2tWlsQCK09PD0R4ntrJMqYOqwwzGXg=,tag:pXIp3pAkYQpdbXG/PtsFag==,type:str]
grafana:
secret_key: ENC[AES256_GCM,data:/KNDMZZN5thoqsgJZS7fuNQULI1PAKVuihRu9WzO00Qw8js/V4KKJT0JOVOcqdHAnf44+szYZaCWt0xe02chGw==,iv:Y0FQ7h4SqZVtz0wLjPnVGGYyXmBIDi8nzaK2GFzDxqQ=,tag:w0z5/vI3Hfd8ry9DCHAvJw==,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]
@@ -11,6 +18,7 @@ gitea:
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]
runner_token: ENC[AES256_GCM,data:fNiP3hIhBw16zYAt9dMuGu6C3n48R6H4O8en8JzRnNy0KGbbvv08w8qRceD6XQ==,iv:DJarsN6yYbdyesd5MoQEB0mDdS9O39VLKmJUIicTlG8=,tag:8+W6jYg8kSqy6FztaJnn9w==,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]
@@ -23,6 +31,15 @@ restic:
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]
eurovote:
secret_key: ENC[AES256_GCM,data:Re9MTYA46ERXsxucT19K4Pj3rV5i74s8zQ/WYj6GlxeoN1r0Oit6PP0C3PY5Arp6Y6g=,iv:0BnuZ9Uv2RgDwlisrVSvg7ESmNZvd8trggQDSJ42ewM=,tag:SXW2hbprj2qSRzjKY3Aw3Q==,type:str]
paperless:
secret_key: ENC[AES256_GCM,data:jHbyLh4Yn0v7huw9oJiytMJ5KjifmEFsWh3u+YyOTlnm/M313dAigZItcX860oFVtZ8zZcuelUVAjcmIcl1LYw==,iv:PJhyXWa4r99dIXuKrEF+2wF9O8GEHIK8ereNQiXzO3Q=,tag:qDcPs3ulzjdQ2EUibo1Nlw==,type:str]
mealie:
secret_key: ENC[AES256_GCM,data:AmtyMMK2RMOy//o9G974wn5IcgZaqAn97OyNaY1AlMc5cCoydZhdAXymQ4RR8opWd+Oelx7vRcSscGJ0hTGakg==,iv:QH+iIbMoD33MAUraMTyuGghaWdjRBhypP9UEcEr9bL4=,tag:uHGW9OLqrDhRy+mnlfRmQA==,type:str]
attic:
jwt_secret: ENC[AES256_GCM,data:6g1wDau2rEqrmirzamrE6q0Sf38tosCp7EM0EtMLHXANoEfUdK8aL2Jo6z+tWL5bhNTkHwOl55j2mbyUWDlFN3I9vtI9uPKjlP+SgGbSJoKv++UYIhBmcg==,iv:DBgrMPQG/V9g0vG6Ax/fb1xCpvTYSfvAhqojH84wgn8=,tag:9WJjMFuo9kSfxRI9DVpdlg==,type:str]
pull_token: ENC[AES256_GCM,data:FDMRf8El1APXdE1+CraGDKBk9PvAnLFNL9YqvDA++5keV/M7ynAdvAhzJV1dkQ2PcRJKalAkWY0zkoQsXzmWRdY/30WzhHa60GPRRfdX4Bc1N2DqK9mFfO4eWFSBRF5EgZkqWJ+XcijiKHTr3W6MNt8oD+YQ6XkKLvRs6tOep085g2ZdK9jmaQnWTsFMhYmUt//THscDPBq8Jh81Uh2WcLJYB4hEGxxIZZtsbdK6AsRjlMsxkzr+W4kwVKs8aGjqJ5LvUOCHPGY9DvdGtWMMvMs9aw20b05ViuKzemMfDd0=,iv:CzzhhbYtJhtrAIMkERGim+j0pvC5anHVwguV//VrJRQ=,tag:6uGz1f1w76Bk8bbZItYzDg==,type:str]
sops:
age:
- recipient: age120j8ty7nn04l3s3kgph5ty3v9g4e52fknn8xtnmzwakq9nv2la3skgte0p
@@ -34,8 +51,8 @@ sops:
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]
lastmodified: "2026-05-30T09:31:03Z"
mac: ENC[AES256_GCM,data:Mnu3wtu6gfGWtU+03KyTKa9n0uWsRCISRZJcZaF2n9wCD/GDikqUX6QFFZcHHoablXEqN6yu5u0wc7efX80PCnDlkr8C0gQF3i9+p9Kj+i+pfguG47sfqP3ITXIjJpwwZwiFlbCJ/Hj3bpIpUCwr3gb6KQjQZ2bm7SGDlNeV9Ys=,iv:SbFCyuMKaYA3yKvh/DcslA98/cBXTBI7sn3TJ3RZ+y4=,tag:Eh9kMKe4pT8H9O1UZWaTRA==,type:str]
pgp:
- created_at: "2026-04-21T06:39:49Z"
enc: |-
+3
View File
@@ -0,0 +1,3 @@
{pkgs} @ args: {
default = import ./defaultShell.nix args;
}
+46
View File
@@ -0,0 +1,46 @@
{pkgs} @ args:
pkgs.mkShell {
buildInputs = with pkgs; [
alejandra
sops
openssl
(pkgs.writeShellScriptBin "homey-deploy-rpi-main" ''
nixos-rebuild switch \
--flake .#pi-main \
--target-host admin@192.168.1.100 \
--build-host admin@192.168.1.100 \
--use-remote-sudo
'')
(pkgs.writeShellScriptBin "homey-build-rpi-main" ''
sudo nixos-rebuild switch \
--flake .#pi-main
'')
(pkgs.writeShellScriptBin "homey-offload-backup" ''
set -euo pipefail
scp scripts/offload-backup.sh admin@192.168.1.100:/tmp/homey-offload-backup.sh
ssh -t admin@192.168.1.100 'sudo bash /tmp/homey-offload-backup.sh; rm /tmp/homey-offload-backup.sh'
'')
(pkgs.writeShellScriptBin "homey-backup-status" ''
ssh admin@192.168.1.100 bash -s <<'ENDSSH'
echo "=== Backup timer ==="
systemctl status restic-backups-homey.timer --no-pager -l 2>&1 || true
echo ""
echo "=== Last backup run (journal) ==="
journalctl -u restic-backups-homey.service -n 50 --no-pager 2>&1 || true
echo ""
echo "=== Recent snapshots ==="
sudo bash -c '
export AWS_ACCESS_KEY_ID=$(cat /run/secrets/restic/s3_access_key_id)
export AWS_SECRET_ACCESS_KEY=$(cat /run/secrets/restic/s3_secret_access_key)
export RESTIC_CACHE_DIR=/mnt/data/restic-cache
restic \
-r "s3:https://s3.us-east-005.backblazeb2.com/zakobar-home-backup" \
--password-file /run/secrets/restic/password \
snapshots --latest 5
' 2>&1
ENDSSH
'')
];
}