Everything changed - major rewrite

This commit is contained in:
Aner Zakobar
2026-06-07 00:59:22 +03:00
parent 08e8b5edbe
commit 261cf892dd
20 changed files with 673 additions and 139 deletions
+142 -50
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,14 +49,20 @@ 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) |
## Networking
@@ -63,7 +76,16 @@ All containers join a private podman network named **`homey`**, created by the
- **Defence in depth** — even if the firewall were misconfigured, services are
not bound to `0.0.0.0`.
Internal ports (all mapped to `127.0.0.1` on the host):
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 |
|-----------|-----------|----------------|
@@ -73,6 +95,10 @@ Internal ports (all mapped to `127.0.0.1` on the host):
| 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 |
@@ -98,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
@@ -160,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 { ... };
@@ -168,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
@@ -176,7 +219,7 @@ 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=homey`** — all containers join the private `homey` podman
@@ -187,16 +230,47 @@ restic password, Cloudflare tokens) can be generated fresh.
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
@@ -240,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.
@@ -293,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`
@@ -302,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
+1
View File
@@ -78,6 +78,7 @@
./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
+7
View File
@@ -96,6 +96,13 @@
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;
+8
View File
@@ -205,6 +205,14 @@ in
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
+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;
}];
};
}
+69 -70
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 #
@@ -79,75 +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"
- domain:
- "uptime.${domain}"
subject:
- "group:admins"
policy: "two_factor"
- domain:
- "uptime.${domain}"
policy: "deny"
- domain:
- "grafana.${domain}"
subject:
- "group:admins"
policy: "two_factor"
- domain:
- "grafana.${domain}"
policy: "deny"
# ntfy: bypass ntfy enforces its own token/password auth;
# the mobile app must be able to connect without Authelia SSO.
- domain:
- "ntfy.${domain}"
policy: "bypass"
# Eurovision Vote: /admin/* for admins only; all others one_factor
- domain:
- "eurovision-vote.${domain}"
resources:
- "^/admin.*$"
subject:
- "group:admins"
policy: "two_factor"
- domain:
- "eurovision-vote.${domain}"
resources:
- "^/admin.*$"
policy: "deny"
- domain:
- "eurovision-vote.${domain}"
policy: "one_factor"
- domain:
- "paperless.${domain}"
policy: "one_factor"
${rulesYaml}
notifier:
filesystem:
filename: "/config/emails.txt"
@@ -163,6 +119,40 @@ let
in
{
options.homey.authelia = {
# 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 {
@@ -178,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
# -----------------------------------------------------------------------
+11 -1
View File
@@ -12,7 +12,7 @@
# 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 (see authelia.nix).
# group:admins by Authelia's access_control rules (defined in this file).
#
# Secrets consumed from sops:
# eurovote/secret_key
@@ -48,6 +48,16 @@ in
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
+11
View File
@@ -188,6 +188,17 @@ in
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
# -----------------------------------------------------------------------
+9
View File
@@ -52,6 +52,15 @@ in
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
# -----------------------------------------------------------------------
+7 -2
View File
@@ -11,6 +11,7 @@
#
# Secrets consumed from sops:
# mealie/secret_key
# openldap/ro_password (shared with openldap module — used as LDAP_QUERY_PASSWORD)
let
cfg = config.homey.mealie;
@@ -42,6 +43,7 @@ in
# Secrets
# -----------------------------------------------------------------------
sops.secrets."mealie/secret_key" = { owner = "root"; };
sops.secrets."openldap/ro_password" = { owner = "root"; };
# -----------------------------------------------------------------------
# Container
@@ -55,12 +57,14 @@ in
ALLOW_SIGNUP = "false";
TZ = homeyConfig.timezone;
# LDAP auth — users log in with their LDAP uid and password.
# Mealie binds directly as the user (no service account needed).
# 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";
@@ -87,6 +91,7 @@ in
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
'')
];
+9
View File
@@ -166,6 +166,15 @@ in
];
};
# -----------------------------------------------------------------------
# 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
# -----------------------------------------------------------------------
+10
View File
@@ -176,6 +176,16 @@ in
};
};
# -----------------------------------------------------------------------
# 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
# -----------------------------------------------------------------------
+16
View File
@@ -12,6 +12,12 @@
#
# 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)
@@ -124,6 +130,16 @@ in
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
# -----------------------------------------------------------------------
+10 -1
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,6 +13,7 @@
let
cfg = config.homey.phpldapadmin;
domain = homeyConfig.domain;
in
{
options.homey.phpldapadmin = {
@@ -50,6 +51,14 @@ in
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
# -----------------------------------------------------------------------
+9
View File
@@ -15,6 +15,7 @@
let
cfg = config.homey.transmission;
dataDir = config.homey.storage.mountPoint;
domain = homeyConfig.domain;
in
{
options.homey.transmission = {
@@ -61,6 +62,14 @@ in
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
# -----------------------------------------------------------------------
+8
View File
@@ -285,6 +285,14 @@ in
};
};
# -----------------------------------------------------------------------
# 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
# -----------------------------------------------------------------------
+5 -2
View File
@@ -37,6 +37,9 @@ 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
@@ -48,8 +51,8 @@ sops:
QXVkRlJHeW52NFZFYnVwaW8ycytDSzAKZt+p5QnZKcEOBghHA2xkH6d7NObtTEoE
wMwCYasnBHzy2unXRbZq/4v9NQ5HJd0Nu1iqbqKgIxMCD3dnxEdK7g==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-05-20T19:56:00Z"
mac: ENC[AES256_GCM,data:i/uXzipvkadRGHxj7sk593SyALHVdv8wjH74xBduCI3y1cgsMYhAzH2+zY2N6BZ2ymrrcEI1+bVr2JAsCRASZ62dKEc3+m9H0+6ydpb5hl9kK7fLpuxy2nntMhTPnHznquysF4cRZoZeUJ0bDudo8mnog7GYoDI8LUvqEXZR/M8=,iv:X5WugE4STQEZHhaq6OzJLpXUgk+imbZNLZnXl5J1jUw=,tag:IDmsFOJEikpQKqi97ZKngg==,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: |-
+1
View File
@@ -3,6 +3,7 @@ pkgs.mkShell {
buildInputs = with pkgs; [
alejandra
sops
openssl
(pkgs.writeShellScriptBin "homey-deploy-rpi-main" ''
nixos-rebuild switch \
--flake .#pi-main \