Compare commits

...

22 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
Aner Zakobar 5e82ca5fe0 Merge nixos-port: complete NixOS port of homey selfhosted stack
Replaces Helm/k8s deployment with flake-based NixOS config.
All core services working: Caddy, Authelia, OpenLDAP, phpLDAPadmin, Gitea.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:46:29 +03:00
Aner Zakobar 0b73d493d8 Working NixOS port: all core services operational
- Fix Caddy cfProxy helper for cloudflared http:// vhosts (X-Forwarded-Proto)
- Fix Authelia LDAP bind (readonly user ACL + password sync)
- Add gitea-admin-setup oneshot service to survive rebuilds
- Update Authelia forward_auth with header_up X-Forwarded-Proto https
- Update TODO.org with completed tasks and LDAP config details
- Remove old Helm/k8s artifacts (Chart.yaml, templates/, values/, scripts)
- Add result to .gitignore

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:46:21 +03:00
Aner Zakobar 05619d12fc Changes to rpi setup 2026-04-20 05:40:09 +03:00
Aner Zakobar e2ff0eb428 Update AGENTS.md for NixOS port branch 2026-04-15 17:20:35 +03:00
Aner Zakobar 2f0d0b5e4c Port to NixOS: replace Helm chart with flake-based NixOS config
Replaces the Helm/k3s setup with a declarative NixOS configuration targeting
a Raspberry Pi 4. Services run as podman containers under systemd, with data
on an external HD at /mnt/data. Key components:

- flake.nix: multi-host flake with pi-main (aarch64) and a placeholder for a
  second machine
