Compare commits
10 Commits
0464092af1
...
5e82ca5fe0
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e82ca5fe0 | |||
| 0b73d493d8 | |||
| 05619d12fc | |||
| e2ff0eb428 | |||
| 2f0d0b5e4c | |||
| d1948df47e | |||
| 138d6d8a6b | |||
| 9ac576c043 | |||
| 5264bdbf4f | |||
| 3655bbc489 |
@@ -1,2 +1,4 @@
|
||||
charts
|
||||
*.lock
|
||||
.agent-shell
|
||||
result
|
||||
|
||||
+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,287 @@
|
||||
# AGENTS.md
|
||||
|
||||
Self-hosted home server configuration for a Raspberry Pi 4 (8 GB), managed
|
||||
entirely through NixOS. Services run as podman containers under systemd.
|
||||
Remote access is via Cloudflare Tunnel; local access goes through Caddy
|
||||
with Let's Encrypt TLS (DNS-01, Cloudflare API).
|
||||
|
||||
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)
|
||||
services/
|
||||
openldap.nix # OpenLDAP — central identity provider
|
||||
authelia.nix # Authelia — SSO gateway
|
||||
gitea.nix # Gitea — Git server
|
||||
nextcloud.nix # Nextcloud + PostgreSQL
|
||||
phpldapadmin.nix # phpLDAPadmin — LDAP web UI
|
||||
jellyfin.nix # Jellyfin — media server (disabled by default)
|
||||
transmission.nix # Transmission — torrent client (disabled by default)
|
||||
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 |
|
||||
|---------|-----|------|
|
||||
| Authelia | `auth.zakobar.com` | Public (it is the auth portal) |
|
||||
| Gitea | `git.zakobar.com` | Gitea-native (LDAP) |
|
||||
| Nextcloud | `nextcloud.zakobar.com` | Nextcloud-native |
|
||||
| phpLDAPadmin | `ldapadmin.zakobar.com` | Authelia two_factor, admins only |
|
||||
| Jellyfin | `jellyfin.zakobar.com` | Jellyfin-native |
|
||||
| Transmission | `torrent.zakobar.com` | Authelia two_factor, admins only |
|
||||
|
||||
Internal ports (all bound to `127.0.0.1`):
|
||||
|
||||
| Container | Port |
|
||||
|-----------|------|
|
||||
| openldap | 389 |
|
||||
| authelia | 9091 |
|
||||
| gitea | 3000 |
|
||||
| nextcloud | 8080 |
|
||||
| nextcloud-postgres | 5432 |
|
||||
| phpldapadmin | 8081 |
|
||||
| jellyfin | 8096 |
|
||||
| transmission | 9092 (not 9091 — avoids clash with authelia) |
|
||||
|
||||
## 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
|
||||
restic-cache/ → restic local cache
|
||||
```
|
||||
|
||||
The drive device path is set per-host in `hosts/<name>/default.nix` via
|
||||
`homey.storage.device`. Use a `/dev/disk/by-id/` path for stability.
|
||||
|
||||
## 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:
|
||||
```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 read domain/org from hardcoded 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 `EnvironmentFile`.
|
||||
Clean it up in `postStop`.
|
||||
|
||||
5. **`--network=host`** — all containers use host networking for simplicity on
|
||||
a single-node setup. Services communicate via `127.0.0.1:<port>`.
|
||||
|
||||
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.
|
||||
|
||||
### Adding a New Service
|
||||
|
||||
1. Create `modules/services/<name>.nix` following the existing module pattern.
|
||||
2. Add `homey.<name>.enable = false` as the default option.
|
||||
3. Import the new module in `flake.nix` (in the `modules` list inside `mkHost`).
|
||||
4. Enable it in `hosts/pi-main/default.nix`.
|
||||
5. Add a Caddy virtual host block in `modules/caddy.nix`.
|
||||
6. Add the service data directory to `modules/storage.nix` `tmpfiles.rules`.
|
||||
7. Add the data path to the `paths` list in `modules/backup.nix`.
|
||||
8. Add any new secrets to `secrets/secrets.yaml` (plaintext) and document them.
|
||||
|
||||
### 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.
|
||||
|
||||
- [ ] **`hosts/pi-main/default.nix` — fill in real values**:
|
||||
- SSH public key in `users.users.admin.openssh.authorizedKeys.keys`
|
||||
- External HD device path in `homey.storage.device`
|
||||
- Backup repository URL in `homey.backup.repository` — must be an S3-compatible
|
||||
URL, e.g. `"s3:https://s3.us-west-002.backblazeb2.com/your-bucket-name"`
|
||||
|
||||
- [ ] **`secrets/secrets.yaml` — populate and encrypt**: Fill in all secret
|
||||
values (old passwords from k8s + freshly generated ones, including
|
||||
`restic/s3_access_key_id` and `restic/s3_secret_access_key`), then run
|
||||
`sops --encrypt --in-place secrets/secrets.yaml` before committing.
|
||||
|
||||
- [x] **`secrets/.sops.yaml` — PGP key**: The encryption subkey
|
||||
`076AA297579A0064` is already in `.sops.yaml`.
|
||||
|
||||
- [ ] **Cloudflare Tunnel**: Create the tunnel in the Zero Trust dashboard,
|
||||
copy the tunnel token into secrets, and configure public hostnames. See
|
||||
`modules/cloudflared.nix` and Phase 3 of `PORTING.md` for details.
|
||||
|
||||
- [ ] **Second machine**: When ready, add `hosts/pi-secondary/` and uncomment
|
||||
the `pi-secondary` entry in `flake.nix`. Services communicating cross-machine
|
||||
should reference the primary Pi's LAN IP instead of `127.0.0.1`.
|
||||
|
||||
- [ ] **Jellyfin and Transmission**: Both modules are written and importable
|
||||
but disabled. Enable in `hosts/pi-main/default.nix` when ready:
|
||||
```nix
|
||||
homey.jellyfin.enable = true;
|
||||
homey.transmission.enable = true;
|
||||
```
|
||||
|
||||
- [ ] **Backup — S3 credentials**: Add `restic/s3_access_key_id` and
|
||||
`restic/s3_secret_access_key` to secrets, and set `homey.backup.repository`
|
||||
to your S3-compatible bucket URL in `hosts/pi-main/default.nix`.
|
||||
|
||||
- [ ] **Backup — offload script**: Write `scripts/offload-backup.sh` for
|
||||
manually copying snapshots to a local disk (USB attached to Pi, or a disk
|
||||
on your workstation). Uses `restic copy` to clone from the S3 repo into a
|
||||
local restic repo on the target path. See `TODO.org` for design notes.
|
||||
|
||||
### 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).
|
||||
The old Helm chart had this commented out; it must be done manually once.
|
||||
Relevant settings:
|
||||
- Host: `127.0.0.1`, Port: `389`, Security: Unencrypted
|
||||
- Bind DN: `cn=readonly,dc=zakobar,dc=com`
|
||||
- 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).
|
||||
@@ -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`.
|
||||
+215
-7
@@ -2,7 +2,146 @@
|
||||
|
||||
A home environment for everyone!
|
||||
|
||||
* Installation
|
||||
* NixOS Deployment (active branch: nixos-port)
|
||||
|
||||
** Prerequisites
|
||||
|
||||
Before building, make sure the following are set in the repo:
|
||||
|
||||
- =hosts/pi-main/default.nix= — SSH public key, static IP, WiFi SSID
|
||||
- =secrets/secrets.yaml= — all secrets populated and sops-encrypted
|
||||
- WiFi password secret formatted as =wifi_psk=YourPassword= (see below)
|
||||
|
||||
** Adding / updating secrets
|
||||
|
||||
#+begin_src bash
|
||||
sops secrets/secrets.yaml
|
||||
#+end_src
|
||||
|
||||
Opens your editor with the decrypted file. Save and quit to re-encrypt.
|
||||
|
||||
The WiFi password entry must use the =wifi_psk== prefix so wpa_supplicant
|
||||
can look up the value by name:
|
||||
|
||||
#+begin_src yaml
|
||||
wifi/psk: "wifi_psk=YourActualWifiPassword"
|
||||
#+end_src
|
||||
|
||||
** Phase 1 — Bootstrap image (flash this first)
|
||||
|
||||
The full =pi-main= config requires sops secrets, which require an age key
|
||||
on the Pi — but the age key doesn't exist until after first boot. To
|
||||
break the chicken-and-egg problem, flash a minimal bootstrap image first.
|
||||
|
||||
Before building, fill in the WiFi password in =flake.nix= in the
|
||||
=pi-main-bootstrap= config (search for =WIFI_PASSWORD_HERE=):
|
||||
|
||||
#+begin_src nix
|
||||
networks."Zakobar".psk = "your-actual-wifi-password";
|
||||
#+end_src
|
||||
|
||||
Build the bootstrap SD image (requires =aarch64-linux= build capability —
|
||||
either =boot.binfmt.emulatedSystems = ["aarch64-linux"]= on your
|
||||
workstation, or an aarch64 remote builder):
|
||||
|
||||
#+begin_src bash
|
||||
nix build .#nixosConfigurations.pi-main-bootstrap.config.system.build.sdImage \
|
||||
--system aarch64-linux
|
||||
#+end_src
|
||||
|
||||
Find your SD card device, then flash (double-check =/dev/sdX=!):
|
||||
|
||||
#+begin_src bash
|
||||
lsblk
|
||||
|
||||
zstdcat result/sd-image/nixos-sd-image-*.img.zst | \
|
||||
sudo dd of=/dev/sdX bs=4M status=progress conv=fsync
|
||||
#+end_src
|
||||
|
||||
The Pi will boot at =192.168.1.100=, connect to =Zakobar= WiFi, and accept
|
||||
SSH connections with your key. No services run yet.
|
||||
|
||||
** Phase 2 — Generate age key and re-encrypt secrets
|
||||
|
||||
#+begin_src bash
|
||||
# SSH into the Pi
|
||||
ssh admin@192.168.1.100
|
||||
|
||||
# Generate the age key
|
||||
sudo age-keygen -o /var/lib/sops-nix/key.txt
|
||||
|
||||
# Print the public key — copy it
|
||||
sudo age-keygen -y /var/lib/sops-nix/key.txt
|
||||
#+end_src
|
||||
|
||||
Back on your workstation, add the public key to =secrets/.sops.yaml=
|
||||
alongside the existing PGP key:
|
||||
|
||||
#+begin_src yaml
|
||||
keys:
|
||||
- &pi_main age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
creation_rules:
|
||||
- path_regex: secrets/secrets.yaml$
|
||||
key_groups:
|
||||
- pgp:
|
||||
- 076AA297579A0064
|
||||
age:
|
||||
- *pi_main
|
||||
#+end_src
|
||||
|
||||
Then re-encrypt so the Pi can decrypt its own secrets:
|
||||
|
||||
#+begin_src bash
|
||||
sops updatekeys secrets/secrets.yaml
|
||||
#+end_src
|
||||
|
||||
** Phase 3 — Deploy the full config
|
||||
|
||||
#+begin_src bash
|
||||
nixos-rebuild switch \
|
||||
--flake .#pi-main \
|
||||
--target-host admin@192.168.1.100 \
|
||||
--build-host admin@192.168.1.100 \
|
||||
--use-remote-sudo
|
||||
#+end_src
|
||||
|
||||
The Pi builds its own config natively (no cross-compilation). sops-nix
|
||||
will now decrypt all secrets and start all services.
|
||||
|
||||
** Caddy plugin hash
|
||||
|
||||
The first deploy will fail at the Caddy build step because =lib.fakeHash=
|
||||
is a placeholder. Copy the correct hash from the error output and replace
|
||||
it in =modules/caddy.nix=:
|
||||
|
||||
#+begin_src nix
|
||||
caddyWithCloudflare = pkgs.caddy.withPlugins {
|
||||
plugins = [ "github.com/caddy-dns/cloudflare@..." ];
|
||||
hash = "sha256-REPLACE_WITH_REAL_HASH="; # ← paste here
|
||||
};
|
||||
#+end_src
|
||||
|
||||
Then re-run the deploy command from Phase 3.
|
||||
|
||||
** Ongoing deploys from workstation
|
||||
|
||||
All future config changes follow the same pattern:
|
||||
|
||||
1. Edit files on workstation
|
||||
2. Run:
|
||||
|
||||
#+begin_src bash
|
||||
nixos-rebuild switch \
|
||||
--flake .#pi-main \
|
||||
--target-host admin@192.168.1.100 \
|
||||
--build-host admin@192.168.1.100 \
|
||||
--use-remote-sudo
|
||||
#+end_src
|
||||
|
||||
NixOS activates the new config on the Pi immediately, with an automatic
|
||||
rollback if activation fails.
|
||||
|
||||
* Installation (legacy Helm)
|
||||
|
||||
Install using
|
||||
|
||||
@@ -12,11 +151,57 @@ helm upgrade --install homey . -n homey
|
||||
|
||||
* 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.
|
||||
|
||||
2. *Offload (manual)*: Run =scripts/offload-backup.sh --target /path/to/disk=
|
||||
to clone snapshots from the S3 repo onto a local disk (USB plugged into the
|
||||
Pi, or a disk on your workstation). Uses =restic copy= so deduplication is
|
||||
preserved on the target.
|
||||
|
||||
** What is backed up
|
||||
|
||||
All service data under =/mnt/data/=:
|
||||
|
||||
- =openldap/= — LDAP database and config
|
||||
- =authelia/= — Authelia config and state
|
||||
- =gitea/= — Gitea repositories and data
|
||||
- =nextcloud/= — Nextcloud files + a =pg_dump= of the database
|
||||
- =jellyfin/= — Jellyfin metadata (media files are excluded — re-downloadable)
|
||||
- =transmission/= — Torrent client config
|
||||
|
||||
Nextcloud is placed into maintenance mode and postgres is =pg_dump='d before
|
||||
each backup to ensure a consistent snapshot.
|
||||
|
||||
** Configuration
|
||||
|
||||
Repository URL and credentials are set per-host:
|
||||
|
||||
#+begin_src nix
|
||||
# hosts/pi-main/default.nix
|
||||
homey.backup.repository = "s3:https://s3.us-west-002.backblazeb2.com/your-bucket";
|
||||
#+end_src
|
||||
|
||||
S3 credentials live in =secrets/secrets.yaml= as =restic/s3_access_key_id= and
|
||||
=restic/s3_secret_access_key=.
|
||||
|
||||
** Restore
|
||||
|
||||
#+begin_src bash
|
||||
# List snapshots
|
||||
restic -r s3:https://... snapshots
|
||||
|
||||
# Restore latest snapshot to /mnt/data
|
||||
restic -r s3:https://... restore latest --target /mnt/data
|
||||
|
||||
# Restore a single service
|
||||
restic -r s3:https://... restore latest --target /mnt/data --include /mnt/data/gitea
|
||||
#+end_src
|
||||
|
||||
* LDAP Configuration
|
||||
|
||||
@@ -24,7 +209,7 @@ Logins are done to PHPLDAPADMIN
|
||||
|
||||
DN is like:
|
||||
|
||||
cn=admin,dc=home,dc=,dc=io
|
||||
cn=admin,dc=,dc=io
|
||||
get-secret-val.sh homey openldap-admin password
|
||||
|
||||
First thing we do is create an organization unit called users
|
||||
@@ -62,9 +247,9 @@ Add a new LDAP Authentication source
|
||||
Authentication name: Home LDAP
|
||||
Host: openldap
|
||||
Port: 389
|
||||
Bind DN = cn=readonly,dc=home,dc=,dc=io
|
||||
Bind DN = cn=readonly,dc=,dc=io
|
||||
Bind Password: openldap-ro password
|
||||
User Search Base: ou=users,dc=home,dc=,dc=io
|
||||
User Search Base: ou=users,dc=,dc=io
|
||||
user search filter = (uid=%s)
|
||||
Admin filter (title=admin)
|
||||
Username Attribute: uid
|
||||
@@ -90,3 +275,26 @@ https://dev.to/ruanbekker/self-hosted-cicd-with-gitea-and-drone-ci-200l
|
||||
https://gitlab.com/davical-project/davical/-/blob/master/config/example-config.php
|
||||
|
||||
Line 800 ish for auth from reverse proxy
|
||||
|
||||
* NEXTCLOUD
|
||||
|
||||
I ran THIS command inside
|
||||
su www-data -s /bin/bash -c php occ ldap:promote-group "admins"
|
||||
|
||||
** When maintenence mode
|
||||
|
||||
#+begin_example
|
||||
kubectl exec --tty --stdin -n homey deploy/nextcloud -- su -l www-data -s /bin/bash
|
||||
php /var/www/html/occ maintenance:mode --off
|
||||
#+end_src
|
||||
|
||||
* I UNDERSTAND
|
||||
|
||||
I need to backup Chen's stuff
|
||||
And... I need to Jellyfin
|
||||
|
||||
* PAPERLESS
|
||||
|
||||
https://github.com/paperless-ngx/paperless-ngx/blob/74c44fe418a91a526b5dab1a91fde4aaebd28bb1/docker/compose/docker-compose.postgres.yml
|
||||
|
||||
For docker
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
#+TITLE: Homey NixOS Port — Outstanding Tasks
|
||||
#+DATE: 2026-04-15
|
||||
#+OPTIONS: toc:nil
|
||||
|
||||
* Secrets Setup
|
||||
|
||||
** DONE Configure sops recipients in =secrets/.sops.yaml=
|
||||
GPG encryption subkey =076AA297579A0064= is already configured in
|
||||
=.sops.yaml=. The Pi's age key will be added post-boot (see Deployment section).
|
||||
|
||||
** DONE Populate =secrets/secrets.yaml= with real values
|
||||
Every key in =secrets/secrets.yaml= needs a real value. Fill in each row below.
|
||||
|
||||
*** Recovered from k8s backup
|
||||
| Key | Source k8s secret | Value |
|
||||
|----------------------------------+-------------------------+----------------------------------|
|
||||
| openldap/admin_password | openldap-admin | lfQWQgBZporyJT4xFSJTnu4vQMC7UevW |
|
||||
| openldap/config_password | openldap-config | ZxlWbDAeHLdHi5lgdxmyZOWzsG3qDgrT |
|
||||
| openldap/ro_password | openldap-ro | CZ7JLn23vSzhVjNW7UHGZ2YLFJPDLGsF |
|
||||
| gitea/admin_password | gitea-admin-pass | y5kCPeCP1e1sCzahd7QmLyJqdQvd37ek |
|
||||
| nextcloud/postgres_password | nextcloud-postgres-pass | hEq4zt1B1VKYtVAoiKYDmswcUmTbknSP |
|
||||
| authelia/jwt_secret | jwt-secret | YJZBnQCD4OmhJkgdr6kksmMCatrKLCl3 |
|
||||
| authelia/storage_encryption_key | fek-secret | KYWRYApCWWIN60gpSi7jhLuj1Wcm5z9Q |
|
||||
|
||||
*** Needs generation or manual creation
|
||||
| Key | Action |
|
||||
|----------------------------------+---------------------------------------------------|
|
||||
| authelia/session_secret | Generate fresh (64 random chars) |
|
||||
| gitea/lfs_jwt_secret | Generate fresh (43-char base64url) |
|
||||
| gitea/oauth2_jwt_secret | Generate fresh (43-char base64url) |
|
||||
| gitea/internal_token | Generate fresh (100-char alphanumeric) |
|
||||
| restic/password | Generate fresh (passphrase) |
|
||||
| nextcloud/admin_password | NOT in k8s backup; try old value or reset later |
|
||||
| cloudflare/api_token | Create DNS Edit token in Cloudflare dashboard |
|
||||
| cloudflare/tunnel_token | Create tunnel in Cloudflare Zero Trust dashboard |
|
||||
| restic/s3_access_key_id | Needs S3 provider credentials |
|
||||
| restic/s3_secret_access_key | Needs S3 provider credentials |
|
||||
|
||||
Generate random secrets with:
|
||||
#+begin_src bash
|
||||
# 64-char hex string
|
||||
openssl rand -hex 32
|
||||
|
||||
# base64url (for gitea tokens)
|
||||
openssl rand -base64 32 | tr '+/' '-_' | tr -d '='
|
||||
|
||||
# 100-char alphanumeric (gitea internal token)
|
||||
openssl rand -base64 75 | tr -dc 'A-Za-z0-9' | head -c 100
|
||||
#+end_src
|
||||
|
||||
** DONE Encrypt =secrets/secrets.yaml= with sops
|
||||
Encrypted with PGP key =076AA297579A0064=. Safe to commit.
|
||||
|
||||
* Pi Hardware Setup
|
||||
|
||||
** DONE Fill in real values in =hosts/pi-main/default.nix=
|
||||
- [X] =users.users.admin.openssh.authorizedKeys.keys= — SSH public key added
|
||||
- [X] =homey.storage.device= — =/dev/disk/by-id/usb-WD_Ext_HDD_1021_5743415A4146313531393031-0:0-part1=
|
||||
- [X] =homey.backup.repository= — =s3:https://s3.us-east-005.backblazeb2.com/zakobar-home-backup=
|
||||
|
||||
** DONE Integrate nixos-raspberrypi flake
|
||||
Replaced =nixos-hardware= with =nixos-raspberrypi= for vendor kernel, firmware,
|
||||
u-boot bootloader, and binary cache. Both =pi-main= and =pi-main-bootstrap=
|
||||
now use =nixos-raspberrypi.lib.nixosSystem= and =raspberry-pi-4.base=.
|
||||
=nix flake check= passes.
|
||||
|
||||
** TODO Verify SD card partition labels in =hosts/pi-main/hardware.nix=
|
||||
The config assumes labels =NIXOS_SD= (root) and =FIRMWARE= (boot).
|
||||
After flashing, check with:
|
||||
#+begin_src bash
|
||||
lsblk -o NAME,LABEL
|
||||
#+end_src
|
||||
Update =fileSystems= entries in =hosts/pi-main/hardware.nix= if they differ.
|
||||
|
||||
* Caddy Build
|
||||
|
||||
** TODO Fix =vendorHash= in =modules/caddy.nix=
|
||||
The Caddy build with the Cloudflare DNS plugin currently uses =lib.fakeHash=
|
||||
as a placeholder. After the first =nix build= attempt it will fail with the
|
||||
correct hash in the error message. Replace =lib.fakeHash= with that value.
|
||||
|
||||
* Cloudflare Setup
|
||||
|
||||
** DONE Create Cloudflare Tunnel
|
||||
1. Go to Cloudflare Zero Trust dashboard → Networks → Tunnels → Create tunnel
|
||||
2. Name it (e.g. =homey=)
|
||||
3. Copy the tunnel token into =secrets/secrets.yaml= under =cloudflare/tunnel_token=
|
||||
4. Configure public hostnames for each service (see service/URL table in AGENTS.md)
|
||||
|
||||
** DONE Create Cloudflare DNS API token
|
||||
1. Cloudflare dashboard → My Profile → API Tokens → Create Token
|
||||
2. Use the "Edit zone DNS" template, scope to =zakobar.com=
|
||||
3. Copy into =secrets/secrets.yaml= under =cloudflare/api_token=
|
||||
|
||||
* Deployment
|
||||
|
||||
** TODO Phase 1 — Build and flash bootstrap SD card image
|
||||
|
||||
The bootstrap image is a minimal NixOS with SSH + WiFi only (no sops, no
|
||||
services). Its sole purpose is to boot the Pi so you can generate the age key
|
||||
and then deploy the full config remotely.
|
||||
|
||||
Build on workstation (cross-compiles for aarch64):
|
||||
#+begin_src bash
|
||||
# Accept the nixos-raspberrypi cache config so pre-built kernel/firmware
|
||||
# are fetched instead of compiled. First build still takes ~10-20 min.
|
||||
nix build .#nixosConfigurations.pi-main-bootstrap.config.system.build.sdImage \
|
||||
--accept-flake-config
|
||||
#+end_src
|
||||
|
||||
Flash to SD card (replace =/dev/sdX= with your card's device):
|
||||
#+begin_src bash
|
||||
# Decompress and write in one step — avoids storing the raw image on disk
|
||||
zstdcat result/sd-image/*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress conv=fsync
|
||||
sudo sync
|
||||
#+end_src
|
||||
|
||||
Insert the SD card into the Pi and power it on.
|
||||
It will connect to WiFi (=Zakobar=) with static IP =192.168.1.100=.
|
||||
|
||||
Verify SSH access (wait ~60 s for first boot):
|
||||
#+begin_src bash
|
||||
ssh admin@192.168.1.100
|
||||
#+end_src
|
||||
|
||||
** TODO Phase 2 — Generate age key and add it to sops
|
||||
|
||||
On the Pi (over SSH):
|
||||
#+begin_src bash
|
||||
sudo mkdir -p /var/lib/sops-nix
|
||||
sudo age-keygen -o /var/lib/sops-nix/key.txt
|
||||
# Print the public key — copy this output to your workstation clipboard
|
||||
sudo age-keygen -y /var/lib/sops-nix/key.txt
|
||||
#+end_src
|
||||
|
||||
On the workstation — edit =secrets/.sops.yaml=, uncomment the age section
|
||||
and replace the placeholder with the public key you just copied:
|
||||
#+begin_src yaml
|
||||
key_groups:
|
||||
- pgp:
|
||||
- 076AA297579A0064
|
||||
age:
|
||||
- age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # paste here
|
||||
#+end_src
|
||||
|
||||
Re-encrypt =secrets/secrets.yaml= so the Pi's age key can decrypt it:
|
||||
#+begin_src bash
|
||||
sops updatekeys secrets/secrets.yaml
|
||||
#+end_src
|
||||
|
||||
Commit and push:
|
||||
#+begin_src bash
|
||||
git add secrets/.sops.yaml secrets/secrets.yaml
|
||||
git commit -m "add Pi age key to sops recipients"
|
||||
#+end_src
|
||||
|
||||
** TODO Phase 3 — Fix Caddy vendorHash, then deploy full config
|
||||
|
||||
The full =pi-main= config includes Caddy built with the Cloudflare DNS
|
||||
plugin. The first build will fail with the correct hash in the error output.
|
||||
|
||||
Attempt the build to get the hash:
|
||||
#+begin_src bash
|
||||
nix build .#nixosConfigurations.pi-main.config.system.build.toplevel \
|
||||
--accept-flake-config 2>&1 | grep 'got:'
|
||||
#+end_src
|
||||
|
||||
Copy the hash from the error message and replace =lib.fakeHash= in
|
||||
=modules/caddy.nix=, then commit:
|
||||
#+begin_src bash
|
||||
git add modules/caddy.nix
|
||||
git commit -m "fix caddy vendorHash"
|
||||
#+end_src
|
||||
|
||||
Deploy to the Pi:
|
||||
#+begin_src bash
|
||||
# Dry-run first — shows what will change without applying
|
||||
nixos-rebuild dry-activate \
|
||||
--flake .#pi-main \
|
||||
--target-host admin@192.168.1.100 \
|
||||
--use-remote-sudo \
|
||||
--accept-flake-config
|
||||
|
||||
# Apply when happy with the diff
|
||||
nixos-rebuild switch \
|
||||
--flake .#pi-main \
|
||||
--target-host admin@192.168.1.100 \
|
||||
--use-remote-sudo \
|
||||
--accept-flake-config
|
||||
#+end_src
|
||||
|
||||
After a successful switch, subsequent deploys can use the hostname:
|
||||
#+begin_src bash
|
||||
nixos-rebuild switch --flake .#pi-main --target-host admin@pi-main --use-remote-sudo
|
||||
#+end_src
|
||||
|
||||
* Post-Deployment Manual Steps
|
||||
|
||||
** DONE Configure Gitea LDAP authentication
|
||||
Admin → Site Administration → Authentication Sources → Add LDAP (via BindDN):
|
||||
- Host: =127.0.0.1=, Port: =389=, Security: Unencrypted
|
||||
- Bind DN: =cn=readonly,dc=zakobar,dc=com=
|
||||
- Bind Password: see =openldap/ro_password= in sops
|
||||
- User Search Base: =ou=users,dc=zakobar,dc=com=
|
||||
- User Filter: =(&(objectClass=inetOrgPerson)(uid=%s))=
|
||||
- Username attribute: =uid=
|
||||
- First name attribute: =cn=
|
||||
- Surname attribute: =sn=
|
||||
- Email attribute: =mail=
|
||||
|
||||
** TODO Verify Nextcloud LDAP app configuration
|
||||
After restoring the Nextcloud volume, check:
|
||||
Admin → LDAP/AD Integration — confirm the LDAP Users and Contacts app is configured.
|
||||
If reconfiguring from scratch, use the same settings as Gitea above but with
|
||||
Nextcloud's LDAP wizard:
|
||||
- Server: =127.0.0.1=, Port: =389=
|
||||
- Bind DN: =cn=readonly,dc=zakobar,dc=com=
|
||||
- Bind Password: see =openldap/ro_password= in sops
|
||||
- Base DN: =dc=zakobar,dc=com=
|
||||
- Users: filter =objectClass=inetOrgPerson=, search base =ou=users=
|
||||
- Login attribute: =uid=
|
||||
- Email attribute: =mail=
|
||||
|
||||
** TODO (Optional) Enable Jellyfin and Transmission
|
||||
When ready, in =hosts/pi-main/default.nix=:
|
||||
#+begin_src nix
|
||||
homey.jellyfin.enable = true;
|
||||
homey.transmission.enable = true;
|
||||
#+end_src
|
||||
|
||||
* Backup Strategy
|
||||
|
||||
** TODO Configure S3-compatible automatic backup target
|
||||
Update =homey.backup.repository= in =hosts/pi-main/default.nix= to point at
|
||||
your S3-compatible bucket (Backblaze B2, Wasabi, AWS S3, etc.):
|
||||
#+begin_src nix
|
||||
homey.backup.repository = "s3:https://s3.us-west-002.backblazeb2.com/your-bucket-name";
|
||||
# or for AWS:
|
||||
# homey.backup.repository = "s3:s3.amazonaws.com/your-bucket-name";
|
||||
#+end_src
|
||||
|
||||
Add the S3 credentials to =secrets/secrets.yaml=:
|
||||
#+begin_src yaml
|
||||
restic/s3_access_key_id: "YOUR_KEY_ID"
|
||||
restic/s3_secret_access_key: "YOUR_SECRET_KEY"
|
||||
#+end_src
|
||||
|
||||
Then wire them into =modules/backup.nix= via environment variables:
|
||||
=AWS_ACCESS_KEY_ID= and =AWS_SECRET_ACCESS_KEY= (restic reads these natively).
|
||||
|
||||
The existing daily schedule + prune retention in =modules/backup.nix= will
|
||||
handle the rest automatically.
|
||||
|
||||
** TODO Write manual offload script (=scripts/offload-backup.sh=)
|
||||
A standalone script for copying backup data to an external disk — either
|
||||
plugged directly into the Pi or mounted on your workstation.
|
||||
|
||||
Design:
|
||||
- Accepts a =--target= argument: a local path to the mounted disk
|
||||
(e.g. =/media/aner/backup-disk= or =/mnt/usb=)
|
||||
- Uses =restic copy= to clone snapshots from the S3 repo into a local restic
|
||||
repo on the target disk (deduplication is preserved, no double storage)
|
||||
- Alternatively can use =rsync= for a plain directory copy if restic is not
|
||||
available on the target machine
|
||||
- Should be runnable from either the Pi or a workstation (with the Pi's data
|
||||
disk mounted or accessible over SSH)
|
||||
|
||||
Example invocation:
|
||||
#+begin_src bash
|
||||
# On the Pi, with USB disk mounted at /mnt/usb:
|
||||
./scripts/offload-backup.sh --target /mnt/usb/homey-backup
|
||||
|
||||
# On workstation, with Pi data disk mounted locally:
|
||||
./scripts/offload-backup.sh --target /media/aner/backup-disk/homey-backup
|
||||
#+end_src
|
||||
|
||||
This script does not exist yet — needs to be written.
|
||||
|
||||
* Future
|
||||
|
||||
** TODO Add second machine (=pi-secondary=)
|
||||
When ready:
|
||||
1. Create =hosts/pi-secondary/= directory with =default.nix= and =hardware.nix=
|
||||
2. Uncomment the =pi-secondary= entry in =flake.nix=
|
||||
3. Services communicating cross-machine should reference the primary Pi's LAN IP
|
||||
instead of =127.0.0.1=
|
||||
@@ -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"~ |
|
||||
@@ -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 "=" "" }}
|
||||
@@ -0,0 +1,105 @@
|
||||
{
|
||||
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";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, sops-nix, nixos-hardware, ... }@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
|
||||
] ++ 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;
|
||||
# };
|
||||
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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,115 @@
|
||||
{ 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;
|
||||
|
||||
# Reverse proxy + Cloudflare
|
||||
homey.caddy.enable = true;
|
||||
homey.cloudflared.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";
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 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,191 @@
|
||||
{ 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";
|
||||
|
||||
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)";
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = pkgs.writeShellScript "backup-pre" ''
|
||||
set -euo pipefail
|
||||
|
||||
# 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
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services."homey-backup-post" = {
|
||||
description = "Post-backup hooks (take NC out of maintenance mode)";
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = pkgs.writeShellScript "backup-post" ''
|
||||
set -euo pipefail
|
||||
if systemctl is-active --quiet podman-nextcloud.service; then
|
||||
podman exec nextcloud php occ maintenance:mode --off || true
|
||||
fi
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Restic backup service
|
||||
# -----------------------------------------------------------------------
|
||||
services.restic.backups.homey = {
|
||||
repository = cfg.repository;
|
||||
passwordFile = config.sops.secrets."restic/password".path;
|
||||
|
||||
# Runtime env file written by ExecStartPre (see systemd override below)
|
||||
environmentFile = "/run/restic-homey-secrets.env";
|
||||
|
||||
paths = [
|
||||
"${dataDir}/openldap"
|
||||
"${dataDir}/authelia"
|
||||
"${dataDir}/gitea"
|
||||
"${dataDir}/nextcloud"
|
||||
# media and transmission config included when those services are enabled:
|
||||
"${dataDir}/jellyfin"
|
||||
"${dataDir}/transmission"
|
||||
# Deliberately excluded: media/* (large, can be re-downloaded)
|
||||
];
|
||||
|
||||
# Exclude Nextcloud's raw DB directory in favour of the pg_dump file
|
||||
exclude = [
|
||||
"${dataDir}/nextcloud/db"
|
||||
"${dataDir}/restic-cache"
|
||||
];
|
||||
|
||||
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 and inject secrets
|
||||
systemd.services."restic-backups-homey" = {
|
||||
requires = [ "homey-backup-pre.service" ];
|
||||
after = [ "homey-backup-pre.service" ];
|
||||
serviceConfig = {
|
||||
# Write runtime env file with actual secret values (restic needs the
|
||||
# raw values; it does not support _FILE suffix env vars).
|
||||
ExecStartPre = [
|
||||
(pkgs.writeShellScript "restic-inject-secrets" ''
|
||||
install -m 0600 /dev/null /run/restic-homey-secrets.env
|
||||
{
|
||||
printf 'AWS_ACCESS_KEY_ID=%s\n' \
|
||||
"$(cat ${config.sops.secrets."restic/s3_access_key_id".path})"
|
||||
printf 'AWS_SECRET_ACCESS_KEY=%s\n' \
|
||||
"$(cat ${config.sops.secrets."restic/s3_secret_access_key".path})"
|
||||
printf 'RESTIC_CACHE_DIR=%s\n' "${dataDir}/restic-cache"
|
||||
} >> /run/restic-homey-secrets.env
|
||||
'')
|
||||
];
|
||||
ExecStopPost = [
|
||||
(pkgs.writeShellScript "restic-cleanup-secrets" ''
|
||||
rm -f /run/restic-homey-secrets.env
|
||||
'')
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services."homey-backup-post" = {
|
||||
after = [ "restic-backups-homey.service" ];
|
||||
wantedBy = [ "restic-backups-homey.service" ];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
{ 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.
|
||||
# copy_headers makes Authelia's Remote-* headers available downstream.
|
||||
autheliaForwardAuth = ''
|
||||
forward_auth localhost:9091 {
|
||||
uri /api/verify?rd=https://auth.${domain}
|
||||
copy_headers Remote-User Remote-Name Remote-Groups Remote-Email
|
||||
# Always tell Authelia the scheme is https (cloudflared terminates TLS
|
||||
# externally; Caddy's http:// vhosts are only for the tunnel loopback).
|
||||
header_up X-Forwarded-Proto https
|
||||
# On auth failure, redirect to the authelia login page
|
||||
@goauth status 401
|
||||
handle_response @goauth {
|
||||
redir https://auth.${domain}?rm={method} 302
|
||||
}
|
||||
}
|
||||
'';
|
||||
|
||||
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.";
|
||||
};
|
||||
};
|
||||
|
||||
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.
|
||||
#
|
||||
# Each service gets two vhost entries:
|
||||
# - "host" (no scheme) → Caddy handles HTTPS + auto cert (for LAN access)
|
||||
# - "http://host" → plain HTTP for cloudflared on loopback (no redirect)
|
||||
#
|
||||
# Caddy auto-redirects HTTP→HTTPS only when no explicit http:// vhost exists.
|
||||
# By defining http:// explicitly we suppress that redirect so cloudflared
|
||||
# (which talks plain HTTP on port 80) gets a direct response.
|
||||
virtualHosts = {
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Authelia — public, no auth gate (it IS the auth gate)
|
||||
# ------------------------------------------------------------------
|
||||
"auth.${domain}" = {
|
||||
extraConfig = ''
|
||||
reverse_proxy localhost:9091
|
||||
'';
|
||||
};
|
||||
"http://auth.${domain}" = {
|
||||
extraConfig = cfProxy 9091;
|
||||
};
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Gitea — no forward_auth; git HTTP clients can't handle SSO redirects.
|
||||
# Access control is handled by Gitea itself (LDAP auth + private repos).
|
||||
# ------------------------------------------------------------------
|
||||
"git.${domain}" = {
|
||||
extraConfig = ''
|
||||
reverse_proxy localhost:3000
|
||||
'';
|
||||
};
|
||||
"http://git.${domain}" = {
|
||||
extraConfig = cfProxy 3000;
|
||||
};
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Nextcloud — public auth (Nextcloud manages its own users + LDAP)
|
||||
# ------------------------------------------------------------------
|
||||
"nextcloud.${domain}" = {
|
||||
extraConfig = ''
|
||||
# Redirect CardDAV/CalDAV discovery
|
||||
redir /.well-known/carddav /remote.php/dav/ 301
|
||||
redir /.well-known/caldav /remote.php/dav/ 301
|
||||
|
||||
# Large uploads (5 GB)
|
||||
request_body {
|
||||
max_size 5GB
|
||||
}
|
||||
|
||||
reverse_proxy localhost:8080
|
||||
'';
|
||||
};
|
||||
"http://nextcloud.${domain}" = {
|
||||
extraConfig = ''
|
||||
redir /.well-known/carddav /remote.php/dav/ 301
|
||||
redir /.well-known/caldav /remote.php/dav/ 301
|
||||
request_body {
|
||||
max_size 5GB
|
||||
}
|
||||
reverse_proxy localhost:8080 {
|
||||
header_up X-Forwarded-Proto https
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# phpLDAPadmin — two_factor, admins only (enforced by authelia policy)
|
||||
# ------------------------------------------------------------------
|
||||
"ldapadmin.${domain}" = {
|
||||
extraConfig = ''
|
||||
${autheliaForwardAuth}
|
||||
reverse_proxy localhost:8081
|
||||
'';
|
||||
};
|
||||
"http://ldapadmin.${domain}" = {
|
||||
extraConfig = ''
|
||||
${autheliaForwardAuth}
|
||||
${cfProxy 8081}
|
||||
'';
|
||||
};
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Jellyfin — no forward_auth; Jellyfin has its own login UI and
|
||||
# native app clients can't handle SSO redirects.
|
||||
# ------------------------------------------------------------------
|
||||
"jellyfin.${domain}" = {
|
||||
extraConfig = ''
|
||||
reverse_proxy localhost:8096
|
||||
'';
|
||||
};
|
||||
"http://jellyfin.${domain}" = {
|
||||
extraConfig = cfProxy 8096;
|
||||
};
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Transmission — two_factor, admins only (enforced by authelia policy)
|
||||
# ------------------------------------------------------------------
|
||||
"torrent.${domain}" = {
|
||||
extraConfig = ''
|
||||
${autheliaForwardAuth}
|
||||
reverse_proxy localhost:9092
|
||||
'';
|
||||
# NOTE: transmission is bound to 9092 to avoid clash with authelia on 9091.
|
||||
};
|
||||
"http://torrent.${domain}" = {
|
||||
extraConfig = ''
|
||||
${autheliaForwardAuth}
|
||||
${cfProxy 9092}
|
||||
'';
|
||||
};
|
||||
|
||||
};
|
||||
};
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# 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,88 @@
|
||||
{ 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
|
||||
# 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,124 @@
|
||||
{ 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="
|
||||
];
|
||||
};
|
||||
gc = {
|
||||
automatic = true;
|
||||
dates = "weekly";
|
||||
options = "--delete-older-than 14d";
|
||||
};
|
||||
};
|
||||
|
||||
# Allow unfree packages (e.g. cloudflared binary)
|
||||
nixpkgs.config.allowUnfree = true;
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 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;
|
||||
};
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 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,200 @@
|
||||
{ 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)
|
||||
|
||||
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));
|
||||
|
||||
# The authelia config is written as a Nix string so all values are
|
||||
# resolved at build time except for secrets, which are injected at
|
||||
# runtime via a wrapper script (same pattern as openldap).
|
||||
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://127.0.0.1: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:
|
||||
- domain:
|
||||
- "auth.${domain}"
|
||||
policy: "bypass"
|
||||
- domain:
|
||||
- "ldapadmin.${domain}"
|
||||
subject:
|
||||
- "group:admins"
|
||||
policy: "two_factor"
|
||||
- domain:
|
||||
- "ldapadmin.${domain}"
|
||||
policy: "deny"
|
||||
- domain:
|
||||
- "torrent.${domain}"
|
||||
subject:
|
||||
- "group:admins"
|
||||
policy: "two_factor"
|
||||
- domain:
|
||||
- "torrent.${domain}"
|
||||
policy: "deny"
|
||||
- domain:
|
||||
- "git.${domain}"
|
||||
policy: "one_factor"
|
||||
- domain:
|
||||
- "nextcloud.${domain}"
|
||||
policy: "one_factor"
|
||||
- domain:
|
||||
- "jellyfin.${domain}"
|
||||
policy: "one_factor"
|
||||
|
||||
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 = {
|
||||
enable = lib.mkEnableOption "Authelia SSO gateway";
|
||||
|
||||
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 {
|
||||
# -----------------------------------------------------------------------
|
||||
# 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;
|
||||
|
||||
# No ports mapping — --network=host shares the host network stack directly.
|
||||
|
||||
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";
|
||||
};
|
||||
|
||||
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=host"
|
||||
"--hostname=authelia"
|
||||
];
|
||||
};
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Systemd — wait for openldap and external HD
|
||||
# -----------------------------------------------------------------------
|
||||
systemd.services."podman-authelia" = {
|
||||
after = lib.mkAfter [ "mnt-data.mount" "podman-openldap.service" ];
|
||||
requires = lib.mkAfter [ "mnt-data.mount" "podman-openldap.service" ];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
{ 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";
|
||||
|
||||
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;
|
||||
# No ports mapping — --network=host means the container shares the host
|
||||
# network stack directly. Gitea binds to 0.0.0.0:3000 on the host.
|
||||
|
||||
# All non-secret settings via GITEA__<SECTION>__<KEY> env vars.
|
||||
# These are safe to store in the Nix store.
|
||||
environment = {
|
||||
USER_UID = "1000";
|
||||
USER_GID = "1000";
|
||||
GITEA_CUSTOM = "/data/gitea";
|
||||
|
||||
# [DEFAULT]
|
||||
GITEA____APP_NAME = homeyConfig.organization;
|
||||
GITEA____RUN_MODE = "prod";
|
||||
|
||||
# [repository]
|
||||
GITEA__repository__ROOT = "/data/git/repositories";
|
||||
|
||||
# [server]
|
||||
GITEA__server__APP_DATA_PATH = "/data/gitea";
|
||||
GITEA__server__DOMAIN = "git.${domain}";
|
||||
GITEA__server__HTTP_PORT = toString cfg.port;
|
||||
GITEA__server__ROOT_URL = "https://git.${domain}/";
|
||||
GITEA__server__DISABLE_SSH = "true";
|
||||
GITEA__server__START_SSH_SERVER = "false";
|
||||
GITEA__server__SSH_PORT = "2222";
|
||||
GITEA__server__SSH_LISTEN_PORT = "2222";
|
||||
GITEA__server__LFS_START_SERVER = "true";
|
||||
GITEA__server__OFFLINE_MODE = "false";
|
||||
|
||||
# [lfs]
|
||||
GITEA__lfs__PATH = "/data/git/lfs";
|
||||
|
||||
# [database]
|
||||
GITEA__database__DB_TYPE = "sqlite3";
|
||||
GITEA__database__PATH = "/data/gitea/gitea.db";
|
||||
GITEA__database__LOG_SQL = "false";
|
||||
|
||||
# [indexer]
|
||||
GITEA__indexer__ISSUE_INDEXER_PATH = "/data/gitea/indexers/issues.bleve";
|
||||
|
||||
# [session]
|
||||
GITEA__session__PROVIDER = "file";
|
||||
GITEA__session__PROVIDER_CONFIG = "/data/gitea/sessions";
|
||||
|
||||
# [picture]
|
||||
GITEA__picture__AVATAR_UPLOAD_PATH = "/data/gitea/avatars";
|
||||
GITEA__picture__REPOSITORY_AVATAR_UPLOAD_PATH = "/data/gitea/repo-avatars";
|
||||
GITEA__picture__DISABLE_GRAVATAR = "false";
|
||||
|
||||
# [attachment]
|
||||
GITEA__attachment__PATH = "/data/gitea/attachments";
|
||||
|
||||
# [log]
|
||||
GITEA__log__MODE = "console";
|
||||
GITEA__log__LEVEL = "info";
|
||||
GITEA__log__ROOT_PATH = "/data/gitea/log";
|
||||
|
||||
# [security]
|
||||
GITEA__security__INSTALL_LOCK = "true";
|
||||
GITEA__security__REVERSE_PROXY_LIMIT = "1";
|
||||
GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES = "*";
|
||||
|
||||
# [service]
|
||||
GITEA__service__DISABLE_REGISTRATION = "true";
|
||||
GITEA__service__REQUIRE_SIGNIN_VIEW = "false";
|
||||
GITEA__service__REGISTER_EMAIL_CONFIRM = "false";
|
||||
GITEA__service__ENABLE_NOTIFY_MAIL = "false";
|
||||
GITEA__service__ALLOW_ONLY_EXTERNAL_REGISTRATION = "true";
|
||||
GITEA__service__ENABLE_CAPTCHA = "false";
|
||||
GITEA__service__DEFAULT_ALLOW_CREATE_ORGANIZATION = "true";
|
||||
GITEA__service__DEFAULT_ENABLE_TIMETRACKING = "true";
|
||||
GITEA__service__NO_REPLY_ADDRESS = "noreply.localhost";
|
||||
GITEA__service__ENABLE_REVERSE_PROXY_AUTHENTICATION = "true";
|
||||
GITEA__service__ENABLE_REVERSE_PROXY_AUTO_REGISTRATION = "true";
|
||||
|
||||
# [mailer]
|
||||
GITEA__mailer__ENABLED = "false";
|
||||
|
||||
# [openid]
|
||||
GITEA__openid__ENABLE_OPENID_SIGNIN = "false";
|
||||
GITEA__openid__ENABLE_OPENID_SIGNUP = "false";
|
||||
|
||||
# [oauth2]
|
||||
GITEA__oauth2__ENABLED = "false";
|
||||
};
|
||||
|
||||
# Secret env vars written at runtime by ExecStartPre — never in store.
|
||||
environmentFiles = [ "/run/gitea-secrets.env" ];
|
||||
|
||||
volumes = [
|
||||
"${dataDir}/gitea/data:/data"
|
||||
];
|
||||
|
||||
extraOptions = [ "--network=host" ];
|
||||
};
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# 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" ];
|
||||
requires = lib.mkAfter [ "mnt-data.mount" ];
|
||||
};
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Ensure the Gitea admin user exists with the correct password after start.
|
||||
# Runs as a oneshot after podman-gitea; idempotent (create or update).
|
||||
# -----------------------------------------------------------------------
|
||||
systemd.services."gitea-admin-setup" = {
|
||||
description = "Ensure Gitea admin user exists with correct password";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "podman-gitea.service" ];
|
||||
requires = [ "podman-gitea.service" ];
|
||||
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
PASS=$(cat ${config.sops.secrets."gitea/admin_password".path})
|
||||
|
||||
# Wait until Gitea's HTTP endpoint is up (max 60 s)
|
||||
for i in $(seq 1 60); do
|
||||
if ${pkgs.curl}/bin/curl -sf http://127.0.0.1:${toString cfg.port}/ -o /dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Sync password if admin exists; create if not.
|
||||
if ! ${pkgs.podman}/bin/podman exec -u 1000 gitea \
|
||||
gitea admin user change-password --username admin --password "$PASS" 2>/dev/null; then
|
||||
${pkgs.podman}/bin/podman exec -u 1000 gitea \
|
||||
gitea admin user create \
|
||||
--username admin \
|
||||
--password "$PASS" \
|
||||
--email "admin@${domain}" \
|
||||
--admin
|
||||
fi
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{ 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";
|
||||
|
||||
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;
|
||||
# No ports mapping — --network=host shares the host network stack directly.
|
||||
|
||||
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=host" ];
|
||||
};
|
||||
|
||||
systemd.services."podman-jellyfin" = {
|
||||
after = lib.mkAfter [ "mnt-data.mount" ];
|
||||
requires = lib.mkAfter [ "mnt-data.mount" ];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
{ 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;
|
||||
in
|
||||
{
|
||||
options.homey.nextcloud = {
|
||||
enable = lib.mkEnableOption "Nextcloud file server";
|
||||
|
||||
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;
|
||||
# No ports mapping — --network=host shares the host network stack directly.
|
||||
|
||||
environment = {
|
||||
POSTGRES_DB = "nextcloud_db";
|
||||
POSTGRES_USER = "postgres";
|
||||
# Password injected via env file
|
||||
};
|
||||
|
||||
volumes = [
|
||||
"${dataDir}/nextcloud/db:/var/lib/postgresql/data"
|
||||
];
|
||||
|
||||
extraOptions = [
|
||||
"--network=host"
|
||||
"--env-file=/run/nc-postgres-secrets.env"
|
||||
];
|
||||
};
|
||||
|
||||
systemd.services."podman-nextcloud-postgres" = {
|
||||
serviceConfig = {
|
||||
LoadCredential = [
|
||||
"nextcloud_postgres_password:${config.sops.secrets."nextcloud/postgres_password".path}"
|
||||
];
|
||||
ExecStartPre = [
|
||||
(pkgs.writeShellScript "nc-postgres-secrets-env" ''
|
||||
set -euo pipefail
|
||||
install -m 600 /dev/null /run/nc-postgres-secrets.env
|
||||
echo "POSTGRES_PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/nextcloud_postgres_password")" \
|
||||
>> /run/nc-postgres-secrets.env
|
||||
'')
|
||||
];
|
||||
};
|
||||
postStop = "rm -f /run/nc-postgres-secrets.env";
|
||||
after = lib.mkAfter [ "mnt-data.mount" ];
|
||||
requires = lib.mkAfter [ "mnt-data.mount" ];
|
||||
};
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Nextcloud container
|
||||
# -----------------------------------------------------------------------
|
||||
virtualisation.oci-containers.containers.nextcloud = {
|
||||
image = cfg.image;
|
||||
# No ports mapping — --network=host shares the host network stack directly.
|
||||
|
||||
environment = {
|
||||
POSTGRES_HOST = "127.0.0.1";
|
||||
POSTGRES_DB = "nextcloud_db";
|
||||
POSTGRES_USER = "postgres";
|
||||
NEXTCLOUD_ADMIN_USER = "admin";
|
||||
NEXTCLOUD_TRUSTED_DOMAINS = "nextcloud.${domain}";
|
||||
OVERWRITEPROTOCOL = "https";
|
||||
OVERWRITECLIURL = "https://nextcloud.${domain}";
|
||||
# With --network=host, port mappings are ignored and the container's
|
||||
# Apache binds directly on the host. Force it onto port 8080 so Caddy
|
||||
# can own 80/443.
|
||||
APACHE_HTTP_PORT_NUMBER = toString cfg.port;
|
||||
# Passwords injected via env file
|
||||
};
|
||||
|
||||
volumes = [
|
||||
"${dataDir}/nextcloud/html:/var/www/html"
|
||||
];
|
||||
|
||||
extraOptions = [
|
||||
"--network=host"
|
||||
"--env-file=/run/nc-secrets.env"
|
||||
];
|
||||
};
|
||||
|
||||
systemd.services."podman-nextcloud" = {
|
||||
serviceConfig = {
|
||||
LoadCredential = [
|
||||
"nextcloud_postgres_password:${config.sops.secrets."nextcloud/postgres_password".path}"
|
||||
"nextcloud_admin_password:${config.sops.secrets."nextcloud/admin_password".path}"
|
||||
];
|
||||
ExecStartPre = [
|
||||
(pkgs.writeShellScript "nc-secrets-env" ''
|
||||
set -euo pipefail
|
||||
install -m 600 /dev/null /run/nc-secrets.env
|
||||
echo "POSTGRES_PASSWORD=$(cat "$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" ];
|
||||
requires = lib.mkAfter [ "mnt-data.mount" "podman-nextcloud-postgres.service" ];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
{ 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";
|
||||
|
||||
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;
|
||||
|
||||
# No ports mapping — --network=host means the container shares the host
|
||||
# network stack. OpenLDAP binds to 0.0.0.0:389, but the firewall
|
||||
# (common.nix) only opens 22/80/443, so port 389 is unreachable from
|
||||
# the LAN or internet.
|
||||
|
||||
environment = {
|
||||
LDAP_ORGANISATION = homeyConfig.organization;
|
||||
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=host"
|
||||
"--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" ];
|
||||
requires = lib.mkAfter [ "mnt-data.mount" ];
|
||||
};
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Firewall — openldap port is NOT opened externally
|
||||
# -----------------------------------------------------------------------
|
||||
# No firewall rule needed; common.nix only opens 22/80/443.
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{ config, lib, pkgs, homeyConfig, ... }:
|
||||
|
||||
# phpLDAPadmin — web UI for OpenLDAP management.
|
||||
#
|
||||
# Stateless container (no persistent volumes needed).
|
||||
# Protected by Authelia two_factor, admins-only policy (defined in authelia.nix).
|
||||
# Bound to localhost:8081; Caddy reverse-proxies it.
|
||||
#
|
||||
# Networking: uses default bridge (podman) network with a port mapping
|
||||
# 127.0.0.1:8081->80 so Caddy can reach it. OpenLDAP runs on the host
|
||||
# network at 127.0.0.1:389; the container reaches it via the special
|
||||
# host.containers.internal DNS name that podman injects automatically.
|
||||
|
||||
let
|
||||
cfg = config.homey.phpldapadmin;
|
||||
in
|
||||
{
|
||||
options.homey.phpldapadmin = {
|
||||
enable = lib.mkEnableOption "phpLDAPadmin web interface";
|
||||
|
||||
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";
|
||||
# host.containers.internal resolves to the host from inside a podman
|
||||
# bridge container — reaches openldap which is on --network=host at :389
|
||||
PHPLDAPADMIN_LDAP_HOSTS = "host.containers.internal";
|
||||
};
|
||||
|
||||
# Bridge network (default) + port mapping: Apache binds inside the
|
||||
# container on :80, podman maps it to 127.0.0.1:8081 on the host.
|
||||
ports = [ "127.0.0.1:${toString cfg.port}:80" ];
|
||||
};
|
||||
|
||||
systemd.services."podman-phpldapadmin" = {
|
||||
after = lib.mkAfter [ "podman-openldap.service" ];
|
||||
wants = lib.mkAfter [ "podman-openldap.service" ];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
{ 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;
|
||||
in
|
||||
{
|
||||
options.homey.transmission = {
|
||||
enable = lib.mkEnableOption "Transmission torrent client";
|
||||
|
||||
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;
|
||||
# No ports mapping — --network=host shares the host network stack directly.
|
||||
|
||||
environment = {
|
||||
PUID = "1000";
|
||||
PGID = "1000";
|
||||
# With --network=host, port mappings are ignored; transmission binds
|
||||
# directly on the host. Force it to cfg.port (9092) to avoid
|
||||
# conflicting with Authelia on 9091.
|
||||
TRANSMISSION_WEB_HOME = "/usr/share/transmission/web";
|
||||
WEBUI_PORT = toString cfg.port;
|
||||
};
|
||||
|
||||
volumes = [
|
||||
"${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=host" ];
|
||||
};
|
||||
|
||||
systemd.services."podman-transmission" = {
|
||||
after = lib.mkAfter [ "mnt-data.mount" ];
|
||||
requires = lib.mkAfter [ "mnt-data.mount" ];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
{ 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/
|
||||
# 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.";
|
||||
};
|
||||
};
|
||||
|
||||
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"
|
||||
];
|
||||
};
|
||||
|
||||
# Ensure the mount point directory exists
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${cfg.mountPoint} 0755 root root -"
|
||||
|
||||
# Service subdirectories — created on boot so containers can start
|
||||
# even before any data is restored into them.
|
||||
"d ${cfg.mountPoint}/openldap 0750 root root -"
|
||||
"d ${cfg.mountPoint}/openldap/etc-ldap-slapd.d 0750 root root -"
|
||||
"d ${cfg.mountPoint}/openldap/var-lib-ldap 0750 root root -"
|
||||
"d ${cfg.mountPoint}/authelia 0750 root root -"
|
||||
"d ${cfg.mountPoint}/authelia/config 0750 root root -"
|
||||
"d ${cfg.mountPoint}/gitea 0750 1000 1000 -"
|
||||
"d ${cfg.mountPoint}/gitea/data 0750 1000 1000 -"
|
||||
"d ${cfg.mountPoint}/nextcloud 0750 root root -"
|
||||
"d ${cfg.mountPoint}/nextcloud/html 0750 root root -"
|
||||
"d ${cfg.mountPoint}/nextcloud/db 0750 root root -"
|
||||
"d ${cfg.mountPoint}/jellyfin 0750 root root -"
|
||||
"d ${cfg.mountPoint}/jellyfin/config 0750 root root -"
|
||||
"d ${cfg.mountPoint}/media 0755 root root -"
|
||||
"d ${cfg.mountPoint}/media/movies 0755 root root -"
|
||||
"d ${cfg.mountPoint}/media/tvshows 0755 root root -"
|
||||
"d ${cfg.mountPoint}/media/general 0755 root root -"
|
||||
"d ${cfg.mountPoint}/media/complete 0755 root root -"
|
||||
"d ${cfg.mountPoint}/transmission 0750 root root -"
|
||||
"d ${cfg.mountPoint}/transmission/config 0750 root root -"
|
||||
"d ${cfg.mountPoint}/restic-cache 0700 root root -"
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -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,61 @@
|
||||
openldap:
|
||||
admin_password: ENC[AES256_GCM,data:hg+Ly1bX4ao1AT4SDvQWXiT/KMzsz0wdnRauiB+FetE=,iv:TAX+NZCVUNiwMeBrW58IeI1OJX6rzzGAhWiQ+cZXreo=,tag:MrwYKKBb1Cg2JvADtQqYrQ==,type:str]
|
||||
config_password: ENC[AES256_GCM,data:qKEurb0slGnr6nES7w7fTPDCy/DARns0BorDZMwpI/w=,iv:+p6Fh9a2g0eBueOxDk1J+hnM9fMgE6/NYwz+sAovGjE=,tag:kKZVsxdxdDACD9J0NAf4gQ==,type:str]
|
||||
ro_password: ENC[AES256_GCM,data:82htWXdJ07tdZ81o7o9a4hizxcn39yQidD5e9PijVpo=,iv:fDT1chX4ZPIS02IMEW02haPa2IIlLFhgFOpUwD7KL50=,tag:9pSo/3vFikKQAe8jS+3Q6A==,type:str]
|
||||
authelia:
|
||||
jwt_secret: ENC[AES256_GCM,data:SwXd/mMsrgXItP8QZr4z9YaN1lgSSO4Cpdwl+XxFj6I=,iv:gAkyHKP5D5RGJ3X3hoh8oEJfYaFnvYxGAoKxe+G1N0I=,tag:NlfWC2pmjCYiiRn9prgl2w==,type:str]
|
||||
session_secret: ENC[AES256_GCM,data:bbbgmxYLbtteuT628O+uSVeo7Gx3hI6uWVIV5l8AhtNSvXBHKb9i2NvQqJfVG8D2YR00+OW9XtreocpBuS7YJKp58cP/EgrF9x1im/8UaaifAYqD8YXZReUsBw+/PzIvqyA1K9tFd0coc8tSHJmwTwea4sf6Tc10N6j/nhNQfpQ=,iv:wrGE8XVsTINmf5505XVI7HHqA33w+SBh9lsJXD+YnwU=,tag:lvSoU+IRy8f+6VpYqzvacg==,type:str]
|
||||
storage_encryption_key: ENC[AES256_GCM,data:8KnaWBTlSStdC/uI4GUOYP9DJygjfCTu62zWTU9eeVE=,iv:DNl0L2QgT1lNUDdPNm9bXcGvrLXLDtWdJ9pPgRH20C8=,tag:hVhcCl0Vrt0ZnaVGaUokSQ==,type:str]
|
||||
gitea:
|
||||
admin_password: ENC[AES256_GCM,data:WncwJlqb/3X5WZYgIXAu0niI0ISP0eHfmhsKDLeAvE4=,iv:JBZNZJRSHKG+cCoFNJBI+jS+/WcLueqJ/UN/7wXfK/0=,tag:f6+Lhfcxstq6pwS9xkVwqQ==,type:str]
|
||||
lfs_jwt_secret: ENC[AES256_GCM,data:i05gr2ou03w0yu6/bhlJOW1huysAAPTidFEusWkhQfpDj4Pyh8LEKb09Og==,iv:aqkblyz0oIFHwzVCzlGDdQuCbsDPrfBaJMzgRTw+pYU=,tag:6gBSerOUK8Y3la/2Bg2AZQ==,type:str]
|
||||
oauth2_jwt_secret: ENC[AES256_GCM,data:BVvQJCEfHPbemd1jz7MWpIRia1wfvPMGuLqoi/xUMSoQoN5RPefQnPR4Cg==,iv:JAZQUTxHZSnMEnl+BIZ1PXlznMwKuPtiPP/17rc6lSs=,tag:mUw5RuthZmZegXCtfsFNmQ==,type:str]
|
||||
internal_token: ENC[AES256_GCM,data:gnOebJbRsh2Cues9WjGQp4rWa6OuE3xSnby9jc3Hk8ywvpL7CNWmlGW7zmmOyDAfIKfm8kf1FxotWLXtGZDretzdbMRM9c6gkwSJf5MCsdm27Er+IRKS/QFBuvLSTEH0,iv:aVgRvs3T3zCg+AV/BKUXQyZDKvunHvXsdfr9sqo2cI0=,tag:U9aP+N0CWyRQ/xJ27Vo2mw==,type:str]
|
||||
nextcloud:
|
||||
admin_password: ENC[AES256_GCM,data:iK6VoE94vFQmn3i4XQc5r/c03u3b0knDgBNK8d1qyns=,iv:P1wax2vAjn9iwBe9T7SN+pKrtrWcOYb5OWUyHF4hlVg=,tag:ET8KU4IKzhWqIDeRihwcag==,type:str]
|
||||
postgres_password: ENC[AES256_GCM,data:ga4cwhYsAgEBvr+aDVwiRZXeT+TjXzeef1r3ud6uYHs=,iv:PMHCjO4wLW6PER4oGODEG9CHqrvVpAbgTGF7p49MCL0=,tag:mTNzsDhufqLlf1LFu7Rl1A==,type:str]
|
||||
cloudflare:
|
||||
api_token: ENC[AES256_GCM,data:Erzom4DKiam9SHGLdT3CQRkuT5kkhcuUaLwTbt2P5pPjr1V56p733KB1kHheO/PZ+TRsZg0=,iv:eO+ryffyoSkzAgUXe0MH+FKitgHCQ3ychLWEAShAd9o=,tag:2Z8Io0ylpAI9rws5NXCvIw==,type:str]
|
||||
tunnel_token: ENC[AES256_GCM,data:AFlD990L8l1Rh9i8wdyXwwyolrlw2ln1uuyCTiT7k1FVc2JjTOrgc8HiBIpxM40eqFGEnzDMs87tgzh1Pl8UThwV7WcLFWHMvtYHNez/F2+THknnW+ZinJbZnNSngicrhRIoNFhjQgjaR1LaS4kcNbkGBi655bl2uqNXoUpNThUKGAlZ4KwByEzK4B7QtCgAkqxQEFehtjdj41p8r58ViJTogXQKXmDYLjbqo8nPmUGSaiR/VCY7Gw==,iv:q755yc6wTMCuqHLvfHOZzBf3KoG4vcw431stQA78Bjs=,tag:Zt/LPG41HxSg2gSQOTC/6A==,type:str]
|
||||
restic:
|
||||
password: ENC[AES256_GCM,data:X/pWmwakzQzRpSaY+T/kOqdrtXyvGPa3UQc/iIQFFAzUS1jHR9IvzW7KYdm/3IKXPBlZzPkWDsoRvVAChPwfQA==,iv:4+RD9UD5daMP04ixeagxbCNkTdOPx+BqfSOheh88OUU=,tag:SmrIz7jvbM74y5RBX0nCbA==,type:str]
|
||||
s3_access_key_id: ENC[AES256_GCM,data:XxElPQF28ThfYuiF4jQu7BiS8sh+c4V4ng==,iv:aLUIYnRGqFLYwlP3nFwDY5uvy8pXtX5QMKLMfRTxdNk=,tag:CscNNWnpvLuxw1DPK92GyA==,type:str]
|
||||
s3_secret_access_key: ENC[AES256_GCM,data:9ZWyhGJm4t2benDrLmnyQ9ZA5Jjl6l+pza1VmymTlw==,iv:xYsG6QlxXhQNO9szmsycxP6lT0cFF7lq3iNg6j+ED0E=,tag:wOJT4Vg3DuNFWTtx3QS9IQ==,type:str]
|
||||
wifi:
|
||||
psk: ENC[AES256_GCM,data:znk9Wr+vsntzbJ3H0TORUrAiDw==,iv:wbl8fUuKlgTqhajwjlTgFS7ijaTwXBFPRW2AmtiTklg=,tag:IK4oe8cJcccPaQ0V0NlncQ==,type:str]
|
||||
sops:
|
||||
age:
|
||||
- recipient: age120j8ty7nn04l3s3kgph5ty3v9g4e52fknn8xtnmzwakq9nv2la3skgte0p
|
||||
enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBZSGpPdTBIaTZ0TER2NkNO
|
||||
U2ZPKzNwelJHUEpyU2VBSmd5Yjd5bEtibFZzCjlZZTRFa2FHN1JtK2JUSm51a3By
|
||||
QmFyV1ZZNWI0OGJVM1NNZERjd2hWcDAKLS0tIG9VSVFTSTJBMjk5ZzBSL0ZQV2Ev
|
||||
QXVkRlJHeW52NFZFYnVwaW8ycytDSzAKZt+p5QnZKcEOBghHA2xkH6d7NObtTEoE
|
||||
wMwCYasnBHzy2unXRbZq/4v9NQ5HJd0Nu1iqbqKgIxMCD3dnxEdK7g==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
lastmodified: "2026-04-21T12:42:15Z"
|
||||
mac: ENC[AES256_GCM,data:fNip/7A7iKCVZqP0EziyBG7K8SVfRJTBpn4RcDLOaciJHx5DkLLszE8we9MmzpKXQIiMcJl2BTj/uqJrgc5EHTSOHwRzNJ4s2NJfvQW+8QUDfTGzKOkP3L837RkEPzH4HZLqGlfYK7cNJU5qXRPbusKjAft7Fz3+ONXmodb/ONY=,iv:CdSs1a+74+MfzWyML2JQ/b2IKbktVdefFFYP5LOtUos=,tag:ikr9LwPnmdiPucOoBt3/Bw==,type:str]
|
||||
pgp:
|
||||
- created_at: "2026-04-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
|
||||
@@ -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