Compare commits
22 Commits
0464092af1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 261cf892dd | |||
| 08e8b5edbe | |||
| 171ff2f3bc | |||
| 42d91012c1 | |||
| d2793904f4 | |||
| 09052e8aec | |||
| af744e819c | |||
| 0e54760e34 | |||
| d6aa39ff04 | |||
| d49f0161ca | |||
| a7099e7d56 | |||
| 5e8d5f575a | |||
| 5e82ca5fe0 | |||
| 0b73d493d8 | |||
| 05619d12fc | |||
| e2ff0eb428 | |||
| 2f0d0b5e4c | |||
| d1948df47e | |||
| 138d6d8a6b | |||
| 9ac576c043 | |||
| 5264bdbf4f | |||
| 3655bbc489 |
+3
-1
@@ -1,2 +1,4 @@
|
||||
charts
|
||||
*.lock
|
||||
.agent-shell
|
||||
result
|
||||
.direnv
|
||||
|
||||
+23
@@ -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
|
||||
@@ -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)
|
||||
- `10–19` — blanket bypasses (e.g. ntfy)
|
||||
- `20–49` — admin-only two_factor + deny pairs
|
||||
- `50–64` — open one_factor services
|
||||
- `65–79` — 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.
|
||||
@@ -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
@@ -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
@@ -2,91 +2,437 @@
|
||||
|
||||
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
|
||||
helm upgrade --install homey . -n homey
|
||||
sops secrets/secrets.yaml
|
||||
#+end_src
|
||||
|
||||
Opens your editor with the decrypted file. Save and quit to re-encrypt.
|
||||
|
||||
The WiFi password entry must use the =wifi_psk== prefix so wpa_supplicant
|
||||
can look up the value by name:
|
||||
|
||||
#+begin_src yaml
|
||||
wifi/psk: "wifi_psk=YourActualWifiPassword"
|
||||
#+end_src
|
||||
|
||||
** Phase 1 — Bootstrap image (flash this first)
|
||||
|
||||
The full =pi-main= config requires sops secrets, which require an age key
|
||||
on the Pi — but the age key doesn't exist until after first boot. To
|
||||
break the chicken-and-egg problem, flash a minimal bootstrap image first.
|
||||
|
||||
Before building, fill in the WiFi password in =flake.nix= in the
|
||||
=pi-main-bootstrap= config (search for =WIFI_PASSWORD_HERE=):
|
||||
|
||||
#+begin_src nix
|
||||
networks."Zakobar".psk = "your-actual-wifi-password";
|
||||
#+end_src
|
||||
|
||||
Build the bootstrap SD image (requires =aarch64-linux= build capability —
|
||||
either =boot.binfmt.emulatedSystems = ["aarch64-linux"]= on your
|
||||
workstation, or an aarch64 remote builder):
|
||||
|
||||
#+begin_src bash
|
||||
nix build .#nixosConfigurations.pi-main-bootstrap.config.system.build.sdImage \
|
||||
--system aarch64-linux
|
||||
#+end_src
|
||||
|
||||
Find your SD card device, then flash (double-check =/dev/sdX=!):
|
||||
|
||||
#+begin_src bash
|
||||
lsblk
|
||||
|
||||
zstdcat result/sd-image/nixos-sd-image-*.img.zst | \
|
||||
sudo dd of=/dev/sdX bs=4M status=progress conv=fsync
|
||||
#+end_src
|
||||
|
||||
The Pi will boot at =192.168.1.100=, connect to =Zakobar= WiFi, and accept
|
||||
SSH connections with your key. No services run yet.
|
||||
|
||||
** Phase 2 — Generate age key and re-encrypt secrets
|
||||
|
||||
#+begin_src bash
|
||||
# SSH into the Pi
|
||||
ssh admin@192.168.1.100
|
||||
|
||||
# Generate the age key
|
||||
sudo age-keygen -o /var/lib/sops-nix/key.txt
|
||||
|
||||
# Print the public key — copy it
|
||||
sudo age-keygen -y /var/lib/sops-nix/key.txt
|
||||
#+end_src
|
||||
|
||||
Back on your workstation, add the public key to =secrets/.sops.yaml=
|
||||
alongside the existing PGP key:
|
||||
|
||||
#+begin_src yaml
|
||||
keys:
|
||||
- &pi_main age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
creation_rules:
|
||||
- path_regex: secrets/secrets.yaml$
|
||||
key_groups:
|
||||
- pgp:
|
||||
- 076AA297579A0064
|
||||
age:
|
||||
- *pi_main
|
||||
#+end_src
|
||||
|
||||
Then re-encrypt so the Pi can decrypt its own secrets:
|
||||
|
||||
#+begin_src bash
|
||||
sops updatekeys secrets/secrets.yaml
|
||||
#+end_src
|
||||
|
||||
** Phase 3 — Deploy the full config
|
||||
|
||||
#+begin_src bash
|
||||
nixos-rebuild switch \
|
||||
--flake .#pi-main \
|
||||
--target-host admin@192.168.1.100 \
|
||||
--build-host admin@192.168.1.100 \
|
||||
--use-remote-sudo
|
||||
#+end_src
|
||||
|
||||
The Pi builds its own config natively (no cross-compilation). sops-nix
|
||||
will now decrypt all secrets and start all services.
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
get-secret-val.sh homey openldap-admin password
|
||||
- =openldap/= — LDAP database and config
|
||||
- =authelia/= — Authelia config and state
|
||||
- =gitea/= — Gitea repositories and data
|
||||
- =nextcloud/= — Nextcloud files + a =pg_dump= of the database
|
||||
- =jellyfin/= — Jellyfin metadata (media files are excluded — re-downloadable)
|
||||
- =transmission/= — Torrent client config
|
||||
|
||||
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.
|
||||
Select RDN = User Name (uid) (FROM DROP DOWN MENU)
|
||||
UID = USERNAME, that is what is important. (In PHPLdapAdmin it is under User Name)
|
||||
Run on the Pi after the first deploy:
|
||||
|
||||
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>
|
||||
SSH Server Port: 2222
|
||||
Gitea Base URL: http://git.<YOUR URL>
|
||||
** Configuration
|
||||
|
||||
Then add Administrator Account Settings:
|
||||
Repository URL and credentials are set per-host:
|
||||
|
||||
Administrator Username: gitea-admin
|
||||
Password: from gitea-admin-pass
|
||||
Email address must be populated
|
||||
#+begin_src nix
|
||||
# hosts/pi-main/default.nix
|
||||
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
|
||||
Host: openldap
|
||||
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
|
||||
# Restore latest snapshot to /mnt/data
|
||||
restic -r s3:https://... restore latest --target /mnt/data
|
||||
|
||||
* 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
|
||||
This fucking sucks
|
||||
https://gist.github.com/james-d-elliott/5152d27c0781aee856a3383f1284998e
|
||||
* Disaster Recovery
|
||||
|
||||
* EVERYTHING
|
||||
https://www.talkingquickly.co.uk/gitea-sso-with-keycloak-openldap-openid-connect
|
||||
Full recovery from total host failure (dead Pi, dead SD card), assuming this
|
||||
git repo and your workstation PGP key (=076AA297579A0064=) survive.
|
||||
|
||||
* DRONE AND GITEA
|
||||
?
|
||||
https://dev.to/ruanbekker/self-hosted-cicd-with-gitea-and-drone-ci-200l
|
||||
** Step 1 — Flash and boot a new Pi
|
||||
|
||||
* 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
|
||||
|
||||
@@ -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=
|
||||
@@ -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"~ |
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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 +0,0 @@
|
||||
kubectl get secret -n $1 $2 --template={{.data.$3}} | base64 -d | xclip -selection c
|
||||
@@ -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 ];
|
||||
}
|
||||
@@ -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
|
||||
# '';
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
|
||||
'')
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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 ];
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}];
|
||||
};
|
||||
}
|
||||
@@ -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`
|
||||
@@ -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;
|
||||
}];
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}];
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}];
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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" ];
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}];
|
||||
};
|
||||
}
|
||||
@@ -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" ];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}];
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}];
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}];
|
||||
};
|
||||
}
|
||||
@@ -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" ];
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}];
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
{pkgs} @ args: {
|
||||
default = import ./defaultShell.nix args;
|
||||
}
|
||||
@@ -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
|
||||
'')
|
||||
];
|
||||
}
|
||||
@@ -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 -}}
|
||||
---
|
||||
@@ -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
|
||||
---
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user