- modules/common.nix: shared system config (nix, podman, sops, SSH)
- modules/storage.nix: external HD mount with per-service subdirs
- modules/caddy.nix: Caddy with cloudflare DNS-01 ACME + authelia forward_auth
- modules/cloudflared.nix: Cloudflare tunnel for remote access
- modules/backup.nix: restic daily backups with NC maintenance mode pre-hook
- modules/services/{openldap,authelia,gitea,nextcloud,phpldapadmin}.nix: core services
- modules/services/{jellyfin,transmission}.nix: media services (disabled by default)
- secrets/: sops-nix scaffold with .sops.yaml age key config
- hosts/pi-main/: hardware config + service selection for the Pi
- PORTING.md: step-by-step migration guide (SD card → data restore → verify)
2026-04-15 17:18:12 +03:00
Aner Zakobar d1948df47e TMP COMMIT BEFORE TRASHING 2026-04-15 16:49:18 +03:00
Aner Zakobar 138d6d8a6b Current snapshot of state with unused garbage. 2025-03-26 12:27:47 +02:00
Aner Zakobar 9ac576c043 Unneeded values erased 2024-06-04 23:58:53 +03:00
Aner Zakobar 5264bdbf4f Temp and works? 2024-06-03 01:15:22 +03:00
Aner Zakobar 3655bbc489 Davical and trying sogo 2023-12-10 15:30:30 +02:00
49 changed files with 6496 additions and 955 deletions
+1
View File
@@ -0,0 +1 @@
use flake
+3 -1
View File
@@ -1,2 +1,4 @@
charts charts
*.lock .agent-shell
result
.direnv
+23
View File
@@ -0,0 +1,23 @@
# sops configuration — controls which keys can decrypt secrets.yaml.
#
# SETUP STEPS (do this once on the Pi):
#
# 1. Install age: nix-shell -p age
# 2. Generate a key: age-keygen -o /var/lib/sops-nix/key.txt
# 3. Print the pubkey: age-keygen -y /var/lib/sops-nix/key.txt
# 4. Replace AGE-PUBLIC-KEY-PI-MAIN below with the output of step 3.
# 5. (Optional) add your own age key or GPG key as a second recipient so
# you can edit secrets from your workstation without the Pi being on.
#
# To encrypt / edit secrets.yaml:
# sops secrets/secrets.yaml
#
# sops will re-encrypt the file for all keys listed here every time you save.
creation_rules:
- path_regex: secrets/secrets\.yaml$
key_groups:
- pgp:
- 076AA297579A0064
age:
- age120j8ty7nn04l3s3kgph5ty3v9g4e52fknn8xtnmzwakq9nv2la3skgte0p
+396
View File
@@ -0,0 +1,396 @@
# AGENTS.md
Self-hosted home server configuration for a Raspberry Pi 4 (8 GB), managed
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.
---
## Project Structure
```
flake.nix # Entry point — defines all hosts
modules/
common.nix # Shared system config (nix, podman, sops, SSH)
storage.nix # External HD mount + per-service directory layout
caddy.nix # Caddy reverse proxy (DNS-01 ACME, forward_auth)
cloudflared.nix # Cloudflare Tunnel for remote access
backup.nix # Restic daily backups (S3 primary + manual offload)
monitoring.nix # Prometheus + Grafana (native NixOS services)
services/
openldap.nix # OpenLDAP — central identity provider
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)
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
hardware.nix # Pi 4 boot, SD card labels, ARM platform
secrets/
.sops.yaml # Age key configuration
secrets.yaml # sops-encrypted secrets (commit only after encrypting)
PORTING.md # Step-by-step migration guide from the old Helm setup
```
## Services and URLs
All services live under `zakobar.com`.
| 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
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
All persistent data lives on the external HD at `/mnt/data/`:
```
/mnt/data/
openldap/
etc-ldap-slapd.d/ → /etc/ldap/slapd.d in container
var-lib-ldap/ → /var/lib/ldap in container
authelia/config/ → /config
gitea/data/ → /data
nextcloud/
html/ → /var/www/html
db/ → /var/lib/postgresql/data
db-dump/ → pg_dump output (pre-backup)
jellyfin/config/ → /config
media/movies|tvshows|... → shared media (read-only to jellyfin)
transmission/config/ → /config
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-label/` or `/dev/disk/by-id/`
path for stability.
## Build / Validate Commands
```bash
# Check flake structure and evaluate all hosts (no build)
nix flake check
# Dry-run: show what would change without applying
sudo nixos-rebuild dry-activate --flake .#pi-main
# Apply configuration
sudo nixos-rebuild switch --flake .#pi-main
# Build without switching (e.g. cross-compile on workstation)
nix build .#nixosConfigurations.pi-main.config.system.build.toplevel
# Show diff between running system and new config
nvd diff /run/current-system $(nix build --no-link --print-out-paths .#nixosConfigurations.pi-main.config.system.build.toplevel)
```
## Secret Management
Secrets are managed with [sops-nix](https://github.com/Mic92/sops-nix) and
age keys. The encrypted `secrets/secrets.yaml` is committed to the repo; the
age private key lives on the Pi at `/var/lib/sops-nix/key.txt`.
```bash
# Edit secrets (decrypts, opens $EDITOR, re-encrypts on save)
sops secrets/secrets.yaml
# Encrypt a plaintext secrets.yaml for the first time
sops --encrypt --in-place secrets/secrets.yaml
# Add a new host key (after generating it on the new machine)
# 1. Add the public key to secrets/.sops.yaml
# 2. Run:
sops updatekeys secrets/secrets.yaml
# Generate a new age key on a host
age-keygen -o /var/lib/sops-nix/key.txt
age-keygen -y /var/lib/sops-nix/key.txt # print public key
```
Secrets that must come from the old deployment (see `PORTING.md` for how to
extract them from the old k8s cluster):
- `openldap/admin_password`, `openldap/config_password`, `openldap/ro_password`
- `gitea/admin_password`
- `nextcloud/admin_password`, `nextcloud/postgres_password`
Everything else (authelia JWT/session/encryption keys, gitea JWT tokens,
restic password, Cloudflare tokens) can be generated fresh.
## Code Style Guidelines
### Nix
1. **Module pattern** — every service is an opt-in module with an `enable` option
(defaulting to `false` for optional services):
```nix
options.homey.myservice.enable = lib.mkEnableOption "My service";
config = lib.mkIf config.homey.myservice.enable { ... };
```
2. **`homeyConfig` specialArgs** — top-level site config (domain, org name,
timezone) is passed via `specialArgs` in `flake.nix` and accessed as
`homeyConfig` in every module. Do not 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
`config.sops.secrets."key".path` to get the runtime path of a secret file.
4. **Secret injection pattern** — because `oci-containers` `environmentFiles`
is limited, use a `systemd ExecStartPre` script to write an ephemeral env
file at `/run/<service>-secrets.env` and reference it via `environmentFiles`.
Clean it up in `postStop`.
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. 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
```bash
# Edit the encrypted file — sops opens $EDITOR
sops secrets/secrets.yaml
# Copy updated secrets to the Pi and rebuild
rsync secrets/secrets.yaml admin@pi-main:/path/to/homey/secrets/
ssh admin@pi-main 'sudo nixos-rebuild switch --flake /path/to/homey#pi-main'
```
### Debugging Containers
```bash
# List all running containers
podman ps
# Follow logs for a service
journalctl -fu podman-authelia.service
# Drop into a running container
podman exec -it authelia sh
# Restart a single service
sudo systemctl restart podman-gitea.service
# Check why a service failed to start
systemctl status podman-openldap.service
journalctl -u podman-openldap.service --since "5 min ago"
```
---
## Outstanding TODOs
These items are known gaps that need to be addressed before the setup is
production-ready:
- [ ] **`caddy.nix` — fix `vendorHash`**: The Caddy build with the Cloudflare
DNS plugin uses `lib.fakeHash` as a placeholder. After the first `nix build`,
replace it with the hash Nix reports in the error message.
- [ ] **`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, 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 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 exist but are disabled.
Enable in `hosts/pi-main/default.nix` when ready:
```nix
homey.jellyfin.enable = true;
homey.transmission.enable = true;
```
- [ ] **Backup — offload script**: Write `scripts/offload-backup.sh` for
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
These items require the Pi to be built, flashed, and booted at least once.
- [ ] **`secrets/.sops.yaml` — add Pi age key**: After generating the age key
on the Pi (`age-keygen -o /var/lib/sops-nix/key.txt`), add the public key
to `.sops.yaml` alongside the existing PGP key, then run
`sops updatekeys secrets/secrets.yaml`.
- [ ] **`hosts/pi-main/hardware.nix` — verify SD card labels**: The file
assumes partition labels `NIXOS_SD` (root) and `FIRMWARE` (boot). Relabel
after flashing if they differ, or update the `fileSystems` entries.
- [ ] **Gitea LDAP auth**: After first start, configure LDAP authentication
in Gitea's admin panel (Admin → Authentication Sources → Add LDAP source).
Relevant settings:
- Host: `127.0.0.1`, Port: `389`, Security: Unencrypted
- Bind DN: `cn=readonly,dc=zakobar,dc=com`
- User search base: `ou=users,dc=zakobar,dc=com`
- [ ] **Nextcloud LDAP app**: After restoring the Nextcloud volume, verify
the LDAP Users and Contacts app is still configured correctly
(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
-6
View File
@@ -1,6 +0,0 @@
apiVersion: v2
name: homey
description: Deploy a fancy home environment!
type: application
version: 0.1.0
appVersion: "1.16.0"
+400
View File
@@ -0,0 +1,400 @@
# Porting Guide — Helm/k3s → NixOS
This document walks through setting up the new NixOS-based home server from
scratch on a Raspberry Pi 4, restoring data from old Longhorn volumes, and
verifying each service.
---
## Prerequisites (on your workstation)
- `nix` with flakes enabled (`~/.config/nix/nix.conf`: `experimental-features = nix-command flakes`)
- `sops` + `age` CLI tools (`nix-shell -p sops age`)
- An SSH key pair
---
## Phase 0 — Secrets
### 0.1 Generate your age key (workstation)
```bash
age-keygen -o ~/.config/sops/age/keys.txt
age-keygen -y ~/.config/sops/age/keys.txt # print public key
```
You will add the Pi's public key in step 2.2; for now add your workstation
public key so you can edit the secrets file offline.
Edit `secrets/.sops.yaml`, replace the placeholder with your workstation pubkey:
```yaml
- age:
- AGE-PUBLIC-KEY-YOUR-WORKSTATION # ← paste here
```
### 0.2 Fill in secrets.yaml
`secrets/secrets.yaml` is a **plaintext template** — do not commit it until
encrypted. Fill in all values:
| Key | Source |
|-----|--------|
| `openldap/admin_password` | From old k8s secret `openldap-admin` |
| `openldap/config_password` | From old k8s secret `openldap-config` |
| `openldap/ro_password` | From old k8s secret `openldap-ro` |
| `gitea/admin_password` | From old k8s secret `gitea-admin-pass` |
| `nextcloud/admin_password` | From old k8s secret `nextcloud-admin-pass` |
| `nextcloud/postgres_password` | From old k8s secret `nextcloud-postgres-pass` |
| `authelia/jwt_secret` | Generate: `openssl rand -hex 64` |
| `authelia/session_secret` | Generate: `openssl rand -hex 64` |
| `authelia/storage_encryption_key` | Generate: `openssl rand -hex 64` |
| `gitea/lfs_jwt_secret` | Generate: `openssl rand -base64 32 \| tr -d '='` |
| `gitea/oauth2_jwt_secret` | Generate: `openssl rand -base64 32 \| tr -d '='` |
| `gitea/internal_token` | Generate: `openssl rand -base64 75 \| tr -d '\n='` |
| `cloudflare/api_token` | Cloudflare dashboard → API Tokens → DNS:Edit |
| `cloudflare/tunnel_token` | Created in Phase 3 (Cloudflare setup) |
| `restic/password` | Generate: `openssl rand -base64 32` |
To get old k8s secrets (if the cluster is still running):
```bash
kubectl get secret openldap-admin -n homey -o jsonpath='{.data.password}' | base64 -d
kubectl get secret openldap-config -n homey -o jsonpath='{.data.password}' | base64 -d
kubectl get secret openldap-ro -n homey -o jsonpath='{.data.password}' | base64 -d
kubectl get secret gitea-admin-pass -n homey -o jsonpath='{.data.password}' | base64 -d
kubectl get secret nextcloud-admin-pass -n homey -o jsonpath='{.data.password}' | base64 -d
kubectl get secret nextcloud-postgres-pass -n homey -o jsonpath='{.data.password}' | base64 -d
```
### 0.3 Encrypt secrets.yaml (workstation, before committing)
```bash
sops --encrypt --in-place secrets/secrets.yaml
git add secrets/secrets.yaml secrets/.sops.yaml
```
---
## Phase 1 — Install NixOS on the Raspberry Pi 4
### 1.1 Flash the SD card
Download the NixOS aarch64 SD card image:
```
https://nixos.org/download#nixos-iso
→ "Raspberry Pi (aarch64) SD card image"
```
Flash with:
```bash
# macOS
sudo dd if=nixos-*-aarch64-linux.img of=/dev/rdiskN bs=4m status=progress
# Linux
sudo dd if=nixos-*-aarch64-linux.img of=/dev/sdX bs=4M status=progress conv=fsync
```
Label the partitions to match `hardware.nix`:
```bash
# After flashing, mount the root partition and relabel if needed:
sudo e2label /dev/sdX2 NIXOS_SD
sudo fatlabel /dev/sdX1 FIRMWARE
```
Boot the Pi from the SD card. You should get a serial console or HDMI output.
### 1.2 Initial network setup
On the Pi (serial or HDMI):
```bash
# Find your IP
ip addr
# Set a temporary password for nixos user to SSH in
passwd nixos
```
From your workstation:
```bash
ssh nixos@<pi-ip>
```
### 1.3 Copy the flake to the Pi
```bash
# From your workstation (repo root)
rsync -avz --exclude='.git' . nixos@<pi-ip>:/tmp/homey/
```
### 1.4 Generate the Pi's age key
```bash
# On the Pi
nix-shell -p age --run 'age-keygen -o /tmp/pi-age-key.txt'
age-keygen -y /tmp/pi-age-key.txt # print public key
```
Copy the public key back to your workstation. Add it to `secrets/.sops.yaml`:
```yaml
- age:
- AGE-PUBLIC-KEY-YOUR-WORKSTATION
- AGE-PUBLIC-KEY-PI-MAIN-REPLACE-ME # ← paste Pi's public key here
```
Re-encrypt secrets so the Pi can decrypt them:
```bash
# On workstation
sops updatekeys secrets/secrets.yaml
git add secrets/.sops.yaml secrets/secrets.yaml
git commit -m "Add Pi age key"
# Copy updated files to Pi
rsync -avz secrets/ nixos@<pi-ip>:/tmp/homey/secrets/
```
Place the Pi's private key where sops-nix expects it:
```bash
# On the Pi
sudo mkdir -p /var/lib/sops-nix
sudo cp /tmp/pi-age-key.txt /var/lib/sops-nix/key.txt
sudo chmod 600 /var/lib/sops-nix/key.txt
```
### 1.5 Configure host-specific settings
Edit `hosts/pi-main/default.nix` on the Pi (or on workstation first):
1. Set your SSH public key in `users.users.admin.openssh.authorizedKeys.keys`
2. Set `homey.storage.device` to your USB drive:
```bash
ls -la /dev/disk/by-id/ | grep -v part
```
3. Set `homey.backup.repository` to your backup destination
Edit `hosts/pi-main/hardware.nix` if the disk labels differ from defaults.
### 1.6 Install NixOS
```bash
# On the Pi
sudo nixos-install --flake /tmp/homey#pi-main --no-root-passwd
# Reboot
sudo reboot
```
After reboot, SSH in with your admin key:
```bash
ssh admin@<pi-ip>
```
---
## Phase 2 — Restore Data from Old Volumes
Mount the external HD (if not auto-mounted):
```bash
sudo mount /dev/disk/by-id/<your-drive-id> /mnt/data
```
Copy data from the old Longhorn volume backups into the new layout:
```bash
# Adjust source paths to wherever your Longhorn volume dumps are
BACKUP_SRC=/path/to/longhorn/backups
# OpenLDAP
sudo rsync -av $BACKUP_SRC/openldap/etc-ldap-slapd.d/ /mnt/data/openldap/etc-ldap-slapd.d/
sudo rsync -av $BACKUP_SRC/openldap/var-lib-ldap/ /mnt/data/openldap/var-lib-ldap/
# Gitea
sudo rsync -av $BACKUP_SRC/gitea/data/ /mnt/data/gitea/data/
# Nextcloud
sudo rsync -av $BACKUP_SRC/nextcloud/html/ /mnt/data/nextcloud/html/
# Restore postgres from pg_dump if available, otherwise restore the data dir:
sudo rsync -av $BACKUP_SRC/nextcloud/db/ /mnt/data/nextcloud/db/
```
Fix ownership (containers run as UID 1000 or root depending on image):
```bash
# openldap runs as root inside the container
sudo chown -R root:root /mnt/data/openldap/
# gitea runs as git (UID 1000)
sudo chown -R 1000:1000 /mnt/data/gitea/
# nextcloud runs as www-data (UID 33)
sudo chown -R 33:33 /mnt/data/nextcloud/html/
# postgres data owned by postgres (UID 999 in the postgres image)
sudo chown -R 999:999 /mnt/data/nextcloud/db/
```
---
## Phase 3 — Cloudflare Tunnel Setup
### 3.1 Create the tunnel in Cloudflare Zero Trust
1. Go to [https://one.dash.cloudflare.com](https://one.dash.cloudflare.com) → Networks → Tunnels
2. Click "Create a tunnel" → Cloudflared → Name it `pi-main`
3. Copy the tunnel token (long string starting with `eyJ...`)
4. Add it to `secrets/secrets.yaml` under `cloudflare/tunnel_token`
5. Re-encrypt: `sops secrets/secrets.yaml` (the file opens in `$EDITOR`)
### 3.2 Configure public hostnames in the Cloudflare dashboard
In the tunnel's "Public Hostnames" tab, add:
| Subdomain | Domain | Service |
|-----------|--------|---------|
| `auth` | `zakobar.com` | `https://localhost:443` |
| `git` | `zakobar.com` | `https://localhost:443` |
| `nextcloud` | `zakobar.com` | `https://localhost:443` |
| `ldapadmin` | `zakobar.com` | `https://localhost:443` |
| `jellyfin` | `zakobar.com` | `https://localhost:443` |
| `torrent` | `zakobar.com` | `https://localhost:443` |
For each entry, under "Additional settings" → TLS → **No TLS Verify: ON**
(because cloudflared connects to `localhost` but the cert is for the real hostname).
### 3.3 Update DNS in Cloudflare
Add a CNAME for `zakobar.com` pointing to your tunnel's UUID (Cloudflare
creates this automatically when you add hostnames). You do not need to add
`zakobar.com` to your domain's A records — Cloudflare handles it.
---
## Phase 4 — Rebuild and Verify
After restoring data and completing Cloudflare setup, apply the final config:
```bash
# On the Pi
sudo nixos-rebuild switch --flake /path/to/homey#pi-main
```
### Verification checklist
```bash
# All container services running?
systemctl list-units 'podman-*' --state=active
# OpenLDAP responding?
ldapsearch -x -H ldap://127.0.0.1:389 -b dc=zakobar,dc=com -D "cn=admin,dc=zakobar,dc=com" -W
# Authelia health?
curl -s http://localhost:9091/api/health | python3 -m json.tool
# Caddy serving TLS?
curl -I https://auth.zakobar.com
# Gitea login?
# Visit https://git.zakobar.com — should redirect to authelia if not logged in
# Nextcloud?
# Visit https://nextcloud.zakobar.com
# Cloudflare tunnel connected?
systemctl status cloudflared-tunnel-pi-main
```
---
## Phase 5 — Local DNS (optional but recommended)
To access services without going through Cloudflare on the LAN, add these
records to your router's DNS or Pi-hole:
```
192.168.1.100 zakobar.com
192.168.1.100 auth.zakobar.com
192.168.1.100 git.zakobar.com
192.168.1.100 nextcloud.zakobar.com
192.168.1.100 ldapadmin.zakobar.com
192.168.1.100 jellyfin.zakobar.com
192.168.1.100 torrent.zakobar.com
```
Replace `192.168.1.100` with your Pi's actual LAN IP.
---
## Day-to-day Operations
### Apply config changes
```bash
sudo nixos-rebuild switch --flake /path/to/homey#pi-main
```
### Edit secrets
```bash
sops secrets/secrets.yaml
# Save and exit — sops re-encrypts automatically
# Then copy to Pi and rebuild
```
### Browse service data on disk
```bash
ls /mnt/data/
ls /mnt/data/gitea/data/
# No special tools needed — plain filesystem
```
### Trigger a manual backup
```bash
sudo systemctl start restic-backups-homey.service
```
### List backup snapshots
```bash
sudo restic -r <your-repo-url> \
--password-file /run/secrets/restic_password \
snapshots
```
### Restore a single service from backup
```bash
sudo systemctl stop podman-gitea.service
sudo restic -r <repo> restore latest \
--target / \
--include /mnt/data/gitea
sudo systemctl start podman-gitea.service
```
---
## Adding a Second Machine (future)
1. Create `hosts/pi-secondary/default.nix` and `hardware.nix`
2. Enable the services you want on that machine
3. Services communicating cross-machine: reference the primary Pi's LAN IP or
hostname directly in environment variables (e.g. point gitea's LDAP config
at `192.168.1.100:389` rather than `127.0.0.1:389`).
4. Add the new host to `flake.nix`:
```nix
pi-secondary = mkHost {
system = "x86_64-linux";
hostPath = ./hosts/pi-secondary/default.nix;
};
```
5. Generate an age key on the new machine and add it to `.sops.yaml`.
+400 -54
View File
@@ -2,91 +2,437 @@
A home environment for everyone! A home environment for everyone!
* Installation * NixOS Deployment (active branch: nixos-port)
Install using ** Prerequisites
Before building, make sure the following are set in the repo:
- =hosts/pi-main/default.nix= — SSH public key, static IP, WiFi SSID
- =secrets/secrets.yaml= — all secrets populated and sops-encrypted
- WiFi password secret formatted as =wifi_psk=YourPassword= (see below)
** Adding / updating secrets
#+begin_src bash #+begin_src bash
helm upgrade --install homey . -n homey sops secrets/secrets.yaml
#+end_src #+end_src
Opens your editor with the decrypted file. Save and quit to re-encrypt.
The WiFi password entry must use the =wifi_psk== prefix so wpa_supplicant
can look up the value by name:
#+begin_src yaml
wifi/psk: "wifi_psk=YourActualWifiPassword"
#+end_src
** Phase 1 — Bootstrap image (flash this first)
The full =pi-main= config requires sops secrets, which require an age key
on the Pi — but the age key doesn't exist until after first boot. To
break the chicken-and-egg problem, flash a minimal bootstrap image first.
Before building, fill in the WiFi password in =flake.nix= in the
=pi-main-bootstrap= config (search for =WIFI_PASSWORD_HERE=):
#+begin_src nix
networks."Zakobar".psk = "your-actual-wifi-password";
#+end_src
Build the bootstrap SD image (requires =aarch64-linux= build capability —
either =boot.binfmt.emulatedSystems = ["aarch64-linux"]= on your
workstation, or an aarch64 remote builder):
#+begin_src bash
nix build .#nixosConfigurations.pi-main-bootstrap.config.system.build.sdImage \
--system aarch64-linux
#+end_src
Find your SD card device, then flash (double-check =/dev/sdX=!):
#+begin_src bash
lsblk
zstdcat result/sd-image/nixos-sd-image-*.img.zst | \
sudo dd of=/dev/sdX bs=4M status=progress conv=fsync
#+end_src
The Pi will boot at =192.168.1.100=, connect to =Zakobar= WiFi, and accept
SSH connections with your key. No services run yet.
** Phase 2 — Generate age key and re-encrypt secrets
#+begin_src bash
# SSH into the Pi
ssh admin@192.168.1.100
# Generate the age key
sudo age-keygen -o /var/lib/sops-nix/key.txt
# Print the public key — copy it
sudo age-keygen -y /var/lib/sops-nix/key.txt
#+end_src
Back on your workstation, add the public key to =secrets/.sops.yaml=
alongside the existing PGP key:
#+begin_src yaml
keys:
- &pi_main age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
creation_rules:
- path_regex: secrets/secrets.yaml$
key_groups:
- pgp:
- 076AA297579A0064
age:
- *pi_main
#+end_src
Then re-encrypt so the Pi can decrypt its own secrets:
#+begin_src bash
sops updatekeys secrets/secrets.yaml
#+end_src
** Phase 3 — Deploy the full config
#+begin_src bash
nixos-rebuild switch \
--flake .#pi-main \
--target-host admin@192.168.1.100 \
--build-host admin@192.168.1.100 \
--use-remote-sudo
#+end_src
The Pi builds its own config natively (no cross-compilation). sops-nix
will now decrypt all secrets and start all services.
You can also use the command:
#+begin_src bash
homey-deploy-rpi-main
#+end_src
** Ongoing deploys from workstation
All future config changes follow the same pattern:
1. Edit files on workstation
2. Run:
#+begin_src bash
homey-deploy-rpi-main
#+end_src
NixOS activates the new config on the Pi immediately, with an automatic
rollback if activation fails.
* Post-deploy setup
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
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 * Backing up
We must find a better solution Backups use [[https://restic.net/][restic]] and run automatically via systemd on a daily schedule.
https://perfectmediaserver.com/day-two/top10apps.html ** Strategy — two tiers
Nefarious 1. *Primary (automatic)*: Daily backup to an S3-compatible bucket (Backblaze B2,
Wasabi, AWS S3, etc.). Restic deduplicates and encrypts before upload.
Retention: 7 daily, 4 weekly, 6 monthly snapshots.
* LDAP Configuration 2. *Offload (manual)*: Run =scripts/offload-backup.sh --target /path/to/disk=
to clone snapshots from the S3 repo onto a local disk (USB plugged into the
Pi, or a disk on your workstation). Uses =restic copy= so deduplication is
preserved on the target.
Logins are done to PHPLDAPADMIN ** What is backed up
DN is like: All service data under =/mnt/data/=:
cn=admin,dc=home,dc=,dc=io - =openldap/= — LDAP database and config
get-secret-val.sh homey openldap-admin password - =authelia/= — Authelia config and state
- =gitea/= — Gitea repositories and data
- =nextcloud/= — Nextcloud files + a =pg_dump= of the database
- =jellyfin/= — Jellyfin metadata (media files are excluded — re-downloadable)
- =transmission/= — Torrent client config
First thing we do is create an organization unit called users Nextcloud is placed into maintenance mode and postgres is =pg_dump='d before
each backup to ensure a consistent snapshot.
To add a new user, we create a child entry to ou=users ** First-time setup — initialize the repository
It has to be of type inetOrgPerson 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.
cn = Common Name, sn = Sur Name. Run on the Pi after the first deploy:
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! #+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
* GITEA 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:
Site Title: whatever #+begin_src bash
ssh admin@192.168.1.100 "sudo systemctl start restic-backups-homey.service"
#+end_src
SSH Server Domain: git.<YOUR URL> ** Configuration
SSH Server Port: 2222
Gitea Base URL: http://git.<YOUR URL>
Then add Administrator Account Settings: Repository URL and credentials are set per-host:
Administrator Username: gitea-admin #+begin_src nix
Password: from gitea-admin-pass # hosts/pi-main/default.nix
Email address must be populated homey.backup.repository = "s3:https://s3.us-west-002.backblazeb2.com/your-bucket";
#+end_src
That will work after a few minutes. S3 credentials live in =secrets/secrets.yaml= as =restic/s3_access_key_id= and
=restic/s3_secret_access_key=.
Now we go into Authentication Sources ** Restore
Add a new LDAP Authentication source #+begin_src bash
# List snapshots
restic -r s3:https://... snapshots
Authentication name: Home LDAP # Restore latest snapshot to /mnt/data
Host: openldap restic -r s3:https://... restore latest --target /mnt/data
Port: 389
Bind DN = cn=readonly,dc=home,dc=,dc=io
Bind Password: openldap-ro password
User Search Base: ou=users,dc=home,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 # Restore a single service
restic -r s3:https://... restore latest --target /mnt/data --include /mnt/data/gitea
#+end_src
https://github.com/authelia/authelia/blob/57d5fbd3f5c82e83296023dc1de6e4f5ff063c00/examples/compose/lite/authelia/configuration.yml * Disaster Recovery
This fucking sucks
https://gist.github.com/james-d-elliott/5152d27c0781aee856a3383f1284998e
* EVERYTHING Full recovery from total host failure (dead Pi, dead SD card), assuming this
https://www.talkingquickly.co.uk/gitea-sso-with-keycloak-openldap-openid-connect git repo and your workstation PGP key (=076AA297579A0064=) survive.
* DRONE AND GITEA ** Step 1 — Flash and boot a new Pi
?
https://dev.to/ruanbekker/self-hosted-cicd-with-gitea-and-drone-ci-200l
* DAV Follow Phase 1 above to build and flash a fresh bootstrap image, then SSH in.
https://gitlab.com/davical-project/davical/-/blob/master/config/example-config.php ** Step 2 — Regenerate the age key and re-encrypt secrets
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=.
On the Pi:
#+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
On the workstation — replace the old age key in =secrets/.sops.yaml= with the
new public key, then re-encrypt:
#+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
** Step 3 — Deploy the full NixOS config
#+begin_src bash
nixos-rebuild switch \
--flake .#pi-main \
--target-host admin@192.168.1.100 \
--build-host admin@192.168.1.100 \
--use-remote-sudo
#+end_src
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.
Line 800 ish for auth from reverse proxy
+288
View File
@@ -0,0 +1,288 @@
#+TITLE: Homey NixOS Port — Outstanding Tasks
#+DATE: 2026-04-15
#+OPTIONS: toc:nil
* Secrets Setup
** DONE Configure sops recipients in =secrets/.sops.yaml=
GPG encryption subkey =076AA297579A0064= is already configured in
=.sops.yaml=. The Pi's age key will be added post-boot (see Deployment section).
** DONE Populate =secrets/secrets.yaml= with real values
Every key in =secrets/secrets.yaml= needs a real value. Fill in each row below.
*** Recovered from k8s backup
| Key | Source k8s secret | Value |
|----------------------------------+-------------------------+----------------------------------|
| openldap/admin_password | openldap-admin | lfQWQgBZporyJT4xFSJTnu4vQMC7UevW |
| openldap/config_password | openldap-config | ZxlWbDAeHLdHi5lgdxmyZOWzsG3qDgrT |
| openldap/ro_password | openldap-ro | CZ7JLn23vSzhVjNW7UHGZ2YLFJPDLGsF |
| gitea/admin_password | gitea-admin-pass | y5kCPeCP1e1sCzahd7QmLyJqdQvd37ek |
| nextcloud/postgres_password | nextcloud-postgres-pass | hEq4zt1B1VKYtVAoiKYDmswcUmTbknSP |
| authelia/jwt_secret | jwt-secret | YJZBnQCD4OmhJkgdr6kksmMCatrKLCl3 |
| authelia/storage_encryption_key | fek-secret | KYWRYApCWWIN60gpSi7jhLuj1Wcm5z9Q |
*** Needs generation or manual creation
| Key | Action |
|----------------------------------+---------------------------------------------------|
| authelia/session_secret | Generate fresh (64 random chars) |
| gitea/lfs_jwt_secret | Generate fresh (43-char base64url) |
| gitea/oauth2_jwt_secret | Generate fresh (43-char base64url) |
| gitea/internal_token | Generate fresh (100-char alphanumeric) |
| restic/password | Generate fresh (passphrase) |
| nextcloud/admin_password | NOT in k8s backup; try old value or reset later |
| cloudflare/api_token | Create DNS Edit token in Cloudflare dashboard |
| cloudflare/tunnel_token | Create tunnel in Cloudflare Zero Trust dashboard |
| restic/s3_access_key_id | Needs S3 provider credentials |
| restic/s3_secret_access_key | Needs S3 provider credentials |
Generate random secrets with:
#+begin_src bash
# 64-char hex string
openssl rand -hex 32
# base64url (for gitea tokens)
openssl rand -base64 32 | tr '+/' '-_' | tr -d '='
# 100-char alphanumeric (gitea internal token)
openssl rand -base64 75 | tr -dc 'A-Za-z0-9' | head -c 100
#+end_src
** DONE Encrypt =secrets/secrets.yaml= with sops
Encrypted with PGP key =076AA297579A0064=. Safe to commit.
* Pi Hardware Setup
** DONE Fill in real values in =hosts/pi-main/default.nix=
- [X] =users.users.admin.openssh.authorizedKeys.keys= — SSH public key added
- [X] =homey.storage.device==/dev/disk/by-id/usb-WD_Ext_HDD_1021_5743415A4146313531393031-0:0-part1=
- [X] =homey.backup.repository==s3:https://s3.us-east-005.backblazeb2.com/zakobar-home-backup=
** DONE Integrate nixos-raspberrypi flake
Replaced =nixos-hardware= with =nixos-raspberrypi= for vendor kernel, firmware,
u-boot bootloader, and binary cache. Both =pi-main= and =pi-main-bootstrap=
now use =nixos-raspberrypi.lib.nixosSystem= and =raspberry-pi-4.base=.
=nix flake check= passes.
** 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
lsblk -o NAME,LABEL
#+end_src
Update =fileSystems= entries in =hosts/pi-main/hardware.nix= if they differ.
* Caddy Build
** 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.
* Cloudflare Setup
** DONE Create Cloudflare Tunnel
1. Go to Cloudflare Zero Trust dashboard → Networks → Tunnels → Create tunnel
2. Name it (e.g. =homey=)
3. Copy the tunnel token into =secrets/secrets.yaml= under =cloudflare/tunnel_token=
4. Configure public hostnames for each service (see service/URL table in AGENTS.md)
** DONE Create Cloudflare DNS API token
1. Cloudflare dashboard → My Profile → API Tokens → Create Token
2. Use the "Edit zone DNS" template, scope to =zakobar.com=
3. Copy into =secrets/secrets.yaml= under =cloudflare/api_token=
* Deployment
** 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
and then deploy the full config remotely.
Build on workstation (cross-compiles for aarch64):
#+begin_src bash
# Accept the nixos-raspberrypi cache config so pre-built kernel/firmware
# are fetched instead of compiled. First build still takes ~10-20 min.
nix build .#nixosConfigurations.pi-main-bootstrap.config.system.build.sdImage \
--accept-flake-config
#+end_src
Flash to SD card (replace =/dev/sdX= with your card's device):
#+begin_src bash
# Decompress and write in one step — avoids storing the raw image on disk
zstdcat result/sd-image/*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress conv=fsync
sudo sync
#+end_src
Insert the SD card into the Pi and power it on.
It will connect to WiFi (=Zakobar=) with static IP =192.168.1.100=.
Verify SSH access (wait ~60 s for first boot):
#+begin_src bash
ssh admin@192.168.1.100
#+end_src
** DONE Phase 2 — Generate age key and add it to sops
On the Pi (over SSH):
#+begin_src bash
sudo mkdir -p /var/lib/sops-nix
sudo age-keygen -o /var/lib/sops-nix/key.txt
# Print the public key — copy this output to your workstation clipboard
sudo age-keygen -y /var/lib/sops-nix/key.txt
#+end_src
On the workstation — edit =secrets/.sops.yaml=, uncomment the age section
and replace the placeholder with the public key you just copied:
#+begin_src yaml
key_groups:
- pgp:
- 076AA297579A0064
age:
- age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # paste here
#+end_src
Re-encrypt =secrets/secrets.yaml= so the Pi's age key can decrypt it:
#+begin_src bash
sops updatekeys secrets/secrets.yaml
#+end_src
Commit and push:
#+begin_src bash
git add secrets/.sops.yaml secrets/secrets.yaml
git commit -m "add Pi age key to sops recipients"
#+end_src
** 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.
Attempt the build to get the hash:
#+begin_src bash
nix build .#nixosConfigurations.pi-main.config.system.build.toplevel \
--accept-flake-config 2>&1 | grep 'got:'
#+end_src
Copy the hash from the error message and replace =lib.fakeHash= in
=modules/caddy.nix=, then commit:
#+begin_src bash
git add modules/caddy.nix
git commit -m "fix caddy vendorHash"
#+end_src
Deploy to the Pi:
#+begin_src bash
# Dry-run first — shows what will change without applying
nixos-rebuild dry-activate \
--flake .#pi-main \
--target-host admin@192.168.1.100 \
--use-remote-sudo \
--accept-flake-config
# Apply when happy with the diff
nixos-rebuild switch \
--flake .#pi-main \
--target-host admin@192.168.1.100 \
--use-remote-sudo \
--accept-flake-config
#+end_src
After a successful switch, subsequent deploys can use the hostname:
#+begin_src bash
nixos-rebuild switch --flake .#pi-main --target-host admin@pi-main --use-remote-sudo
#+end_src
* Post-Deployment Manual Steps
** DONE Configure Gitea LDAP authentication
Admin → Site Administration → Authentication Sources → Add LDAP (via BindDN):
- Host: =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=
- User Filter: =(&(objectClass=inetOrgPerson)(uid=%s))=
- Username attribute: =uid=
- First name attribute: =cn=
- Surname attribute: =sn=
- Email attribute: =mail=
** 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: =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=
- Users: filter =objectClass=inetOrgPerson=, search base =ou=users=
- Login attribute: =uid=
- Email attribute: =mail=
** TODO (Optional) Enable Jellyfin and Transmission
When ready, in =hosts/pi-main/default.nix=:
#+begin_src nix
homey.jellyfin.enable = true;
homey.transmission.enable = true;
#+end_src
* Backup Strategy
** 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
homey.backup.repository = "s3:https://s3.us-west-002.backblazeb2.com/your-bucket-name";
# or for AWS:
# homey.backup.repository = "s3:s3.amazonaws.com/your-bucket-name";
#+end_src
Add the S3 credentials to =secrets/secrets.yaml=:
#+begin_src yaml
restic/s3_access_key_id: "YOUR_KEY_ID"
restic/s3_secret_access_key: "YOUR_SECRET_KEY"
#+end_src
Then wire them into =modules/backup.nix= via environment variables:
=AWS_ACCESS_KEY_ID= and =AWS_SECRET_ACCESS_KEY= (restic reads these natively).
The existing daily schedule + prune retention in =modules/backup.nix= will
handle the rest automatically.
** TODO Write manual offload script (=scripts/offload-backup.sh=)
A standalone script for copying backup data to an external disk — either
plugged directly into the Pi or mounted on your workstation.
Design:
- Accepts a =--target= argument: a local path to the mounted disk
(e.g. =/media/aner/backup-disk= or =/mnt/usb=)
- Uses =restic copy= to clone snapshots from the S3 repo into a local restic
repo on the target disk (deduplication is preserved, no double storage)
- Alternatively can use =rsync= for a plain directory copy if restic is not
available on the target machine
- Should be runnable from either the Pi or a workstation (with the Pi's data
disk mounted or accessible over SSH)
Example invocation:
#+begin_src bash
# On the Pi, with USB disk mounted at /mnt/usb:
./scripts/offload-backup.sh --target /mnt/usb/homey-backup
# On workstation, with Pi data disk mounted locally:
./scripts/offload-backup.sh --target /media/aner/backup-disk/homey-backup
#+end_src
This script does not exist yet — needs to be written.
* Future
** TODO Add second machine (=pi-secondary=)
When ready:
1. Create =hosts/pi-secondary/= directory with =default.nix= and =hardware.nix=
2. Uncomment the =pi-secondary= entry in =flake.nix=
3. Services communicating cross-machine should reference the primary Pi's LAN IP
instead of =127.0.0.1=
+321
View File
@@ -0,0 +1,321 @@
#+TITLE: Caddy, Cloudflare Tunnel & TLS Setup
#+DATE: 2026-04-23
#+AUTHOR: homey project
#+OPTIONS: toc:2 num:t
* Overview
This document describes the TLS and reverse-proxy architecture for the homey
self-hosted stack, the problems encountered while getting it working, and the
final configuration that resolved them. It is intended as a reference for
future debugging and for adding new services.
** Traffic flow
#+BEGIN_EXAMPLE
Browser
│ HTTPS (TLS terminated by Cloudflare edge, *.zakobar.com cert)
Cloudflare edge (anycast IP)
│ QUIC/HTTP2 tunnel (outbound from Pi, no open inbound ports)
cloudflared daemon on Pi (systemd: cloudflared-tunnel.service)
│ plain HTTP on loopback http://localhost:80
Caddy reverse proxy (systemd: caddy.service, port 80 + 443)
│ proxies to backend by Host header
Service container (podman, port on 127.0.0.1)
#+END_EXAMPLE
Key points:
- TLS to the browser is provided entirely by Cloudflare's Universal SSL cert
(~*.zakobar.com~), not by the Pi's Let's Encrypt cert.
- The Pi's Let's Encrypt cert (~*.zakobar.com~ via DNS-01) is used only for
direct LAN access (bypassing the tunnel).
- The tunnel leg (cloudflared → Caddy) is plain HTTP on loopback — this is
safe because both endpoints are the same machine.
* Components
** Caddy (~modules/caddy.nix~)
Caddy runs as a NixOS service (~services.caddy~) using a custom build that
includes the ~caddy-dns/cloudflare~ plugin for DNS-01 ACME challenges.
*** Custom build
The nixpkgs ~caddy~ package does not include the Cloudflare DNS plugin by
default. It is built using the ~withPlugins~ passthru function (backed by
xcaddy):
#+BEGIN_SRC nix
caddyWithCloudflare = pkgs.caddy.withPlugins {
plugins = [
"github.com/caddy-dns/cloudflare@v0.2.4"
];
hash = "sha256-...";
};
#+END_SRC
The ~hash~ is a fixed-output derivation hash that must be updated whenever
the plugin version changes. Use ~lib.fakeHash~ to trigger a build failure
that prints the correct hash, then substitute it.
*** API token injection
The Cloudflare API token is stored in sops (~cloudflare/api_token~) and
injected into the Caddy process via ~systemd LoadCredential~:
#+BEGIN_SRC nix
serviceConfig.LoadCredential =
"cloudflare_api_token:${config.sops.secrets."cloudflare/api_token".path}";
ExecStart = lib.mkForce [
""
(pkgs.writeShellScript "caddy-start" ''
export CLOUDFLARE_API_TOKEN=$(cat "$CREDENTIALS_DIRECTORY/cloudflare_api_token")
exec caddy run --environ --config /etc/caddy/caddy_config --adapter caddyfile
'')
];
#+END_SRC
*** Virtual hosts — dual HTTP/HTTPS entries
Each service has *two* Caddyfile vhost entries:
| Entry | Purpose |
|---|---|
| ~git.zakobar.com~ | HTTPS — for direct LAN access; Caddy handles TLS |
| ~http://git.zakobar.com~ | HTTP — for cloudflared on loopback; no redirect |
Caddy's default behaviour is to automatically redirect HTTP → HTTPS for any
hostname that has a matching HTTPS vhost. By explicitly defining an
~http://~ vhost, that redirect is suppressed and cloudflared gets a direct
200 response instead of a redirect loop.
Without the ~http://~ vhost, accessing via the tunnel produces:
~ERR_TOO_MANY_REDIRECTS~ in the browser because cloudflared follows the 308
back to HTTP indefinitely.
*** Global config
#+BEGIN_SRC caddyfile
{
email admin@zakobar.com
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
#+END_SRC
The ~acme_dns~ directive in the global block tells Caddy to use DNS-01
challenges for *all* HTTPS vhosts. This allows wildcard and multi-level
subdomain certs to be issued without any inbound port 80 requirement.
** Cloudflare Tunnel (~modules/cloudflared.nix~)
cloudflared runs as a plain systemd service using the token-based tunnel
approach (~cloudflared tunnel run --token~). No local credentials file or
config file is needed — just the tunnel token from the Zero Trust dashboard.
*** Tunnel configuration (Zero Trust dashboard)
One wildcard public hostname entry covers all services:
| Field | Value |
|---|---|
| Hostname | ~*.zakobar.com~ |
| Service | ~http://localhost:80~ |
| No TLS Verify | off (not needed for HTTP) |
| HTTP Host Header | (empty — cloudflared forwards the real Host header) |
| Origin Server Name | (empty — not needed for HTTP) |
cloudflared automatically forwards the incoming ~Host~ header (e.g.
~git.zakobar.com~) to Caddy, which uses it to select the correct vhost and
backend.
*** DNS records
A single wildcard CNAME record in Cloudflare DNS covers all subdomains:
#+BEGIN_EXAMPLE
*.zakobar.com CNAME <tunnel-id>.cfargotunnel.com (proxied, orange cloud)
#+END_EXAMPLE
This means new services require no DNS changes — only a new Caddy vhost.
*** Cloudflare SSL/TLS mode
Set to *Full (strict)* in the Cloudflare dashboard (SSL/TLS → Overview).
| Mode | Meaning |
|---|---|
| Off | No HTTPS to browser |
| Flexible | HTTPS to browser, HTTP to origin |
| Full | HTTPS to browser, HTTPS to origin (cert not validated) |
| Full (strict) | HTTPS to browser, HTTPS to origin (cert must be valid) |
Full (strict) works here because Cloudflare terminates TLS at its own edge
using its Universal cert, and the origin (cloudflared → Caddy) uses plain
HTTP which Cloudflare does not validate in this tunnel architecture.
* Problems Encountered & How They Were Resolved
** 1. ~caddy-dns/cloudflare~ rejected ~cfut_~ token format
*Symptom:*
#+BEGIN_EXAMPLE
provision dns.providers.cloudflare: API token 'cfut_...' appears invalid;
ensure it's correctly entered and not wrapped in braces nor quotes
#+END_EXAMPLE
*Cause:*
Cloudflare introduced new token formats with a ~cfut_~ (user token) or
~cfat_~ (account token) prefix. These tokens are 54 characters long. The
~caddy-dns/cloudflare~ plugin had a validation regex ~{35,50}~ that rejected
tokens longer than 50 characters, failing before even making an API call.
*Fix:*
The fix was merged into the plugin's master branch as commit ~a8737d0~ and
included in the ~v0.2.4~ tag (despite the tag previously being associated
with an older tree — the proxy confirmed ~v0.2.4~ resolves to ~a8737d0~).
Updating the ~hash~ in ~caddy.nix~ to the value produced by ~lib.fakeHash~
forced a fresh fetch of the corrected ~v0.2.4~ tree:
#+BEGIN_SRC nix
plugins = [ "github.com/caddy-dns/cloudflare@v0.2.4" ];
hash = lib.fakeHash; # replace with hash from build error output
#+END_SRC
Run ~nix build .#nixosConfigurations.pi-main.config.system.build.toplevel~,
copy the ~got:~ hash from the error, substitute it, and rebuild.
** 2. cloudflared ~tls: internal error~ (SNI mismatch)
*Symptom:*
#+BEGIN_EXAMPLE
Unable to reach the origin service: remote error: tls: internal error
originService=https://localhost:443
#+END_EXAMPLE
*Cause:*
cloudflared connected to ~https://localhost:443~ without sending an SNI
(Server Name Indication) hostname in the TLS ClientHello. Caddy could not
match any vhost, had no certificate for ~localhost~, and aborted the
handshake with a TLS internal error.
Setting the ~HTTP Host Header~ override in the dashboard fixes the HTTP
layer but does *not* affect the TLS SNI, which is negotiated before HTTP
headers are exchanged.
Setting the ~Origin Server Name~ field does set the SNI, but for a wildcard
rule (~*.zakobar.com~) the dashboard only accepts a static value, not a
dynamic placeholder — so it cannot be used for a catch-all.
*Fix:*
Switch the tunnel service from ~https://localhost:443~ to
~http://localhost:80~. The internal leg does not need TLS (loopback
interface, same machine). Caddy's HTTP vhosts handle the requests directly.
** 3. Cloudflare edge TLS handshake failure (~*.home.zakobar.com~)
*Symptom:*
#+BEGIN_EXAMPLE
TLS connect error: error:0A000410:SSL routines::ssl/tls alert handshake failure
#+END_EXAMPLE
*Cause:*
The domain was originally configured as ~home.zakobar.com~ (base domain),
making all services two levels deep: ~git.home.zakobar.com~,
~auth.home.zakobar.com~, etc. Cloudflare's free Universal SSL certificate
covers only one level of wildcard: ~*.zakobar.com~. It does *not* cover
~*.home.zakobar.com~ (two levels). The Cloudflare edge had no certificate to
present to browsers for these hostnames, causing a TLS handshake failure
before the request ever reached the tunnel.
*Fix:*
Move all services to single-level subdomains under ~zakobar.com~
(~git.zakobar.com~, ~auth.zakobar.com~, etc.). In the NixOS config this
required only one line change — the ~domain~ field in ~flake.nix~:
#+BEGIN_SRC nix
domain = "zakobar.com"; # was "home.zakobar.com"
#+END_SRC
All modules reference ~homeyConfig.domain~ and updated automatically on
rebuild. Tunnel hostnames and DNS records in the Cloudflare dashboard were
updated to match.
** 4. ~ERR_TOO_MANY_REDIRECTS~ via tunnel
*Symptom:*
Browser shows ~ERR_TOO_MANY_REDIRECTS~ when accessing any service through
the Cloudflare tunnel.
*Cause:*
cloudflared was talking to Caddy over plain HTTP (~http://localhost:80~).
Caddy's default behaviour is to issue a 308 permanent redirect from HTTP to
HTTPS for any hostname that has a matching HTTPS vhost. cloudflared followed
the redirect back to ~http://localhost:80~, which redirected again,
indefinitely.
*Fix:*
Add explicit ~http://~ vhost entries in ~caddy.nix~ for every service. When
Caddy has an explicit HTTP vhost for a hostname, it serves it directly
without redirecting:
#+BEGIN_SRC nix
"git.${domain}" = {
extraConfig = "reverse_proxy localhost:3000";
};
"http://git.${domain}" = { # ← suppresses HTTP→HTTPS redirect
extraConfig = "reverse_proxy localhost:3000";
};
#+END_SRC
* Adding a New Service
To expose a new service through the tunnel:
1. Create ~modules/services/<name>.nix~ following the module pattern.
2. Add both a plain and ~http://~ vhost in ~modules/caddy.nix~:
#+BEGIN_SRC nix
"<name>.${domain}" = {
extraConfig = "reverse_proxy localhost:<port>";
};
"http://<name>.${domain}" = {
extraConfig = "reverse_proxy localhost:<port>";
};
#+END_SRC
3. No DNS or tunnel changes needed — the wildcard CNAME and wildcard tunnel
rule (~*.zakobar.com~) cover new subdomains automatically.
4. Rebuild and switch: ~sudo nixos-rebuild switch --flake .#pi-main~
* Certificate Details
** Let's Encrypt cert (LAN access)
- Issued per-hostname by Caddy via DNS-01 ACME using the Cloudflare API.
- Covers each hostname individually (e.g. ~git.zakobar.com~).
- Stored in ~/var/lib/caddy/.local/share/caddy/certificates/~.
- Used only when accessing services directly on the LAN (bypassing tunnel).
- Auto-renewed by Caddy.
** Cloudflare Universal SSL cert (tunnel / remote access)
- Issued by Google Trust Services for ~*.zakobar.com~.
- Managed entirely by Cloudflare — no action required on the Pi.
- Covers all single-level subdomains (~git.zakobar.com~, ~auth.zakobar.com~, etc.).
- Does *not* cover two-level subdomains (~git.home.zakobar.com~) — this was
the root cause of problem #3 above.
* Quick Reference: Debugging Checklist
| Symptom | Where to look | Command |
|---|---|---|
| 502 Bad Gateway | cloudflared logs | ~journalctl -u cloudflared-tunnel -n 50~ |
| 502 Bad Gateway | Caddy → backend | ~curl http://localhost:<port>/~ |
| TLS internal error | SNI / cert issue | ~curl -sv --resolve host:443:127.0.0.1 https://host/~ |
| Too many redirects | HTTP vhost missing | check ~http://~ entries in caddy.nix |
| Handshake failure at edge | Cloudflare cert scope | check SSL/TLS → Edge Certificates |
| Token appears invalid | plugin version | check ~caddy-dns/cloudflare~ version vs token format |
| Caddy won't start | token / config error | ~journalctl -u caddy --since "5 min ago"~ |
+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.
-64
View File
@@ -1,64 +0,0 @@
###############################################################
# Authelia minimal configuration #
###############################################################
theme: "light"
log:
level: "debug"
jwt_secret: {{ .homey_authelia_jwt | quote }}
authentication_backend:
ldap:
implementation: "custom"
url: "ldap://openldap:389"
timeout: "5s"
start_tls: false
base_dn: "{{ .Values.homey.url | replace "." ",dc=" | printf "dc=%s " | trim}}"
users_filter: "({username_attribute}={input})"
username_attribute: "uid"
additional_users_dn: "ou=users"
groups_filter: "(&(uniquemember=uid={input},ou=users,{{ .Values.homey.url | replace "." ",dc=" | printf "dc=%s " | trim}})(objectclass=groupOfUniqueNames))"
group_name_attribute: "cn"
additional_groups_dn: "ou=groups"
mail_attribute: "mail"
display_name_attribute: "uid"
permit_referrals: false
permit_unauthenticated_bind: false
user: "cn=readonly,{{ .Values.homey.url | replace "." ",dc=" | printf "dc=%s " | trim }}"
password: {{ .homey_openldap_ro | quote }}
totp:
issuer: "{{ .Values.homey.url }}"
disable: false
session:
name: authelia_session
secret: {{ .homey_authelia_session | quote }}
expiration: 3600 # 1 hour
inactivity: 7200 # 2 hours
domain: "{{ .Values.homey.url}}" # needs to be your root domain
storage:
local:
path: "/config/db.sqlite3"
encryption_key: {{ .homey_authelia_encryption_key | quote }}
access_control:
default_policy: "deny"
rules:
- domain:
- "auth.zakobar.com"
policy: "bypass"
- domain:
- "dav.{{ .Values.homey.url }}"
policy: "one_factor"
- domain:
- "ldapadmin.{{ .Values.homey.url }}"
subject:
- 'group:admins'
policy: "two_factor"
- domain:
- "*.admin.{{ .Values.homey.url }}"
subject:
- 'group:admins'
policy: "two_factor"
- domain:
- "*.admin.{{ .Values.homey.url }}"
policy: "deny"
notifier:
filesystem:
filename: "/var/lib/authelia/emails.txt"
-95
View File
@@ -1,95 +0,0 @@
APP_NAME = {{ .Values.homey.organization }}
RUN_MODE = prod
RUN_USER = git
WORK_PATH = /data/gitea
[repository]
ROOT = /data/git/repositories
[repository.local]
LOCAL_COPY_PATH = /data/gitea/tmp/local-repo
[repository.upload]
TEMP_PATH = /data/gitea/uploads
[server]
APP_DATA_PATH = /data/gitea
DOMAIN = git.{{ .Values.homey.url }}
HTTP_PORT = 3000
ROOT_URL = https://git.{{ .Values.homey.url }}/
DISABLE_SSH = true
SSH_PORT = 443
SSH_LISTEN_PORT = 22
LFS_START_SERVER = true
LFS_JWT_SECRET = {{ .homey_gitea_lfs_jwt_secret | b64enc | replace "=" "" }}
OFFLINE_MODE = false
[lfs]
PATH = /data/git/lfs
[database]
PATH = /data/gitea/gitea.db
DB_TYPE = sqlite3
HOST = localhost:3306
NAME = gitea
USER = root
PASSWD =
LOG_SQL = false
SCHEMA =
SSL_MODE = disable
CHARSET = utf8
[indexer]
ISSUE_INDEXER_PATH = /data/gitea/indexers/issues.bleve
[session]
PROVIDER_CONFIG = /data/gitea/sessions
PROVIDER = file
[picture]
AVATAR_UPLOAD_PATH = /data/gitea/avatars
REPOSITORY_AVATAR_UPLOAD_PATH = /data/gitea/repo-avatars
DISABLE_GRAVATAR = false
ENABLE_FEDERATED_AVATAR = false
[attachment]
PATH = /data/gitea/attachments
[log]
MODE = console
LEVEL = info
ROUTER = console
ROOT_PATH = /data/gitea/log
[security]
INSTALL_LOCK = true
SECRET_KEY =
REVERSE_PROXY_LIMIT = 1
REVERSE_PROXY_TRUSTED_PROXIES = *
INTERNAL_TOKEN = {{ .homey_gitea_random_internal_token }}
PASSWORD_HASH_ALGO = pbkdf2
[service]
DISABLE_REGISTRATION = true
REQUIRE_SIGNIN_VIEW = false
REGISTER_EMAIL_CONFIRM = false
ENABLE_NOTIFY_MAIL = false
ALLOW_ONLY_EXTERNAL_REGISTRATION = true
ENABLE_CAPTCHA = false
DEFAULT_KEEP_EMAIL_PRIVATE = false
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
DEFAULT_ENABLE_TIMETRACKING = true
NO_REPLY_ADDRESS = noreply.localhost
ENABLE_REVERSE_PROXY_AUTHENTICATION = true
ENABLE_REVERSE_PROXY_AUTO_REGISTRATION = true
[mailer]
ENABLED = false
[openid]
ENABLE_OPENID_SIGNIN = false
ENABLE_OPENID_SIGNUP = false
[oauth2]
ENABLE = false
JWT_SECRET = {{ .homey_gitea_oauth2_jwt_secret | b64enc | replace "=" "" }}
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
}
+124
View File
@@ -0,0 +1,124 @@
{
description = "Homey - self-hosted home server NixOS configuration";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
# sops-nix for secret management
sops-nix = {
url = "github:Mic92/sops-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
# nixos-hardware provides RPi4 wireless firmware.
# We use only the minimal pieces needed for a headless server —
# no display, audio, or bluetooth modules.
nixos-hardware.url = "github:NixOS/nixos-hardware/master";
# Eurovision voting app
eurovote = {
url = "github:anerisgreat/eurovote";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, sops-nix, nixos-hardware, eurovote, ... }@inputs:
let
# Shared specialArgs passed to every host
commonArgs = {
inherit inputs;
# Top-level site config — override per-host if needed
homeyConfig = {
domain = "zakobar.com"; # base domain for all services
organization = "Zakobar Home Server";
timezone = "Asia/Jerusalem";
};
};
# Minimal RPi4 hardware module for a headless server.
# Provides only: bootloader, initrd modules, wireless firmware, DTB filter.
# Deliberately excludes display, audio, bluetooth from the full nixos-hardware module.
rpi4Headless = { pkgs, ... }: {
boot.loader.grub.enable = false;
boot.loader.generic-extlinux-compatible.enable = true;
boot.initrd.availableKernelModules = [
"pcie-brcmstb" # PCIe bus (USB3, NVMe)
"reset-raspberrypi" # required for vl805 firmware
"usb-storage"
"usbhid"
"vc4" # VideoCore (needed even headless for boot)
];
# sd-image-aarch64.nix lists modules for many SoCs (including sun4i-drm
# for Allwinner boards) that don't exist in linux_rpi4. Allow missing.
boot.initrd.includeDefaultModules = false;
hardware.deviceTree.filter = "bcm2711-rpi-*.dtb";
hardware.firmware = [
(pkgs.callPackage "${nixos-hardware}/raspberry-pi/common/raspberry-pi-wireless-firmware.nix" {})
];
};
mkHost = { hostPath, extraModules ? [] }:
nixpkgs.lib.nixosSystem {
specialArgs = commonArgs;
modules = [
sops-nix.nixosModules.sops
rpi4Headless
hostPath
./modules/common.nix
./modules/storage.nix
./modules/caddy.nix
./modules/cloudflared.nix
./modules/backup.nix
./modules/services/openldap.nix
./modules/services/authelia.nix
./modules/services/gitea.nix
./modules/services/nextcloud.nix
./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;
};
in {
nixosConfigurations = {
# Bootstrap image — flash this first, then deploy pi-main.
# See hosts/pi-main-bootstrap/default.nix for details.
pi-main-bootstrap = nixpkgs.lib.nixosSystem {
specialArgs = commonArgs;
modules = [
rpi4Headless
({ modulesPath, ... }: {
imports = [ "${modulesPath}/installer/sd-card/sd-image-aarch64.nix" ];
})
./hosts/pi-main/hardware.nix
./hosts/pi-main-bootstrap/default.nix
];
};
# Primary Raspberry Pi 4
pi-main = mkHost {
hostPath = ./hosts/pi-main/default.nix;
};
# Future second machine (placeholder — uncomment and configure when ready)
# pi-secondary = mkHost {
# hostPath = ./hosts/pi-secondary/default.nix;
# };
};
devShells = nixpkgs.lib.genAttrs [ "x86_64-linux" "aarch64-linux" ] (system:
(import ./shells) { pkgs = nixpkgs.legacyPackages.${system}; }
);
};
}
-1
View File
@@ -1 +0,0 @@
kubectl get secret -n $1 $2 --template={{.data.$3}} | base64 -d | xclip -selection c
+70
View File
@@ -0,0 +1,70 @@
{ pkgs, lib, homeyConfig, ... }:
# Bootstrap image for the primary Raspberry Pi 4.
#
# Flash this image first. Its only purpose is to boot the Pi so you can:
# 1. Generate the age key: sudo age-keygen -o /var/lib/sops-nix/key.txt
# 2. Print the pubkey: sudo age-keygen -y /var/lib/sops-nix/key.txt
# 3. Add the pubkey to .sops.yaml, re-encrypt secrets, then deploy pi-main.
#
# No sops, no services, no external HD — just SSH + WiFi.
#
# WiFi PSK: uncomment and fill in before building. Do not commit the password.
# networks."YourSSID".psk = "your-wifi-password";
{
networking.hostName = "pi-main";
time.timeZone = homeyConfig.timezone;
i18n.defaultLocale = "en_US.UTF-8";
system.stateVersion = "25.05";
nix.settings = {
experimental-features = [ "nix-command" "flakes" ];
substituters = [
"https://cache.nixos.org"
"https://nix-community.cachix.org"
];
trusted-public-keys = [
"cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
"nix-community.cachix.org-1:mB9FkXj6Q3Q4ohOcbM4FJ9Z1X2kCrVK4vZOqsDqqNqk="
];
};
nixpkgs.config.allowUnfree = true;
# linux_rpi4 is pre-built in cache.nixos.org — fetched, not compiled.
boot.kernelPackages = pkgs.linuxKernel.packages.linux_rpi4;
networking.wireless = {
enable = true;
# networks."Zakobar".psk = "your-wifi-password";
};
networking.interfaces.wlan0.ipv4.addresses = [{
address = "192.168.1.100";
prefixLength = 24;
}];
networking.useDHCP = false;
networking.interfaces.wlan0.useDHCP = false;
networking.defaultGateway = "192.168.1.1";
networking.nameservers = [ "1.1.1.1" "8.8.8.8" ];
networking.firewall.allowedTCPPorts = [ 22 ];
services.openssh = {
enable = true;
settings = {
PasswordAuthentication = false;
PermitRootLogin = "no";
};
};
users.mutableUsers = false;
users.users.admin = {
isNormalUser = true;
extraGroups = [ "wheel" ];
openssh.authorizedKeys.keys = [
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDfzDDO5juINctECmWlsYtGghEiX/RnTJ1cazLvOWSrPfsTyEd+B1+Ig8kFefNryjkpApfRXqj5KtLPNlpLfdVBrOIfhIveEp2MGqhgOGZFNVxQyXnZgii8Zdh4cqZ2O3pZpMsaAQBaJ9nH6dK0dJjicWT5f6TqwrVcInywRc5SuyizoSxoFmg7ch2rnlVi0j5XMVqdh8XLzHXZ7yWCzXy7+hWl/d7pwpyuzoK8dBw2EU9TauhgRDruom5Q9vWJTLStALC9pAIb0v9UFj9y+1zwx7pXsXp5F1g73EYrE4QR+QQ6z2LebuK280W0t+VA/fSCEB13DnkmofgqZQxX5MSCmrxZ5lTFp1FjW6yJo7As9FheF/GECowYkMRIx4IiQsjjHjZqlLRpLas11yAp6tGoZnw59hFo6Lu0Kva39jGVVmioYHtAeE5rD5w+v5kseJR4jlQ8aKB5yOjYUQOIz2AHQyoidgaeR2jPWqZUeRQbACI+/p3CHO45r3hrjATtGloBg0xF95Qws7Be3mjHVhbBLOoob8MdZ8nYAGnhlWrZphlkvXsHC6OUkuDJW00tmMjWXRlFwhFJ+nqUQCgLVjxVHQJ5rq9GeXBUuNXAeCm5BKBsdq+9qqVlt7D9iGyfr0lcZ7peKz/96KwPCWpG2En1Ur0/cVcbWnXEfG/xWO10tQ== cardno:24_758_470"
];
};
security.sudo.wheelNeedsPassword = false;
environment.systemPackages = [ pkgs.age pkgs.vim ];
}
+227
View File
@@ -0,0 +1,227 @@
{ config, lib, pkgs, homeyConfig, ... }:
# Pi-main host configuration.
# This file declares which services run on this machine and any
# host-specific overrides. Hardware config lives in hardware.nix.
{
imports = [
./hardware.nix
];
# linux_rpi4 is the Raspberry Pi Foundation's kernel, sourced from nixpkgs
# and pre-built in cache.nixos.org. Avoids a multi-hour native compilation.
boot.kernelPackages = pkgs.linuxKernel.packages.linux_rpi4;
# -------------------------------------------------------------------------
# Identity
# -------------------------------------------------------------------------
networking.hostName = "pi-main";
# -------------------------------------------------------------------------
# WiFi — static IP, always connect to home network
# -------------------------------------------------------------------------
networking.wireless = {
enable = true;
# secretsFile is read by wpa_supplicant at runtime; values are literal
# (not env vars). The key name after "ext:" must match a line in the file
# formatted as: key_name=the-actual-password
secretsFile = config.sops.secrets."wifi/psk".path;
networks."Zakobar".pskRaw = "ext:wifi_psk";
};
# Static IP on wlan0
networking.interfaces.wlan0.ipv4.addresses = [{
address = "192.168.1.100";
prefixLength = 24;
}];
networking.defaultGateway = "192.168.1.1";
networking.nameservers = [ "1.1.1.1" "8.8.8.8" ];
# Disable DHCP on wlan0 — we're using a static address
networking.useDHCP = false;
networking.interfaces.wlan0.useDHCP = false;
# The secret file must contain exactly one line: wifi_psk=<your-password>
# Add it with: sops secrets/secrets.yaml → wifi/psk: "wifi_psk=YourPassword"
sops.secrets."wifi/psk" = { owner = "root"; mode = "0400"; };
# -------------------------------------------------------------------------
# Admin user
# -------------------------------------------------------------------------
users.users.admin = {
isNormalUser = true;
extraGroups = [ "wheel" "podman" ];
# Paste your SSH public key here
openssh.authorizedKeys.keys = [
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDfzDDO5juINctECmWlsYtGghEiX/RnTJ1cazLvOWSrPfsTyEd+B1+Ig8kFefNryjkpApfRXqj5KtLPNlpLfdVBrOIfhIveEp2MGqhgOGZFNVxQyXnZgii8Zdh4cqZ2O3pZpMsaAQBaJ9nH6dK0dJjicWT5f6TqwrVcInywRc5SuyizoSxoFmg7ch2rnlVi0j5XMVqdh8XLzHXZ7yWCzXy7+hWl/d7pwpyuzoK8dBw2EU9TauhgRDruom5Q9vWJTLStALC9pAIb0v9UFj9y+1zwx7pXsXp5F1g73EYrE4QR+QQ6z2LebuK280W0t+VA/fSCEB13DnkmofgqZQxX5MSCmrxZ5lTFp1FjW6yJo7As9FheF/GECowYkMRIx4IiQsjjHjZqlLRpLas11yAp6tGoZnw59hFo6Lu0Kva39jGVVmioYHtAeE5rD5w+v5kseJR4jlQ8aKB5yOjYUQOIz2AHQyoidgaeR2jPWqZUeRQbACI+/p3CHO45r3hrjATtGloBg0xF95Qws7Be3mjHVhbBLOoob8MdZ8nYAGnhlWrZphlkvXsHC6OUkuDJW00tmMjWXRlFwhFJ+nqUQCgLVjxVHQJ5rq9GeXBUuNXAeCm5BKBsdq+9qqVlt7D9iGyfr0lcZ7peKz/96KwPCWpG2En1Ur0/cVcbWnXEfG/xWO10tQ== cardno:24_758_470"
];
};
security.sudo.wheelNeedsPassword = false; # convenience on a home server
# -------------------------------------------------------------------------
# External HD
# -------------------------------------------------------------------------
homey.storage = {
# Replace with the actual by-id path of your USB drive.
# Find it: ls -la /dev/disk/by-id/ | grep -v part
device = "/dev/disk/by-label/homey-data";
mountPoint = "/mnt/data";
fsType = "ext4";
};
# -------------------------------------------------------------------------
# Services enabled on this host
# -------------------------------------------------------------------------
# Auth stack (run these together — authelia depends on openldap)
homey.openldap.enable = true;
homey.authelia.enable = true;
# Productivity
homey.gitea.enable = true;
homey.nextcloud.enable = true;
homey.phpldapadmin.enable = true;
# Media (enable when ready)
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:
# "sftp:user@nas.local:/backups/homey"
# "b2:your-bucket-name:homey"
# "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)
# -------------------------------------------------------------------------
# If you run Pi-hole or Adguard, add these records there instead.
# networking.extraHosts = ''
# 192.168.1.100 zakobar.com
# 192.168.1.100 auth.zakobar.com
# 192.168.1.100 git.zakobar.com
# 192.168.1.100 nextcloud.zakobar.com
# 192.168.1.100 ldapadmin.zakobar.com
# '';
}
+50
View File
@@ -0,0 +1,50 @@
{ config, lib, pkgs, modulesPath, ... }:
# Hardware configuration for the primary Raspberry Pi 4 (8 GB).
#
# nixos-raspberrypi's raspberry-pi-4.base module (imported in flake.nix)
# provides everything that nixos-hardware.raspberry-pi-4 previously did:
# - linuxPackages_rpi4 vendor kernel + matching firmware
# - u-boot bootloader with /boot/firmware partition management
# - initrd modules (xhci_pci, usbhid, usb_storage, vc4, pcie_brcmstb, etc.)
# - config.txt generation
#
# This file adds only host-specific overrides on top of that.
#
# External HD:
# Set homey.storage.device to the by-id path of your USB drive.
# Find it with: ls -la /dev/disk/by-id/
#
# TODO: Verify SD card partition labels after first flash.
# The config assumes labels NIXOS_SD (root) and FIRMWARE (boot).
# Check with: lsblk -o NAME,LABEL
# Update fileSystems entries below if they differ.
{
# tmpfs for /tmp — keep the SD card writes down
boot.tmp.useTmpfs = true;
# Filesystems
fileSystems."/" = {
device = "/dev/disk/by-label/NIXOS_SD";
fsType = "ext4";
options = [ "noatime" ];
};
fileSystems."/boot/firmware" = {
device = "/dev/disk/by-label/FIRMWARE";
fsType = "vfat";
options = [ "fmask=0022" "dmask=0022" ];
};
# External HD — device path is set in default.nix via homey.storage.device.
# storage.nix creates the actual fileSystems entry from that option.
swapDevices = [];
# Platform
nixpkgs.hostPlatform = lib.mkDefault "aarch64-linux";
# Power management
powerManagement.cpuFreqGovernor = lib.mkDefault "ondemand";
}
+181
View File
@@ -0,0 +1,181 @@
{ config, lib, pkgs, homeyConfig, ... }:
# Restic backup module.
#
# Backs up all service data directories from the external HD.
# Schedule: daily at 03:00, keep 7 daily / 4 weekly / 6 monthly snapshots.
#
# Before a backup, Nextcloud is put into maintenance mode and postgres is
# pg_dump'd to a file. This ensures consistent DB backups.
#
# Backup strategy — two tiers:
#
# 1. Automatic daily backup to an S3-compatible bucket (primary offsite copy).
# Set the repository URL to your bucket in hosts/pi-main/default.nix, e.g.:
# homey.backup.repository = "s3:https://s3.us-west-002.backblazeb2.com/your-bucket";
# S3 credentials are injected via environment variables from sops secrets:
# restic/s3_access_key_id → AWS_ACCESS_KEY_ID
# restic/s3_secret_access_key → AWS_SECRET_ACCESS_KEY
#
# 2. Manual offload to a local disk (USB drive plugged into Pi, or workstation disk).
# Use scripts/offload-backup.sh --target /path/to/mounted/disk
# That script uses `restic copy` to clone snapshots from the S3 repo into a
# local restic repo on the target disk, preserving deduplication.
#
# Secrets consumed from sops:
# restic/password
# restic/s3_access_key_id (if using S3 backend)
# restic/s3_secret_access_key (if using S3 backend)
#
# The backup repository URL is set per-host in default.nix:
# homey.backup.repository = "s3:https://s3.us-west-002.backblazeb2.com/bucket";
#
# Restore:
# restic -r <repo> restore latest --target /mnt/data
# (or restore a single path: --include /mnt/data/openldap)
let
cfg = config.homey.backup;
dataDir = config.homey.storage.mountPoint;
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";
description = ''
Restic repository URL. Examples:
sftp:user@host:/path
b2:bucket-name:prefix
rclone:remote:path
/local/path (for testing)
'';
};
schedule = lib.mkOption {
type = lib.types.str;
default = "03:00";
description = "systemd OnCalendar expression for the daily backup.";
};
pruneRetention = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = {
daily = "7";
weekly = "4";
monthly = "6";
};
};
};
config = lib.mkIf cfg.enable {
# -----------------------------------------------------------------------
# Secrets
# -----------------------------------------------------------------------
sops.secrets."restic/password" = { owner = "root"; };
sops.secrets."restic/s3_access_key_id" = { owner = "root"; };
sops.secrets."restic/s3_secret_access_key" = { owner = "root"; };
# -----------------------------------------------------------------------
# Pre-backup hook: pg_dump + nextcloud maintenance mode
# -----------------------------------------------------------------------
systemd.services."homey-backup-pre" = {
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
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 \
pg_dump -U postgres nextcloud_db \
> ${dataDir}/nextcloud/db-dump/nextcloud.sql
fi
'';
};
};
# -----------------------------------------------------------------------
# Restic backup service
# -----------------------------------------------------------------------
services.restic.backups.homey = {
repository = cfg.repository;
passwordFile = config.sops.secrets."restic/password".path;
# Runtime env file written by homey-backup-pre.service (which runs first)
environmentFile = "/run/restic-homey-secrets.env";
# Paths are contributed by individual service modules via homey.backup.extraPaths.
paths = config.homey.backup.extraPaths;
exclude = [
# 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;
Persistent = true; # run on next boot if missed
};
pruneOpts = [
"--keep-daily ${cfg.pruneRetention.daily}"
"--keep-weekly ${cfg.pruneRetention.weekly}"
"--keep-monthly ${cfg.pruneRetention.monthly}"
];
};
# 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 = {
ExecStopPost = [
(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
'')
];
};
};
};
}
+183
View File
@@ -0,0 +1,183 @@
{ config, lib, pkgs, homeyConfig, ... }:
# Caddy reverse proxy.
#
# Features:
# - DNS-01 ACME via Cloudflare API → real wildcard cert for *.zakobar.com
# - forward_auth to Authelia for protected vhosts
# - Plain reverse_proxy for public vhosts (authelia itself, nextcloud)
# - Listens on :80 (redirect) and :443 (TLS)
#
# Because nixpkgs ships Caddy without the cloudflare DNS plugin by default,
# we build a custom Caddy with it using the xcaddy wrapper from nixpkgs.
#
# Secrets consumed from sops:
# cloudflare/api_token
let
cfg = config.homey.caddy;
domain = homeyConfig.domain;
# Build Caddy with the Cloudflare DNS plugin using the nixos-25.05 API.
# `withPlugins` is a passthru function on the caddy package; it uses xcaddy
# under the hood to produce a fixed-output derivation.
caddyWithCloudflare = pkgs.caddy.withPlugins {
plugins = [
# v0.2.4 tag points to commit a8737d0 which includes the fix for
# cfut_/cfat_ token format validation (PR #123).
"github.com/caddy-dns/cloudflare@v0.2.4"
];
hash = "sha256-pRrLBlYRaAyMYwPXeTy4WqWNRu/L9K6Mn2src11dGh8=";
};
# Reverse-proxy snippet for cloudflared http:// vhosts.
# Cloudflare terminates TLS; cloudflared connects to Caddy over plain HTTP.
# We must override X-Forwarded-Proto so upstream services (especially
# Authelia) know the client is actually on HTTPS.
cfProxy = port: ''
reverse_proxy localhost:${toString port} {
header_up X-Forwarded-Proto https
}
'';
# Reusable Authelia forward_auth snippet
# Returns a Caddyfile snippet block that applies forward_auth.
# 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/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
}
'';
in
{
options.homey.caddy = {
enable = lib.mkEnableOption "Caddy reverse proxy";
acmeEmail = lib.mkOption {
type = lib.types.str;
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 {
# -----------------------------------------------------------------------
# Secrets
# -----------------------------------------------------------------------
sops.secrets."cloudflare/api_token" = {
owner = config.services.caddy.user;
};
# -----------------------------------------------------------------------
# Caddy service
# -----------------------------------------------------------------------
services.caddy = {
enable = true;
package = caddyWithCloudflare;
# Global options
globalConfig = ''
email ${cfg.acmeEmail}
# Use Cloudflare DNS-01 challenge for wildcard cert
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
'';
# Each virtual host is generated from homey.caddy.virtualHosts entries.
# Each service module contributes its own entries to that list.
#
# 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
);
};
# -----------------------------------------------------------------------
# Pass Cloudflare token as env var to the caddy systemd unit.
#
# The caddy-dns/cloudflare plugin reads CLOUDFLARE_API_TOKEN directly.
# sops decrypts the secret to a file at runtime; we write a transient
# env file to /run/ in ExecStartPre so systemd picks it up via
# EnvironmentFile. The file is removed in ExecStopPost.
# -----------------------------------------------------------------------
systemd.services.caddy = {
serviceConfig = {
# LoadCredential stages the sops-decrypted secret into a
# per-invocation directory ($CREDENTIALS_DIRECTORY) before any
# Exec* step. ExecStart then reads the file contents and exports
# CLOUDFLARE_API_TOKEN before exec-ing caddy, so there is no
# intermediate env file and no ordering race with EnvironmentFile.
LoadCredential = "cloudflare_api_token:${config.sops.secrets."cloudflare/api_token".path}";
# Systemd requires clearing ExecStart= before setting a new value for
# non-oneshot services. The empty string resets the list; the second
# entry is the actual start command.
ExecStart = lib.mkForce [
""
(pkgs.writeShellScript "caddy-start" ''
export CLOUDFLARE_API_TOKEN=$(cat "$CREDENTIALS_DIRECTORY/cloudflare_api_token")
exec ${caddyWithCloudflare}/bin/caddy run --environ --config /etc/caddy/caddy_config --adapter caddyfile
'')
];
};
after = lib.mkAfter [ "podman-authelia.service" ];
wants = lib.mkAfter [ "podman-authelia.service" ];
};
# -----------------------------------------------------------------------
# Firewall — open HTTP + HTTPS (already in common.nix, explicit here too)
# -----------------------------------------------------------------------
networking.firewall.allowedTCPPorts = [ 80 443 ];
};
}
+91
View File
@@ -0,0 +1,91 @@
{ config, lib, pkgs, homeyConfig, ... }:
# Cloudflare Tunnel (cloudflared) — remote access without open inbound ports.
#
# Architecture:
# Internet → Cloudflare edge → cloudflared tunnel (outbound from Pi)
# → Caddy on localhost → service containers
#
# The tunnel is configured to route each hostname to Caddy's HTTPS listener.
# Caddy handles TLS and forward_auth; cloudflared just carries the traffic.
#
# Setup steps (one-time, done from the Cloudflare dashboard):
# 1. Go to Zero Trust → Networks → Tunnels → Create a tunnel
# 2. Name it (e.g. "pi-main")
# 3. Copy the tunnel token — add it to secrets.yaml as cloudflare/tunnel_token
# 4. In the tunnel's "Public Hostnames" config, add routes:
# auth.zakobar.com → http://localhost:80 (or https://localhost:443)
# git.zakobar.com → https://localhost:443
# nextcloud.zakobar.com → https://localhost:443
# ldapadmin.zakobar.com → https://localhost:443
# jellyfin.zakobar.com → https://localhost:443
# torrent.zakobar.com → https://localhost:443
# 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).
#
# The tunnel_token approach (--token) is the simplest: one secret, no config
# file needed on the Pi.
#
# Secrets consumed from sops:
# cloudflare/tunnel_token
let
cfg = config.homey.cloudflared;
in
{
options.homey.cloudflared = {
enable = lib.mkEnableOption "Cloudflare Tunnel for remote access";
};
config = lib.mkIf cfg.enable {
# -----------------------------------------------------------------------
# Secrets
# -----------------------------------------------------------------------
sops.secrets."cloudflare/tunnel_token" = { owner = "cloudflared"; };
# -----------------------------------------------------------------------
# cloudflared service
#
# We use the token-based tunnel approach (cloudflared tunnel run --token).
# This needs no credentials file and no local tunnel config — just the
# token from the Cloudflare dashboard.
#
# Rather than using services.cloudflared.tunnels (which requires a
# credentialsFile), we create a plain systemd service that runs cloudflared
# directly with the token read from the sops secret.
# -----------------------------------------------------------------------
users.users.cloudflared = {
isSystemUser = true;
group = "cloudflared";
description = "cloudflared tunnel daemon";
};
users.groups.cloudflared = {};
systemd.services."cloudflared-tunnel" = {
description = "Cloudflare Tunnel (token-based)";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" "caddy.service" ];
wants = [ "network-online.target" "caddy.service" ];
serviceConfig = {
Type = "simple";
User = "cloudflared";
Group = "cloudflared";
Restart = "on-failure";
RestartSec = "5s";
ExecStart = pkgs.writeShellScript "cloudflared-start" ''
exec ${pkgs.cloudflared}/bin/cloudflared tunnel \
--no-autoupdate \
run \
--token "$(cat ${config.sops.secrets."cloudflare/tunnel_token".path})"
'';
# Hardening
NoNewPrivileges = true;
PrivateTmp = true;
};
};
};
}
+156
View File
@@ -0,0 +1,156 @@
{ config, lib, pkgs, homeyConfig, ... }:
# Common configuration shared by every host in the homey ecosystem.
# Hardware-specific settings (disk layout, device trees, etc.) go in
# hosts/<name>/hardware.nix instead.
{
# -------------------------------------------------------------------------
# Nix / flakes
# -------------------------------------------------------------------------
nix = {
settings = {
experimental-features = [ "nix-command" "flakes" ];
auto-optimise-store = true;
# Extra binary caches — speeds up aarch64-linux builds significantly
substituters = [
"https://cache.nixos.org"
"https://nix-community.cachix.org"
];
trusted-public-keys = [
"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;
dates = "weekly";
options = "--delete-older-than 14d";
};
};
# 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
# -------------------------------------------------------------------------
# boot.loader is intentionally left to hardware.nix
# -------------------------------------------------------------------------
# Locale / timezone
# -------------------------------------------------------------------------
time.timeZone = homeyConfig.timezone;
i18n.defaultLocale = "en_US.UTF-8";
# -------------------------------------------------------------------------
# Networking
# -------------------------------------------------------------------------
networking = {
# hostname is set per-host in default.nix
firewall = {
enable = true;
allowedTCPPorts = [
22 # SSH
80 # Caddy HTTP (redirect to HTTPS or ACME challenge)
443 # Caddy HTTPS
];
};
# Use systemd-resolved for DNS — supports mDNS and local overrides
nameservers = [ "1.1.1.1" "8.8.8.8" ];
};
# -------------------------------------------------------------------------
# SSH
# -------------------------------------------------------------------------
services.openssh = {
enable = true;
settings = {
PasswordAuthentication = false;
PermitRootLogin = "no";
};
};
# -------------------------------------------------------------------------
# Container runtime — podman (rootless-capable, no daemon needed)
# -------------------------------------------------------------------------
virtualisation.podman = {
enable = true;
dockerCompat = true; # allow `docker` CLI commands against podman
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
# -------------------------------------------------------------------------
environment.systemPackages = with pkgs; [
git
vim
htop
curl
wget
rsync
lsof
sops # secret editing
age # key generation for sops
restic # backup (CLI, also used by services.restic)
podman-compose
];
# -------------------------------------------------------------------------
# sops-nix global config — point at the secrets file and the host's age key
# -------------------------------------------------------------------------
sops = {
defaultSopsFile = ../secrets/secrets.yaml;
# The age private key must be present on the host at this path.
# Generate on the Pi with: age-keygen -o /var/lib/sops-nix/key.txt
# Then add the PUBLIC key to secrets/.sops.yaml before encrypting.
age.keyFile = "/var/lib/sops-nix/key.txt";
};
# -------------------------------------------------------------------------
# Admin user — adjust username / SSH key in hosts/<name>/default.nix
# -------------------------------------------------------------------------
users.mutableUsers = false; # all user config must be declared here
# The actual admin user is declared in hosts/<name>/default.nix so the
# SSH authorized key can be host-specific.
# -------------------------------------------------------------------------
# System state version — do not change after first install
# (tracks NixOS backwards-compat markers)
# -------------------------------------------------------------------------
system.stateVersion = "25.05";
}
+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;
}];
};
}
+274
View File
@@ -0,0 +1,274 @@
{ config, lib, pkgs, homeyConfig, ... }:
# Authelia — SSO gateway.
#
# Connects to OpenLDAP on 127.0.0.1:389.
# Exposes port 9091 on localhost; Caddy reverse-proxies it and provides
# the forward_auth endpoint for protected vhosts.
#
# Volume layout:
# <dataDir>/authelia/config/ → /config (sqlite db, notification log, etc.)
#
# The configuration file is rendered by Nix (no Go templates) and written
# to a NixOS-managed path, then bind-mounted read-only into the container.
#
# Secrets consumed from sops:
# authelia/jwt_secret
# 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;
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));
# 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 environment variables.
autheliaConfig = ''
###############################################################
# Authelia configuration #
# Generated by NixOS do not edit by hand #
###############################################################
theme: "light"
log:
level: "info"
# jwt_secret injected at runtime via env var AUTHELIA_JWT_SECRET_FILE
authentication_backend:
ldap:
implementation: "custom"
url: "ldap://openldap:389"
timeout: "5s"
start_tls: false
base_dn: "${ldapBaseDN}"
users_filter: "({username_attribute}={input})"
username_attribute: "uid"
additional_users_dn: "ou=users"
groups_filter: "(&(uniquemember=uid={input},ou=users,${ldapBaseDN})(objectclass=groupOfUniqueNames))"
group_name_attribute: "cn"
additional_groups_dn: "ou=groups"
mail_attribute: "mail"
display_name_attribute: "uid"
permit_referrals: false
permit_unauthenticated_bind: false
user: "cn=readonly,${ldapBaseDN}"
# password injected at runtime via AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE
totp:
issuer: "${domain}"
disable: false
session:
name: authelia_session
# secret injected at runtime via AUTHELIA_SESSION_SECRET_FILE
expiration: 3600
inactivity: 7200
domain: "${domain}"
storage:
local:
path: "/config/db.sqlite3"
# encryption_key injected at runtime via AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE
access_control:
default_policy: "deny"
rules:
${rulesYaml}
notifier:
filesystem:
filename: "/config/emails.txt"
ntp:
address: "udp://time.cloudflare.com:123"
version: 3
max_desync: "3s"
disable_startup_check: false
disable_failure: true
'';
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 {
type = lib.types.str;
default = "docker.io/authelia/authelia:latest";
};
port = lib.mkOption {
type = lib.types.port;
default = 9091;
description = "Host port Authelia listens on (bound to 127.0.0.1).";
};
};
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
# -----------------------------------------------------------------------
sops.secrets."authelia/jwt_secret" = { owner = "root"; };
sops.secrets."authelia/session_secret" = { owner = "root"; };
sops.secrets."authelia/storage_encryption_key" = { owner = "root"; };
# openldap/ro_password is declared in openldap.nix; reference it here too
# (sops-nix deduplicates identical declarations)
sops.secrets."openldap/ro_password" = { owner = "root"; };
# -----------------------------------------------------------------------
# Write the config file into /etc (read-only in the container)
# -----------------------------------------------------------------------
environment.etc."authelia/configuration.yml" = {
text = autheliaConfig;
mode = "0444";
};
# -----------------------------------------------------------------------
# Container
# -----------------------------------------------------------------------
virtualisation.oci-containers.containers.authelia = {
image = cfg.image;
ports = [ "127.0.0.1:${toString cfg.port}:9091" ];
environment = {
TZ = homeyConfig.timezone;
# Tell authelia to read secrets from files (its native mechanism)
AUTHELIA_JWT_SECRET_FILE = "/run/secrets/jwt_secret";
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 = [
"/etc/authelia/configuration.yml:/config/configuration.yml:ro"
"${dataDir}/authelia/config:/config"
# Mount sops secret files into the container under /run/secrets/
"${config.sops.secrets."authelia/jwt_secret".path}:/run/secrets/jwt_secret:ro"
"${config.sops.secrets."authelia/session_secret".path}:/run/secrets/session_secret:ro"
"${config.sops.secrets."authelia/storage_encryption_key".path}:/run/secrets/storage_encryption_key:ro"
"${config.sops.secrets."openldap/ro_password".path}:/run/secrets/ldap_ro_password:ro"
];
extraOptions = [
"--network=homey"
"--hostname=authelia"
];
};
# -----------------------------------------------------------------------
# Systemd — wait for openldap and external HD
# -----------------------------------------------------------------------
systemd.services."podman-authelia" = {
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
];
};
};
}
+273
View File
@@ -0,0 +1,273 @@
{ config, lib, pkgs, homeyConfig, ... }:
# Gitea — self-hosted Git service.
#
# Auth model: LDAP authentication is configured through Gitea's admin UI
# (or CLI) after first start. Reverse proxy auth headers from Caddy/Authelia
# handle transparent login.
#
# Volume layout:
# <dataDir>/gitea/data/ → /data (repos, sqlite db, avatars, lfs, etc.)
#
# Configuration strategy: all settings are passed as GITEA__<SECTION>__<KEY>
# environment variables. Gitea writes its own app.ini into /data/gitea/conf/
# on first start; the env vars override every key at runtime without touching
# that file. This avoids the bind-mount / read-only-fs problem where Gitea
# needs to rewrite its own config file on startup.
#
# Non-secret settings go in the `environment` block (they are fine in the
# Nix store). Secret settings go into /run/gitea-secrets.env via ExecStartPre
# (never in the store).
#
# Secrets consumed from sops:
# gitea/admin_password
# gitea/lfs_jwt_secret
# gitea/oauth2_jwt_secret
# gitea/internal_token
let
cfg = config.homey.gitea;
dataDir = config.homey.storage.mountPoint;
domain = homeyConfig.domain;
in
{
options.homey.gitea = {
enable = lib.mkEnableOption "Gitea Git server" // { default = true; };
image = lib.mkOption {
type = lib.types.str;
default = "docker.io/gitea/gitea:latest";
};
port = lib.mkOption {
type = lib.types.port;
default = 3000;
description = "Host port Gitea listens on (bound to 127.0.0.1).";
};
};
config = lib.mkIf cfg.enable {
# -----------------------------------------------------------------------
# Secrets
# -----------------------------------------------------------------------
sops.secrets."gitea/admin_password" = { owner = "root"; };
sops.secrets."gitea/lfs_jwt_secret" = { owner = "root"; };
sops.secrets."gitea/oauth2_jwt_secret" = { owner = "root"; };
sops.secrets."gitea/internal_token" = { owner = "root"; };
# -----------------------------------------------------------------------
# Container
# -----------------------------------------------------------------------
virtualisation.oci-containers.containers.gitea = {
image = cfg.image;
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.
environment = {
USER_UID = "1000";
USER_GID = "1000";
GITEA_CUSTOM = "/data/gitea";
# [DEFAULT]
GITEA____APP_NAME = homeyConfig.organization;
GITEA____RUN_MODE = "prod";
# [repository]
GITEA__repository__ROOT = "/data/git/repositories";
# [server]
GITEA__server__APP_DATA_PATH = "/data/gitea";
GITEA__server__DOMAIN = "git.${domain}";
GITEA__server__HTTP_PORT = toString cfg.port;
GITEA__server__ROOT_URL = "https://git.${domain}/";
GITEA__server__DISABLE_SSH = "true";
GITEA__server__START_SSH_SERVER = "false";
GITEA__server__SSH_PORT = "2222";
GITEA__server__SSH_LISTEN_PORT = "2222";
GITEA__server__LFS_START_SERVER = "true";
GITEA__server__OFFLINE_MODE = "false";
# [lfs]
GITEA__lfs__PATH = "/data/git/lfs";
# [database]
GITEA__database__DB_TYPE = "sqlite3";
GITEA__database__PATH = "/data/gitea/gitea.db";
GITEA__database__LOG_SQL = "false";
# [indexer]
GITEA__indexer__ISSUE_INDEXER_PATH = "/data/gitea/indexers/issues.bleve";
# [session]
GITEA__session__PROVIDER = "file";
GITEA__session__PROVIDER_CONFIG = "/data/gitea/sessions";
# [picture]
GITEA__picture__AVATAR_UPLOAD_PATH = "/data/gitea/avatars";
GITEA__picture__REPOSITORY_AVATAR_UPLOAD_PATH = "/data/gitea/repo-avatars";
GITEA__picture__DISABLE_GRAVATAR = "false";
# [attachment]
GITEA__attachment__PATH = "/data/gitea/attachments";
# [log]
GITEA__log__MODE = "console";
GITEA__log__LEVEL = "info";
GITEA__log__ROOT_PATH = "/data/gitea/log";
# [security]
GITEA__security__INSTALL_LOCK = "true";
GITEA__security__REVERSE_PROXY_LIMIT = "1";
GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES = "*";
# [service]
GITEA__service__DISABLE_REGISTRATION = "true";
GITEA__service__REQUIRE_SIGNIN_VIEW = "false";
GITEA__service__REGISTER_EMAIL_CONFIRM = "false";
GITEA__service__ENABLE_NOTIFY_MAIL = "false";
GITEA__service__ALLOW_ONLY_EXTERNAL_REGISTRATION = "true";
GITEA__service__ENABLE_CAPTCHA = "false";
GITEA__service__DEFAULT_ALLOW_CREATE_ORGANIZATION = "true";
GITEA__service__DEFAULT_ENABLE_TIMETRACKING = "true";
GITEA__service__NO_REPLY_ADDRESS = "noreply.localhost";
GITEA__service__ENABLE_REVERSE_PROXY_AUTHENTICATION = "true";
GITEA__service__ENABLE_REVERSE_PROXY_AUTO_REGISTRATION = "true";
# [mailer]
GITEA__mailer__ENABLED = "false";
# [openid]
GITEA__openid__ENABLE_OPENID_SIGNIN = "false";
GITEA__openid__ENABLE_OPENID_SIGNUP = "false";
# [oauth2]
GITEA__oauth2__ENABLED = "false";
# [actions]
GITEA__actions__ENABLED = "true";
};
# Secret env vars written at runtime by ExecStartPre — never in store.
environmentFiles = [ "/run/gitea-secrets.env" ];
volumes = [
"${dataDir}/gitea/data:/data"
];
extraOptions = [ "--network=homey" ];
};
# -----------------------------------------------------------------------
# ExecStartPre: write ephemeral secrets env file
# ExecStopPost: clean it up
# -----------------------------------------------------------------------
systemd.services."podman-gitea" = {
serviceConfig = {
ExecStartPre = [
(pkgs.writeShellScript "gitea-write-secrets" ''
set -euo pipefail
LFS=$(cat ${config.sops.secrets."gitea/lfs_jwt_secret".path})
OAUTH=$(cat ${config.sops.secrets."gitea/oauth2_jwt_secret".path})
TOKEN=$(cat ${config.sops.secrets."gitea/internal_token".path})
printf '%s\n' \
"GITEA__server__LFS_JWT_SECRET=$LFS" \
"GITEA__security__INTERNAL_TOKEN=$TOKEN" \
"GITEA__oauth2__JWT_SECRET=$OAUTH" \
> /run/gitea-secrets.env
chmod 600 /run/gitea-secrets.env
'')
];
ExecStopPost = [
(pkgs.writeShellScript "gitea-cleanup-secrets" ''
rm -f /run/gitea-secrets.env
'')
];
};
after = lib.mkAfter [ "mnt-data.mount" "podman-openldap.service" "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).
# -----------------------------------------------------------------------
systemd.services."gitea-admin-setup" = {
description = "Ensure Gitea admin user exists with correct password";
wantedBy = [ "multi-user.target" ];
after = [ "podman-gitea.service" ];
requires = [ "podman-gitea.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
set -euo pipefail
PASS=$(cat ${config.sops.secrets."gitea/admin_password".path})
# Wait until Gitea's HTTP endpoint is up (max 60 s)
for i in $(seq 1 60); do
if ${pkgs.curl}/bin/curl -sf http://127.0.0.1:${toString cfg.port}/ -o /dev/null; then
break
fi
sleep 1
done
# Sync password if admin exists; create if not.
if ! ${pkgs.podman}/bin/podman exec -u 1000 gitea \
gitea admin user change-password --username admin --password "$PASS" 2>/dev/null; then
${pkgs.podman}/bin/podman exec -u 1000 gitea \
gitea admin user create \
--username admin \
--password "$PASS" \
--email "admin@${domain}" \
--admin
fi
'';
};
};
}
+86
View File
@@ -0,0 +1,86 @@
{ config, lib, pkgs, homeyConfig, ... }:
# Jellyfin — media server. (Deferred — enable when ready.)
#
# Volume layout:
# <dataDir>/jellyfin/config/ → /config
# <dataDir>/media/movies/ → /data/movies
# <dataDir>/media/tvshows/ → /data/tvshows
let
cfg = config.homey.jellyfin;
dataDir = config.homey.storage.mountPoint;
domain = homeyConfig.domain;
in
{
options.homey.jellyfin = {
enable = lib.mkEnableOption "Jellyfin media server" // { default = true; };
image = lib.mkOption {
type = lib.types.str;
default = "docker.io/jellyfin/jellyfin:latest";
};
port = lib.mkOption {
type = lib.types.port;
default = 8096;
};
};
config = lib.mkIf cfg.enable {
virtualisation.oci-containers.containers.jellyfin = {
image = cfg.image;
ports = [ "127.0.0.1:${toString cfg.port}:8096" ];
environment = {
JELLYFIN_PublishedServerUrl = "https://jellyfin.${domain}";
PUID = "1000";
PGID = "1000";
};
volumes = [
"${dataDir}/jellyfin/config:/config"
"${dataDir}/media/movies:/data/movies:ro"
"${dataDir}/media/tvshows:/data/tvshows:ro"
];
extraOptions = [ "--network=homey" ];
};
systemd.services."podman-jellyfin" = {
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;
}];
};
}
+259
View File
@@ -0,0 +1,259 @@
{ config, lib, pkgs, homeyConfig, ... }:
# Nextcloud + PostgreSQL.
#
# Two containers:
# nextcloud-postgres — PostgreSQL, bound to localhost:5432
# nextcloud — Nextcloud PHP-FPM + Apache, bound to localhost:8080
#
# Volume layout:
# <dataDir>/nextcloud/db/ → /var/lib/postgresql/data (postgres)
# <dataDir>/nextcloud/html/ → /var/www/html (nextcloud)
#
# Secrets consumed from sops:
# nextcloud/admin_password
# nextcloud/postgres_password
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" // { default = true; };
image = lib.mkOption {
type = lib.types.str;
default = "docker.io/nextcloud:latest";
};
postgresImage = lib.mkOption {
type = lib.types.str;
default = "docker.io/postgres:16";
};
port = lib.mkOption {
type = lib.types.port;
default = 8080;
description = "Host port Nextcloud listens on (bound to 127.0.0.1).";
};
postgresPort = lib.mkOption {
type = lib.types.port;
default = 5432;
description = "Host port PostgreSQL listens on (bound to 127.0.0.1).";
};
};
config = lib.mkIf cfg.enable {
# -----------------------------------------------------------------------
# Secrets
# -----------------------------------------------------------------------
sops.secrets."nextcloud/admin_password" = { owner = "root"; };
sops.secrets."nextcloud/postgres_password" = { owner = "root"; };
# -----------------------------------------------------------------------
# PostgreSQL container
# -----------------------------------------------------------------------
virtualisation.oci-containers.containers.nextcloud-postgres = {
image = cfg.postgresImage;
# 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";
POSTGRES_USER = "postgres";
# Password injected via env file
};
volumes = [
"${dataDir}/nextcloud/db:/var/lib/postgresql/data"
];
extraOptions = [
"--network=homey"
"--env-file=/run/nc-postgres-secrets.env"
];
};
systemd.services."podman-nextcloud-postgres" = {
serviceConfig = {
LoadCredential = [
"nextcloud_postgres_password:${config.sops.secrets."nextcloud/postgres_password".path}"
];
ExecStartPre = [
(pkgs.writeShellScript "nc-postgres-secrets-env" ''
set -euo pipefail
install -m 600 /dev/null /run/nc-postgres-secrets.env
echo "POSTGRES_PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/nextcloud_postgres_password")" \
>> /run/nc-postgres-secrets.env
'')
];
};
postStop = "rm -f /run/nc-postgres-secrets.env";
after = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
requires = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
};
# -----------------------------------------------------------------------
# Nextcloud container
# -----------------------------------------------------------------------
virtualisation.oci-containers.containers.nextcloud = {
image = cfg.image;
# 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 = "nextcloud-postgres";
POSTGRES_DB = "nextcloud_db";
POSTGRES_USER = "postgres";
NEXTCLOUD_ADMIN_USER = "admin";
NEXTCLOUD_TRUSTED_DOMAINS = "nextcloud.${domain}";
OVERWRITEPROTOCOL = "https";
OVERWRITECLIURL = "https://nextcloud.${domain}";
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=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 = [
"nextcloud_postgres_password:${config.sops.secrets."nextcloud/postgres_password".path}"
"nextcloud_admin_password:${config.sops.secrets."nextcloud/admin_password".path}"
];
ExecStartPre = [
(pkgs.writeShellScript "nc-secrets-env" ''
set -euo pipefail
install -m 600 /dev/null /run/nc-secrets.env
echo "POSTGRES_PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/nextcloud_postgres_password")" >> /run/nc-secrets.env
echo "NEXTCLOUD_ADMIN_PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/nextcloud_admin_password")" >> /run/nc-secrets.env
'')
];
};
postStop = "rm -f /run/nc-secrets.env";
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;
}];
};
}
+136
View File
@@ -0,0 +1,136 @@
{ config, lib, pkgs, homeyConfig, ... }:
# OpenLDAP — central identity provider.
#
# Runs as a podman container (osixia/openldap).
# Listens on localhost:389 only — not exposed to the outside world.
# Authelia and other services connect to it over the container network (127.0.0.1).
#
# Volume layout on host:
# <dataDir>/openldap/etc-ldap-slapd.d/ → /etc/ldap/slapd.d (config DB)
# <dataDir>/openldap/var-lib-ldap/ → /var/lib/ldap (data)
#
# Secrets consumed from sops:
# openldap/admin_password
# openldap/config_password
# openldap/ro_password
let
cfg = config.homey.openldap;
dataDir = config.homey.storage.mountPoint;
in
{
options.homey.openldap = {
enable = lib.mkEnableOption "OpenLDAP identity provider" // { default = true; };
image = lib.mkOption {
type = lib.types.str;
default = "docker.io/osixia/openldap:latest";
description = "Container image to use for OpenLDAP.";
};
port = lib.mkOption {
type = lib.types.port;
default = 389;
description = "Host port OpenLDAP listens on (bound to 127.0.0.1).";
};
};
config = lib.mkIf cfg.enable {
# -----------------------------------------------------------------------
# Secrets
# -----------------------------------------------------------------------
sops.secrets."openldap/admin_password" = { owner = "root"; };
sops.secrets."openldap/config_password" = { owner = "root"; };
sops.secrets."openldap/ro_password" = { owner = "root"; };
# -----------------------------------------------------------------------
# Container
# -----------------------------------------------------------------------
virtualisation.oci-containers.containers.openldap = {
image = cfg.image;
ports = [ "127.0.0.1:${toString cfg.port}:389" ];
environment = {
LDAP_ORGANISATION = homeyConfig.organization;
LDAP_DOMAIN = homeyConfig.domain;
LDAP_ADMIN_USERNAME = "admin";
LDAP_READONLY_USER = "true";
# TLS disabled — traffic stays on localhost
LDAP_TLS = "false";
};
# Inject passwords from sops-managed secret files
environmentFiles = []; # we use secretFiles below instead
# sops writes secret values to files; we read them into env vars
# via a wrapper script run as ExecStartPre (see systemd override below).
# Podman's --env-file doesn't support arbitrary paths, so we use
# a secrets tmpfile approach via the systemd unit override.
volumes = [
"${dataDir}/openldap/etc-ldap-slapd.d:/etc/ldap/slapd.d"
"${dataDir}/openldap/var-lib-ldap:/var/lib/ldap"
];
extraOptions = [
"--network=homey"
"--env-file=/run/openldap-secrets.env"
];
};
# -----------------------------------------------------------------------
# Systemd override to inject sops secrets as env vars
# -----------------------------------------------------------------------
# podman containers are managed by systemd units named
# podman-<container-name>.service
systemd.services."podman-openldap" = {
serviceConfig = {
# LoadCredential stages the sops secrets into a per-invocation
# credential directory before any Exec* step, so they are available
# when ExecStartPre runs. ExecStartPre writes the env file that
# podman --env-file reads; this avoids the EnvironmentFile ordering
# race (EnvironmentFile is evaluated before ExecStartPre).
LoadCredential = [
"openldap_admin_password:${config.sops.secrets."openldap/admin_password".path}"
"openldap_config_password:${config.sops.secrets."openldap/config_password".path}"
"openldap_ro_password:${config.sops.secrets."openldap/ro_password".path}"
];
ExecStartPre = [
(pkgs.writeShellScript "openldap-secrets-env" ''
set -euo pipefail
install -m 600 /dev/null /run/openldap-secrets.env
echo "LDAP_ADMIN_PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/openldap_admin_password")" >> /run/openldap-secrets.env
echo "LDAP_CONFIG_PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/openldap_config_password")" >> /run/openldap-secrets.env
echo "LDAP_READONLY_USER_PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/openldap_ro_password")" >> /run/openldap-secrets.env
'')
];
};
# 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" "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
# -----------------------------------------------------------------------
# No firewall rule needed; common.nix only opens 22/80/443.
};
}
+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;
}];
};
}
+82
View File
@@ -0,0 +1,82 @@
{ config, lib, pkgs, homeyConfig, ... }:
# phpLDAPadmin — web UI for OpenLDAP management.
#
# Stateless container (no persistent volumes needed).
# 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
# 127.0.0.1:8081->80 so Caddy can reach it. OpenLDAP runs on the host
# network at 127.0.0.1:389; the container reaches it via the special
# host.containers.internal DNS name that podman injects automatically.
let
cfg = config.homey.phpldapadmin;
domain = homeyConfig.domain;
in
{
options.homey.phpldapadmin = {
enable = lib.mkEnableOption "phpLDAPadmin web interface" // { default = true; };
image = lib.mkOption {
type = lib.types.str;
default = "docker.io/osixia/phpldapadmin:latest";
};
port = lib.mkOption {
type = lib.types.port;
default = 8081;
description = "Host port phpLDAPadmin listens on (bound to 127.0.0.1).";
};
};
config = lib.mkIf cfg.enable {
virtualisation.oci-containers.containers.phpldapadmin = {
image = cfg.image;
environment = {
PHPLDAPADMIN_HTTPS = "false";
# "openldap" resolves to the OpenLDAP container via homey network DNS.
PHPLDAPADMIN_LDAP_HOSTS = "openldap";
};
ports = [ "127.0.0.1:${toString cfg.port}:80" ];
extraOptions = [ "--network=homey" ];
};
systemd.services."podman-phpldapadmin" = {
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;
}];
};
}
+95
View File
@@ -0,0 +1,95 @@
{ config, lib, pkgs, homeyConfig, ... }:
# Transmission — BitTorrent client. (Deferred — enable when ready.)
#
# NOTE: Transmission's web UI also runs on port 9091. To avoid clashing
# with Authelia (also 9091), this module binds Transmission to 9092.
#
# Volume layout:
# <dataDir>/transmission/config/ → /config
# <dataDir>/media/movies/ → /downloads/movies
# <dataDir>/media/tvshows/ → /downloads/tvshows
# <dataDir>/media/general/ → /downloads/general
# <dataDir>/media/complete/ → /downloads/complete
let
cfg = config.homey.transmission;
dataDir = config.homey.storage.mountPoint;
domain = homeyConfig.domain;
in
{
options.homey.transmission = {
enable = lib.mkEnableOption "Transmission torrent client" // { default = true; };
image = lib.mkOption {
type = lib.types.str;
default = "docker.io/linuxserver/transmission:latest";
};
port = lib.mkOption {
type = lib.types.port;
default = 9092;
description = "Host port for Transmission web UI (9092 to avoid clash with authelia@9091).";
};
};
config = lib.mkIf cfg.enable {
virtualisation.oci-containers.containers.transmission = {
image = cfg.image;
# 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";
TRANSMISSION_WEB_HOME = "/usr/share/transmission/web";
};
volumes = [
"${dataDir}/transmission/config:/config"
"${dataDir}/media/movies:/downloads/movies"
"${dataDir}/media/tvshows:/downloads/tvshows"
"${dataDir}/media/general:/downloads/general"
"${dataDir}/media/complete:/downloads/complete"
];
extraOptions = [ "--network=homey" ];
};
systemd.services."podman-transmission" = {
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;
}];
};
}
+114
View File
@@ -0,0 +1,114 @@
{ config, lib, pkgs, ... }:
# External hard-drive storage module.
#
# Each host sets:
# homey.storage.device = "/dev/disk/by-id/usb-WD_..."; (by-id is stable across reboots)
# homey.storage.mountPoint = "/mnt/data"; (default)
#
# All service data lives under <mountPoint>/<service-name>/, so the whole
# dataset can be browsed, backed up, or restored with plain filesystem tools.
#
# Directory layout under mountPoint:
# openldap/
# etc-ldap-slapd.d/ ← /etc/ldap/slapd.d in container
# var-lib-ldap/ ← /var/lib/ldap in container
# authelia/
# config/ ← /config in container (sqlite db etc.)
# gitea/
# data/ ← /data in container
# nextcloud/
# html/ ← /var/www/html in container
# db/ ← /var/lib/postgresql/data in postgres container
# jellyfin/
# config/
# media/
# movies/
# tvshows/
# general/
# 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
cfg = config.homey.storage;
in
{
options.homey.storage = {
device = lib.mkOption {
type = lib.types.str;
example = "/dev/disk/by-id/usb-WD_Elements_12345-0:0";
description = ''
Block device for the external hard drive.
Use /dev/disk/by-id/ paths for stable identification across reboots.
Leave empty to skip automount (useful during initial setup).
'';
default = "";
};
mountPoint = lib.mkOption {
type = lib.types.str;
default = "/mnt/data";
description = "Where the external HD is mounted. All service data lives here.";
};
fsType = lib.mkOption {
type = lib.types.str;
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 != "") {
# Mount the external drive
fileSystems."${cfg.mountPoint}" = {
device = cfg.device;
fsType = cfg.fsType;
options = [
"defaults"
"nofail" # Don't block boot if drive is absent
"noatime" # Better performance / less SD wear
"x-systemd.automount"
"x-systemd.idle-timeout=0"
];
};
# 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 -"
# 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}/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
+10
View File
@@ -0,0 +1,10 @@
# Never commit an unencrypted secrets file.
# The encrypted version (produced by `sops -e -i secrets.yaml`) IS committed.
#
# If you accidentally add the plaintext version, sops-encrypted files
# contain a `sops:` key at the top — check before committing.
#
# Paranoia: ignore any plaintext variants you might create while editing.
secrets.yaml.plaintext
secrets.yaml.bak
*.plain
+78
View File
@@ -0,0 +1,78 @@
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]
ro_password: ENC[AES256_GCM,data:82htWXdJ07tdZ81o7o9a4hizxcn39yQidD5e9PijVpo=,iv:fDT1chX4ZPIS02IMEW02haPa2IIlLFhgFOpUwD7KL50=,tag:9pSo/3vFikKQAe8jS+3Q6A==,type:str]
authelia:
jwt_secret: ENC[AES256_GCM,data:SwXd/mMsrgXItP8QZr4z9YaN1lgSSO4Cpdwl+XxFj6I=,iv:gAkyHKP5D5RGJ3X3hoh8oEJfYaFnvYxGAoKxe+G1N0I=,tag:NlfWC2pmjCYiiRn9prgl2w==,type:str]
session_secret: ENC[AES256_GCM,data:bbbgmxYLbtteuT628O+uSVeo7Gx3hI6uWVIV5l8AhtNSvXBHKb9i2NvQqJfVG8D2YR00+OW9XtreocpBuS7YJKp58cP/EgrF9x1im/8UaaifAYqD8YXZReUsBw+/PzIvqyA1K9tFd0coc8tSHJmwTwea4sf6Tc10N6j/nhNQfpQ=,iv:wrGE8XVsTINmf5505XVI7HHqA33w+SBh9lsJXD+YnwU=,tag:lvSoU+IRy8f+6VpYqzvacg==,type:str]
storage_encryption_key: ENC[AES256_GCM,data:8KnaWBTlSStdC/uI4GUOYP9DJygjfCTu62zWTU9eeVE=,iv:DNl0L2QgT1lNUDdPNm9bXcGvrLXLDtWdJ9pPgRH20C8=,tag:hVhcCl0Vrt0ZnaVGaUokSQ==,type:str]
gitea:
admin_password: ENC[AES256_GCM,data:WncwJlqb/3X5WZYgIXAu0niI0ISP0eHfmhsKDLeAvE4=,iv:JBZNZJRSHKG+cCoFNJBI+jS+/WcLueqJ/UN/7wXfK/0=,tag:f6+Lhfcxstq6pwS9xkVwqQ==,type:str]
lfs_jwt_secret: ENC[AES256_GCM,data:i05gr2ou03w0yu6/bhlJOW1huysAAPTidFEusWkhQfpDj4Pyh8LEKb09Og==,iv:aqkblyz0oIFHwzVCzlGDdQuCbsDPrfBaJMzgRTw+pYU=,tag:6gBSerOUK8Y3la/2Bg2AZQ==,type:str]
oauth2_jwt_secret: ENC[AES256_GCM,data:BVvQJCEfHPbemd1jz7MWpIRia1wfvPMGuLqoi/xUMSoQoN5RPefQnPR4Cg==,iv:JAZQUTxHZSnMEnl+BIZ1PXlznMwKuPtiPP/17rc6lSs=,tag:mUw5RuthZmZegXCtfsFNmQ==,type:str]
internal_token: ENC[AES256_GCM,data:gnOebJbRsh2Cues9WjGQp4rWa6OuE3xSnby9jc3Hk8ywvpL7CNWmlGW7zmmOyDAfIKfm8kf1FxotWLXtGZDretzdbMRM9c6gkwSJf5MCsdm27Er+IRKS/QFBuvLSTEH0,iv:aVgRvs3T3zCg+AV/BKUXQyZDKvunHvXsdfr9sqo2cI0=,tag:U9aP+N0CWyRQ/xJ27Vo2mw==,type:str]
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]
cloudflare:
api_token: ENC[AES256_GCM,data:Erzom4DKiam9SHGLdT3CQRkuT5kkhcuUaLwTbt2P5pPjr1V56p733KB1kHheO/PZ+TRsZg0=,iv:eO+ryffyoSkzAgUXe0MH+FKitgHCQ3ychLWEAShAd9o=,tag:2Z8Io0ylpAI9rws5NXCvIw==,type:str]
tunnel_token: ENC[AES256_GCM,data:AFlD990L8l1Rh9i8wdyXwwyolrlw2ln1uuyCTiT7k1FVc2JjTOrgc8HiBIpxM40eqFGEnzDMs87tgzh1Pl8UThwV7WcLFWHMvtYHNez/F2+THknnW+ZinJbZnNSngicrhRIoNFhjQgjaR1LaS4kcNbkGBi655bl2uqNXoUpNThUKGAlZ4KwByEzK4B7QtCgAkqxQEFehtjdj41p8r58ViJTogXQKXmDYLjbqo8nPmUGSaiR/VCY7Gw==,iv:q755yc6wTMCuqHLvfHOZzBf3KoG4vcw431stQA78Bjs=,tag:Zt/LPG41HxSg2gSQOTC/6A==,type:str]
restic:
password: ENC[AES256_GCM,data:X/pWmwakzQzRpSaY+T/kOqdrtXyvGPa3UQc/iIQFFAzUS1jHR9IvzW7KYdm/3IKXPBlZzPkWDsoRvVAChPwfQA==,iv:4+RD9UD5daMP04ixeagxbCNkTdOPx+BqfSOheh88OUU=,tag:SmrIz7jvbM74y5RBX0nCbA==,type:str]
s3_access_key_id: ENC[AES256_GCM,data:XxElPQF28ThfYuiF4jQu7BiS8sh+c4V4ng==,iv:aLUIYnRGqFLYwlP3nFwDY5uvy8pXtX5QMKLMfRTxdNk=,tag:CscNNWnpvLuxw1DPK92GyA==,type:str]
s3_secret_access_key: ENC[AES256_GCM,data:9ZWyhGJm4t2benDrLmnyQ9ZA5Jjl6l+pza1VmymTlw==,iv:xYsG6QlxXhQNO9szmsycxP6lT0cFF7lq3iNg6j+ED0E=,tag:wOJT4Vg3DuNFWTtx3QS9IQ==,type:str]
wifi:
psk: ENC[AES256_GCM,data:znk9Wr+vsntzbJ3H0TORUrAiDw==,iv:wbl8fUuKlgTqhajwjlTgFS7ijaTwXBFPRW2AmtiTklg=,tag:IK4oe8cJcccPaQ0V0NlncQ==,type:str]
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
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBZSGpPdTBIaTZ0TER2NkNO
U2ZPKzNwelJHUEpyU2VBSmd5Yjd5bEtibFZzCjlZZTRFa2FHN1JtK2JUSm51a3By
QmFyV1ZZNWI0OGJVM1NNZERjd2hWcDAKLS0tIG9VSVFTSTJBMjk5ZzBSL0ZQV2Ev
QXVkRlJHeW52NFZFYnVwaW8ycytDSzAKZt+p5QnZKcEOBghHA2xkH6d7NObtTEoE
wMwCYasnBHzy2unXRbZq/4v9NQ5HJd0Nu1iqbqKgIxMCD3dnxEdK7g==
-----END AGE ENCRYPTED FILE-----
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: |-
-----BEGIN PGP MESSAGE-----
hQIMAwdqopdXmgBkAQ//TzlOz/QYwiYAc6NGo2O8YJi5ERkS1+0qNpptD51g2dLF
V4iUx7400tc6IEEhZ0N54R7AO5mSX55XCWJxVQDTRJXmLDHcOR+9vThb4H571XBa
3mcmE8Dj3sN3a1K2RwajZJXl1o5d1oNvWJ83pVsCnrJegi92+GmvmOt4QZ1l5aCf
TGYgUXAz1RreqsGKjJsSXscZOvRnp+cslJ9xY8OXeKLbQvLg0Z3pSQG2QGgDmHPD
fRxYnlc2lKe32uoBlD2LXK+NoBnrRYEVrrwGf6P5GpTDpJbc0bR5BiRIYDhPxtqK
SiXWHaebg73+kbWdcm+2kiac6hW6xW/iJL4eFBT1v/NgZmNoQCnJOIA7v2vjv9vl
81Y1FM5MpIfwNiTwkJjVsgM2tHkANlbixBHJdbjlnKpo9pTS7RuttWtdCmFdmXr0
oiuKDDRPVGvykPqvHzvCLf/k5j1nYvqvb7Wn2Bycc5kIOjFYEDEeM0r37vOX9nDM
SW1HtaWoZuVceTJEit0WR63kmXYLZ/AHvXcmq6ucUw8Fmw79n+7brQiX2RMtCK1E
pfrNey3EEqvPs2RDd6XdF4/73CdMDN5s3xiFAIfLGeZ6h0Eq27fazSZNmdh4MGYb
Wzj81ur8dimoSP+W9eW1TjIfY4deH5FRnN19ldKPuHdazvikWWsdN05evNlSZsDS
XgHafkhKiNSNZLw/VVzf+1SDLhN1H5QoxZ2YsxCc+psd5CFxU1x3llIDg4hXScAR
OQvRR1VjQOLFCwdFErW7sd6nQlkS7LnAskgT/0ZJGsxfkh1gJO3YqDnEKF7+P9w=
=zKa+
-----END PGP MESSAGE-----
fp: 076AA297579A0064
unencrypted_suffix: _unencrypted
version: 3.12.2
+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
'')
];
}
-27
View File
@@ -1,27 +0,0 @@
---
{{- define "homey.lookuporgensecret" -}}
{{- $secretObj := (lookup "v1" "Secret" .Release.Namespace .secretname ) | default dict -}}
{{- $secretData := (get $secretObj "data") | default dict -}}
{{- $ret := (get $secretData "password" | b64dec ) | default (randAlphaNum 32 ) -}}
{{ $ret -}}
{{- end -}}
---
{{- define "homey.randomsecret"}}
apiVersion: v1
kind: Secret
metadata:
name: {{ (replace "\"" "" .secretname ) }}
type: Opaque
data:
password: {{ .secretval | b64enc | quote }}
{{- end }}
---
{{- define "homey.randHex"}}
{{- $result := "" }}
{{- range $i := until . }}
{{- $rand_hex_char := mod (randNumeric 4 | atoi) 16 | printf "%x" }}
{{- $result = print $result $rand_hex_char }}
{{- end }}
{{- $result }}
{{- end -}}
---
-558
View File
@@ -1,558 +0,0 @@
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: ldap-pvc
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 100Mi
storageClassName: longhorn
---
{{- $_ := set $ "homey_openldap_admin" (include "homey.lookuporgensecret" (merge (dict "secretname" "openldap-admin") $))}}
{{ include "homey.randomsecret" (merge (dict "secretname" "openldap-admin" "secretval" .homey_openldap_admin) $) }}
# ---
{{- $_ := set $ "homey_openldap_config" (include "homey.lookuporgensecret" (merge (dict "secretname" "openldap-config") $))}}
{{ include "homey.randomsecret" (merge (dict "secretname" "openldap-config" "secretval" .homey_openldap_config) $) }}
# ---
{{- $_ := set $ "homey_openldap_ro" (include "homey.lookuporgensecret" (merge (dict "secretname" "openldap-ro") $))}}
{{ include "homey.randomsecret" (merge (dict "secretname" "openldap-ro" "secretval" .homey_openldap_ro) $) }}
---
{{- $_ := set $ "homey_authelia_jwt" (include "homey.lookuporgensecret" (merge (dict "secretname" "authelia-jwt") $))}}
{{ include "homey.randomsecret" (merge (dict "secretname" "authelia-jwt" "secretval" .homey_authelia_jwt) $) }}
---
{{- $_ := set $ "homey_authelia_session" (include "homey.lookuporgensecret" (merge (dict "secretname" "authelia-session") $))}}
{{ include "homey.randomsecret" (merge (dict "secretname" "authelia-session" "secretval" .homey_authelia_session) $) }}
---
{{- $_ := set $ "homey_authelia_encryption_key" (include "homey.lookuporgensecret" (merge (dict "secretname" "authelia-encryption-key") $))}}
{{ include "homey.randomsecret" (merge (dict "secretname" "authelia-encryption-key" "secretval" .homey_authelia_encryption_key) $) }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: openldap
labels:
app.kubernetes.io/name: openldap
spec:
selector:
matchLabels:
app.kubernetes.io/name: openldap
replicas: 1
template:
metadata:
labels:
app.kubernetes.io/name: openldap
spec:
# securityContext:
# fsGroup: 0
containers:
- name: openldap
image: osixia/openldap
env:
- name: LDAP_ORGANISATION
value: {{ .Values.homey.organization }}
- name: LDAP_DOMAIN
value: {{ .Values.homey.url | quote}}
- name: LDAP_ADMIN_USERNAME
value: "admin"
- name: LDAP_READONLY_USER
value: "true"
- name: LDAP_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
key: password
name: openldap-admin
- name: LDAP_CONFIG_PASSWORD
valueFrom:
secretKeyRef:
key: password
name: openldap-config
- name: LDAP_READONLY_USER_PASSWORD
valueFrom:
secretKeyRef:
key: password
name: openldap-ro
ports:
- name: tcp-ldap
containerPort: 389
- name: ssl-ldap
containerPort: 636
volumeMounts:
- mountPath: /etc/ldap/slapd.d
subPath: openldap/etc/ldap/slapd.d
name: openldap-volume
- mountPath: /var/lib/ldap
subPath: openldap/var/lib/ldap
name: openldap-volume
volumes:
- name: openldap-volume
persistentVolumeClaim:
claimName: ldap-pvc
---
apiVersion: v1
kind: Service
metadata:
name: openldap
labels:
app.kubernetes.io/name: openldap
spec:
type: ClusterIP
ports:
- name: tcp-ldap
port: 389
targetPort: tcp-ldap
- name: ssl-ldap
port: 636
targetPort: ssl-ldap
selector:
app.kubernetes.io/name: openldap
---
apiVersion: v1
kind: ConfigMap
metadata:
name: authelia-conf
data:
configuration.yml: |-
{{ tpl (.Files.Get "files/authelia-config.yaml" | indent 4) . }}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: authelia-pvc
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 100Mi
storageClassName: longhorn
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: authelia
labels:
app.kubernetes.io/name: authelia
spec:
selector:
matchLabels:
app.kubernetes.io/name: authelia
replicas: 1
template:
metadata:
labels:
app.kubernetes.io/name: authelia
spec:
enableServiceLinks: false
containers:
- name: authelia
image: authelia/authelia
imagePullPolicy: "IfNotPresent"
env:
- name: TZ
value: "Jerusalem/Israel"
ports:
- name: tcp
containerPort: 9091
volumeMounts:
- mountPath: /config/configuration.yml
name: authelia-conf
subPath: configuration.yml
readOnly: true
- mountPath: /config
subPath: authelia/config
name: authelia-volume
volumes:
- name: authelia-conf
configMap:
name: authelia-conf
items:
- key: configuration.yml
path: configuration.yml
- name: authelia-volume
persistentVolumeClaim:
claimName: authelia-pvc
---
apiVersion: v1
kind: Service
metadata:
name: authelia
labels:
app.kubernetes.io/name: authelia
spec:
type: ClusterIP
ports:
- name: tcp
port: 9091
targetPort: tcp
selector:
app.kubernetes.io/name: authelia
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: authelia
spec:
ingressClassName: {{ .Values.homey.ingress_class }}
tls:
- hosts:
- auth.{{ .Values.homey.url }}
secretName: {{ .Values.homey.certname }}
rules:
- host: auth.{{ .Values.homey.url }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: authelia
port:
number: 9091
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: gitea-pvc
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 5Gi
storageClassName: longhorn
---
{{- $_ := set $ "homey_gitea_admin_pass" (include "homey.lookuporgensecret" (merge (dict "secretname" "gitea-admin-pass") $))}}
{{ include "homey.randomsecret" (merge (dict "secretname" "gitea-admin-pass" "secretval" .homey_gitea_admin_pass) $) }}
---
{{- $_ := set $ "homey_gitea_lfs_jwt_secret" (include "homey.lookuporgensecret" (merge (dict "secretname" "gitea-lfs-jwt-secret") $))}}
{{ include "homey.randomsecret" (merge (dict "secretname" "gitea-lfs-jwt-secret" "secretval" .homey_gitea_lfs_jwt_secret) $) }}
---
{{- $_ := set $ "homey_gitea_oauth2_jwt_secret" (include "homey.lookuporgensecret" (merge (dict "secretname" "gitea-oauth2-jwt-secret") $))}}
{{ include "homey.randomsecret" (merge (dict "secretname" "gitea-oauth2-jwt-secret" "secretval" .homey_gitea_oauth2_jwt_secret) $) }}
---
apiVersion: v1
kind: Secret
metadata:
name: gitea-random-internal-token
annotations:
"helm.sh/resource-policy": "keep"
type: Opaque
data:
{{- $secretObj := (lookup "v1" "Secret" .Release.Namespace "gitea-random-internal-token") | default dict -}}
{{- $secretData := (get $secretObj "data") | default dict -}}
{{- $pass := (get $secretData "password") | default (randAlphaNum 100 | b64enc) -}}
{{- $_ := set $ "homey_gitea_random_internal_token" ($pass | b64dec) }}
password: {{ $pass | quote }}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: gitea-conf
data:
app.ini: |-
{{ tpl (.Files.Get "files/gitea-app.ini" | indent 4) . }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: gitea
spec:
replicas: 1
selector:
matchLabels:
app: gitea
template:
metadata:
labels:
app: gitea
spec:
containers:
- name: gitea
image: gitea/gitea:latest
ports:
- containerPort: 3000
name: http
volumeMounts:
- name: gitea-persistent-storage
mountPath: /data
subPath: gitea/gitea/data
- name: gitea-conf
mountPath: /data/gitea/conf/app.ini
subPath: app.ini
readOnly: true
# startProbe:
# httpGet:
# path: /
# port: 3000
# initialDelaySeconds: 15
# lifecycle:
# postStart:
# exec:
# {{- $gitea_cmd := (printf "gitea admin auth add-ldap --name ldap --security-protocol unencrypted --host ldap --port 389 --user-search-base ou=users,%s --user-filter \\\"(&(objectClass=inetOrgPerson)(|(uid=%[1]s)(mail=kk[1]s)))\\\" --email-attribute mail --bind-dn=\\\"cn=readonly,%s\\\" --bind-password=\\\"%s\\\"" ( .Values.homey.url | replace "." ",dc=" | printf "dc=%s " | trim) ( .Values.homey.url | replace "." ",dc=" | printf "dc=%s " | trim) (.homey_openldap_ro | replace "\"" ""))}}
# command: ["/bin/sh", "-c", "{{$gitea_cmd}}"]
volumes:
- name: gitea-persistent-storage
persistentVolumeClaim:
claimName: gitea-pvc
- name: gitea-conf
configMap:
name: gitea-conf
items:
- key: app.ini
path: app.ini
---
apiVersion: v1
kind: Service
metadata:
name: gitea-svc
spec:
selector:
app: gitea
ports:
- name: http-port
protocol: TCP
port: 3000
targetPort: http
selector:
app: gitea
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gitea-ingress
spec:
ingressClassName: {{ .Values.homey.ingress_class }}
tls:
- hosts:
- git.{{ .Values.homey.url }}
secretName: {{ .Values.homey.certname }}
rules:
- host: git.{{ .Values.homey.url }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: gitea-svc
port:
number: 3000
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: davical-postgres-pvc
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 1Gi
storageClassName: longhorn
---
{{- $_ := set $ "homey_davical_postgres_pass" (include "homey.lookuporgensecret" (merge (dict "secretname" "davical-postgres-pass") $))}}
{{ include "homey.randomsecret" (merge (dict "secretname" "davical-postgres-pass" "secretval" .homey_davical_postgres_pass) $) }}
---
# apiVersion: extensions/v1beta1
apiVersion: v1
kind: ConfigMap
metadata:
name: davical-postgres-config
labels:
app: davical-postgres
data:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: davical-postgres
labels:
app: davical-postgres
spec:
replicas: 1
selector:
matchLabels:
app: davical-postgres
template:
metadata:
labels:
app: davical-postgres
name: davical-postgres
spec:
containers:
- name: davical-postgres
image: postgres:10.4
imagePullPolicy: "IfNotPresent"
ports:
- containerPort: 5432
envFrom:
- configMapRef:
name: davical-postgres-config
env:
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: davical-postgres-pass
key: password
volumeMounts:
- mountPath: /var/lib/postgresql/data
subPath: data
name: davical-postgredb
volumes:
- name: davical-postgredb
persistentVolumeClaim:
claimName: davical-postgres-pvc
---
apiVersion: v1
kind: Service
metadata:
name: davical-postgres
labels:
app: davical-postgres
spec:
ports:
- port: 5432
selector:
app: davical-postgres
---
{{- $_ := set $ "homey_davical_admin_pass" (include "homey.lookuporgensecret" (merge (dict "secretname" "davical-admin-pass") $))}}
{{ include "homey.randomsecret" (merge (dict "secretname" "davical-admin-pass" "secretval" .homey_davical_admin_pass) $) }}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: davical-conf
data:
config.php: |-
{{ tpl (.Files.Get "files/davical-config.php" | indent 4) . }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: davical
labels:
app: davical
spec:
replicas: 1
selector:
matchLabels:
app: davical
template:
metadata:
labels:
app: davical
spec:
containers:
- name: davical
image: anerisgreat/davical-multiarch-docker:latest
imagePullPolicy: "Always"
ports:
- containerPort: 80
name: dav
env:
- name: PGHOST
value: "davical-postgres"
- name: PGUSER
value: "postgres"
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: davical-postgres-pass
key: password
- name: PGDATABASE
value: "davical"
- name: PGPORT
value: "5432"
- name: HOST_NAME
value:
"dav.{{ .Values.homey.url }}"
- name: DAVICAL_ADMIN_PASS
valueFrom:
secretKeyRef:
name: davical-admin-pass
key: password
- name: ROOT_PGUSER
value: "postgres"
- name: ROOT_PGPASSWORD
valueFrom:
secretKeyRef:
name: davical-postgres-pass
key: password
- name: RUN_MIGRATIONS_AT_STARTUP
value: "true"
volumeMounts:
- name: davical-conf
mountPath: /etc/davical/config.php
subPath: config.php
readOnly: true
volumes:
- name: davical-conf
configMap:
name: davical-conf
items:
- key: config.php
path: config.php
---
apiVersion: v1
kind: Service
metadata:
name: davical
spec:
selector:
app: davical
ports:
- name: dav
protocol: TCP
port: 80
targetPort: 80
selector:
app: davical
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: davical
annotations:
kubernetes.io/ingress.allow-http: "false"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
nginx.ingress.kubernetes.io/auth-method: GET
nginx.ingress.kubernetes.io/auth-url: http://authelia.{{ .Release.Namespace }}.svc.cluster.local:9091/api/verify
nginx.ingress.kubernetes.io/auth-signin: https://auth.{{ .Values.homey.url }}?rm=$request_method
nginx.ingress.kubernetes.io/auth-response-headers: Remote-User,Remote-Name,Remote-Groups,Remote-Email
nginx.ingress.kubernetes.io/auth-snippet: |
proxy_set_header X-Forwarded-Method $request_method;
auth_request_set $user $upstream_http_remote_user;
auth_request_set $groups $upstream_http_remote_groups;
auth_request_set $name $upstream_http_remote_name;
auth_request_set $email $upstream_http_remote_email;
proxy_set_header Remote-User $user;
proxy_set_header Remote-Fullname $name;
proxy_set_header Remote-Email $email;
proxy_set_header Redirect-Remote-User $user;
proxy_set_header Redirect-Remote-Fullname $name;
proxy_set_header Redirect-Remote-Email $email;
spec:
ingressClassName: {{ .Values.homey.ingress_class }}
tls:
- hosts:
- dav.{{ .Values.homey.url }}
secretName: {{ .Values.homey.certname }}
rules:
- host: dav.{{ .Values.homey.url }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: davical
port:
number: 80
---
-80
View File
@@ -1,80 +0,0 @@
---
#_PHPADMIN________
apiVersion: apps/v1
kind: Deployment
metadata:
name: phpldapadmin
labels:
app: phpldapadmin
spec:
replicas: 1
selector:
matchLabels:
app: phpldapadmin
template:
metadata:
labels:
app: phpldapadmin
spec:
containers:
- env:
- name: PHPLDAPADMIN_HTTPS
value: "false"
- name: PHPLDAPADMIN_LDAP_HOSTS
value: ldap://openldap:389
image: osixia/phpldapadmin
name: phpldapadmin
ports:
- containerPort: 80
name: http
restartPolicy: Always
---
apiVersion: v1
kind: Service
metadata:
name: phpldapadmin
spec:
ports:
- port: 80
targetPort: 80
name: http
selector:
app: phpldapadmin
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: phpldapadmin
annotations:
kubernetes.io/ingress.allow-http: "false"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
nginx.ingress.kubernetes.io/auth-method: GET
nginx.ingress.kubernetes.io/auth-url: http://authelia.{{ .Release.Namespace }}.svc.cluster.local:9091/api/verify
nginx.ingress.kubernetes.io/auth-signin: https://auth.{{ .Values.homey.url }}?rm=$request_method
nginx.ingress.kubernetes.io/auth-response-headers: Remote-User,Remote-Name,Remote-Groups,Remote-Email
nginx.ingress.kubernetes.io/auth-snippet: |
proxy_set_header X-Forwarded-Method $request_method;
auth_request_set $user $upstream_http_remote_user;
auth_request_set $groups $upstream_http_remote_groups;
auth_request_set $name $upstream_http_remote_name;
auth_request_set $email $upstream_http_remote_email;
proxy_set_header X-Webauth-User $user;
proxy_set_header X-Webauth-Fullname $name;
proxy_set_header X-Webauth-Email $email;
spec:
ingressClassName: {{ .Values.homey.ingress_class }}
tls:
- hosts:
- ldapadmin.{{ .Values.homey.url }}
secretName: {{ .Values.homey.certname }}
rules:
- host: ldapadmin.{{ .Values.homey.url }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: phpldapadmin
port:
number: 80
-69
View File
@@ -1,69 +0,0 @@
replicaCount: 1
homeyNamespace: homey
imagePullSecrets: []
nameOverride: "homey-app"
fullnameOverride: "homey-chart"
serviceAccount:
# Specifies whether a service account should be created
create: true
# Annotations to add to the service account
annotations: {}
# The name of the service account to use.
# If not set and create is true, a name is generated using the fullname template
name: "homey"
podAnnotations: {}
podSecurityContext: {}
# fsGroup: 2000
securityContext: {}
# capabilities:
# drop:
# - ALL
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 1000
service:
type: ClusterIP
port: 80
resources: {} # We usually recommend not to specify default resources and to leave this as a conscious
# choice for the user. This also increases chances charts run on environments with little
# resources, such as Minikube. If you do want to specify resources, uncomment the following
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
# limits:
# cpu: 100m
# memory: 128Mi
# requests:
# cpu: 100m
# memory: 128Mi
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 100
targetCPUUtilizationPercentage: 80
# targetMemoryUtilizationPercentage: 80
nodeSelector: {}
tolerations: []
affinity: {}
homey:
organization: "Zakobar Home Server"
storage:
ip: "10.0.0.100"
storageCapacity: 30Gi
mediaStorageCapacity: 30Gi
url: zakobar.com
ip: 10.0.0.100
certname: zakobarcert
ingress_class: nginx