resources/dev

Server infrastructure — l.asturias · l.jezerca · c.h.main · c.h.apps

Written on 2026-09-23 from a read-only inspection. Sources: both git repos (home-server/,
main-server/), a live SSH inventory of the hosts (docker ps, ss, systemctl,
journalctl, disk and memory stats, the Pangolin and Coolify databases), and DNS and HTTP
probes from the LAN. Nothing was changed on any host.

Updated on 2026-09-30 after Immich, Jellyfin, notes and Paperless moved, together with the
DAS, from l.asturias (called l.macmachine until then) to the new l.jezerca. Source:
, which also holds the rollback steps. Only what the
migration touched was re-checked. Everything else is still the 23 Sep snapshot. Points that
follow from the move but were not checked on a host are marked inferred.


Contents

  1. The short version
  2. Findings, by priority
  3. Topology
  4. l.asturias — the core home server
  5. l.jezerca — the media and data server
  6. c.h.main — the edge / ingress VPS
  7. c.h.apps — the Coolify app VPS
  8. Cross-cutting concerns
    • 8.1 Domain map · 8.2 Request paths · 8.3 DNS & certificates · 8.4 Deployment & updates ·
      8.5 Observability · 8.6 Secrets · 8.7 Backups · 8.8 Dependencies & failure modes
  9. Suggested action list
  10. Appendix — operating cheat-sheet

1. The short version

l.asturias l.jezerca c.h.main c.h.apps
Role Home server: LAN ingress (Traefik, AdGuard), Gitea + registry + CI, Home Assistant, Vaultwarden, own tools Home server: media and personal data (Immich, Jellyfin + *arr, notes, Paperless), with the DAS attached Public edge: Pangolin tunnel endpoint, mail, static sites PaaS: Coolify apps plus the observability backend
Where LAN, 192.168.178.48, behind a FritzBox LAN, 192.168.178.92 wired (Wi-Fi .90 still up as a fallback) Hetzner Cloud, 78.47.148.93 (+ private 10.10.0.2) Hetzner Cloud, 167.235.31.193 (+ private 10.10.0.3)
Hardware Mac mini 7,1 (Late 2014), i5-4278U 2C/4T, 7.6 GiB RAM Ryzen 5 7530U 6C/12T, 15 GiB RAM, Radeon iGPU, 1 TB NVMe + 4-disk DAS KVM vServer, 1 vCPU, 1.9 GiB RAM, no swap KVM vServer, 4 vCPU, 7.6 GiB RAM, no swap
OS Debian forky/sid (testing/unstable), kernel 7.1.7 not recorded Debian 13 trixie, 6.12.107 Debian 13 trixie, running 6.12.90 (6.12.107 installed)
Uptime (23 Sep) 42 days, rebooted during the migration — 2 days 69 days
Containers 29 running (30 Sep), plus 23 stopped pre-migration leftovers. Was 55 on 23 Sep 26 (30 Sep) 9 30
Config source home-server repo → doco-cd (.doco-cd.asturias.yaml, GitOps, SOPS) home-server repo → doco-cd (.doco-cd.jezerca.yaml) main-server repo → systemd timer auto-sync.sh Coolify UI/DB. Not in git, except one hand-placed /srv/compose.yaml
Image updates Renovate PRs (weekly); doco-cd force_image_pull for own images, but only when a push touches the stack Renovate PRs (weekly) Renovate PRs (weekly) Coolify redeploys on git push; many :latest images
SSH max@, port 22 max@; the l.jezerca alias in ~/.ssh/config still points at the Wi-Fi address .90 root@, port 2222 (22 belongs to Gitea SSH via gerbil) root@, port 22

How it fits together: c.h.main is the front door. Public DNS for *.max-richter.dev
points at it. Pangolin's Traefik terminates TLS and forwards most hostnames through WireGuard
tunnels (gerbil ↔ newt) to containers at home. Each home server runs its own newt with its
own Pangolin site. On the LAN, AdGuard on l.asturias answers the same hostnames with
l.asturias's IP, so local traffic goes to a LAN-only Traefik there that serves a wildcard
certificate copied over from c.h.main. For the stacks on l.jezerca, that Traefik forwards to
published ports on 192.168.178.92 through a file-provider config. c.h.apps runs separately:
Coolify builds apps from the home Gitea, and it also hosts the Grafana LGTM stack
(observe.max-richter.dev) that all four OpenTelemetry collectors ship to.


2. Findings, by priority

Everything below was observed directly unless it is marked unverified or inferred. Status
notes dated 30 Sep come from the migration.

🔴 High — fix soon

# Host Finding Evidence / fix
H1 c.h.main None of the image updates merged since 24 Aug are running. The repo pins pangolin ee-1.23.0, gerbil 1.5.2, traefik v3.7.13, docker-mailserver 16.0.1, roundcube 1.7.4, nginx 1.31.6, otel 0.161.0. Running: ee-1.21.1, 1.5.0, v3.7.11, 15.1.0, 1.7.3, 1.31.3, 0.159.0. On 22 Sep auto-sync failed every minute with "local changes to mail/compose.yaml would be overwritten". /srv was then fast-forwarded by hand, so the next tick saw HEAD == origin/main and never ran docker compose up -d. This is a design gap: a manual pull silently skips the apply step. Fix: cd /srv/{pangolin,nginx,mail} && docker compose up -d. Read the docker-mailserver v16 changelog first, since it is a major version. Longer term, have auto-sync.sh compare running images with docker compose config --images, or record the last applied commit rather than relying on HEAD.
H2 l.jezerca ✅ Resolved. On 23 Sep Immich's live Postgres sat inside doco-cd's git clone (/opt/doco-cd/data/…/immich/pgdata) on the 94%-full root disk, because DB_DATA_LOCATION was relative. The live DB was moved to /mnt/ssd/immich/pgdata (the older copy is parked as pgdata.stale-2026-08-21), then copied to l.jezerca's /mnt/ssd (NVMe) and verified on 30 Sep (PG 14, 4.5 GB). Still open: no backup covers it (H4).
H3 l.asturias 🟡 Improved. Root filesystem was 94% full on 23 Sep (193 G of 218 G). 30 Sep: pruning 24 GB of old images took it from 74% to 63%. Removing the 23 stopped containers and their images on 2026-10-07 frees more. Docker's data-root is on /. On 23 Sep docker system df also showed 49 GB of unused volumes, 2.7 GB of build cache and six idle buildx_buildkit_builder-* containers.
H4 all Backups cover little of the data, nothing on the VPSes, and the only backup job now runs on the wrong host. The only backup is restic → Google Cloud Storage on l.asturias (~/bin/backup/all.sh, last logged run 1 Sep, schedule not visible without sudo). Most of what it covers (/mnt/hdd/photos, document_data, Notes, and /mnt/hdd/gitea_data, which was already a stale Gitea copy) lives on the DAS, which is now attached to l.jezerca, and asturias's fstab no longer mounts it. Inferred: on asturias those paths are now empty directories, so the job either fails or snapshots nothing, and two such runs push the last good snapshots out of --keep-last 2 --keep-monthly 1. Check this before the next run. Only bitwarden is still backed up from where it lives. Not covered at all: Gitea's repos and Postgres, Immich's Postgres, Paperless's data dir (its DB), Home Assistant, the Jellyfin/*arr configs, AdGuard, /mnt/kemper (293 G of family photos, single disk), all of c.h.main (mail 532 M, Pangolin DB, acme.json) and c.h.apps (Coolify DB, LGTM, MinIO, PocketBase, umami, wanderer). Coolify has 0 scheduled DB backups. Live databases are copied as raw files, so those copies aren't crash-consistent.
H5 l.asturias Backup credentials are world-readable. ~/bin/backup/{password,.env,trusty-field-…json} are mode 644. The JSON is a GCS service-account private key. chmod 600 them (and 700 the dir). Rotate the service-account key if other local users or containers could have read it. Delete ~/bin/backup/test (it's just openssl help output).
H6 c.h.apps No host firewall and SSH password auth enabled. INPUT ACCEPT, no ufw/nftables, no fail2ban, PasswordAuthentication yes. Coolify's UI is reachable over plain HTTP on :8000 from the internet (confirmed 200). Realtime is on :6001-6002, and silvester-25 publishes :3000 directly (confirmed 200), which skips the proxy. Root is key-only (without-password), but the one other login user (openobserve-agent, /bin/sh) could accept a password. Set PasswordAuthentication no, add a Hetzner Cloud Firewall (Docker bypasses host firewalls) allowing only 22/80/443, and reach Coolify only through its HTTPS domain.
H7 c.h.main Tight on memory: 172 MiB available of 1.9 GiB, no swap. Pangolin uses 523 MiB and Traefik 412 MiB. The pending mailserver v16 and Pangolin upgrades (H1) will use more. An OOM kill here takes down every public hostname plus mail. Add a 2 GB swapfile at minimum, or move to a 4 GB plan.
H8 l.jezerca, l.asturias No SMART monitoring at all since the migration. On 23 Sep smartctl-exporter (in otel on the Mac mini) watched all six disks. It isn't deployed anywhere now, and its receivers are commented out in both collector configs. The DAS holds four single, partly old disks (M11) with incomplete backups (H4). Deploy it on l.jezerca (DAS disk IDs + NVMe), optionally a second instance on l.asturias for its sda/sdb, re-enable the receivers, and add an alert.

🟠 Medium

# Host Finding
M1 c.h.main The 100 GiB volume holds a 50 GiB filesystem. /dev/sdb is 107 374 182 400 bytes, but ext4 has 13 107 200 × 4 KiB blocks. The volume was resized in Hetzner but the filesystem wasn't grown. Fix: resize2fs /dev/sdb (online). / is also 88% full (19 G).
M2 c.h.main Anubis bypass on :3923. Docker publishes the anubis entrypoint (the one Anubis forwards back into) on 0.0.0.0:3923. UFW has 3923 DENY, but Docker's iptables rules bypass UFW. Confirmed: curl -H 'Host: git.max-richter.dev' http://78.47.148.93:3923/ returns Gitea over plain HTTP. Fix: drop 3923:3923 from gerbil's ports:. Traefik shares gerbil's network namespace, so Anubis still reaches it at gerbil:3923.
M3 both repos Secrets committed in plaintext. main-server/pangolin/config/traefik/traefik_config.yml holds the LGTM ingest Authorization: Basic … header three times. home-server/notes/compose.yaml:1112 has MARKA_API_KEY (still open on 30 Sep), and marka.max-richter.dev is published via Pangolin. Move both into SOPS/.env and rotate them.
M4 l.asturias, l.jezerca Many services bound to 0.0.0.0 and [::]. l.asturias has no host firewall and publishes Verdaccio :4873, OctoPrint :3010, AdGuard admin :3004, noise :8000 and NFS :2049. l.jezerca runs ufw, but Docker-published ports bypass it: qBittorrent WebUI :8080, SABnzbd :8085, Sonarr/Radarr/Prowlarr/Bazarr, Jellyfin, Immich :2283, marka :8088, and now SilverBullet :3000 and Paperless :8000, which Traefik on asturias needs. l.asturias has a global IPv6 address (2001:4dd4:…). This is safe only while the FritzBox keeps blocking inbound IPv6 (its default; unverified). Ports that only Traefik needs could be limited to 192.168.178.48 in the DOCKER-USER chain.
M5 l.asturias OS hygiene: it runs Debian sid on a server, and on 23 Sep 254 packages were upgradable, including the kernel and openssh-server 10.5. It was rebooted during the migration; whether the upgrades went in wasn't checked. Docker CE comes from the bookworm repo on top of sid. Sid breaks at random. Consider moving to trixie stable.
M6 c.h.apps Pending reboot and no automatic updates. Kernel 6.12.107 is installed but 6.12.90 is running (69 days up). 63 packages are upgradable. unattended-upgrades isn't installed (c.h.main has it).
M7 l.asturias Memory pressure on 23 Sep: 7.6 GiB RAM, 1.4 GiB available, 4.3 GiB swapped, load average ~3.7 on 4 threads. Probably much better now that Immich, Jellyfin, Paperless and both Silverbullets run on l.jezerca (not re-measured). The idle buildx builders still count until they're removed.
M8 l.jezerca Renovate can't track Jellyfin any more. The rule expects X.Y.Zubu####-lsN, but the running tag is 12.0ubu2604-ls48, which has only two version parts. The regex ^(\d+)\.(\d+)\.(\d+)(ubu\d+)-ls(\d+)$ doesn't match it, so no Jellyfin PRs will be opened. Make the patch group optional.
M9 c.h.main CrowdSec half-installed: crowdsec-firewall-bouncer is active but the crowdsec agent is inactive, so the bouncer has no decisions to enforce. Either run the agent or remove the bouncer.
M10 l.asturias /mnt/ssd has no nofail. It's a USB SSD holding all service state. If it's missing at boot, the machine drops to emergency mode with no SSH. That may be intentional (better than starting services on an empty mountpoint), but know it's there.
M11 l.jezerca Single-disk storage throughout. There's no RAID or mirror anywhere. The DAS is now attached to l.jezerca. /mnt/hdd, a ~2014 WD Red 1 TB, has only ~166 GB free of 916 GB (Immich uploads, file data). /mnt/media is a ~2010 Seagate Barracuda on NTFS (ntfs-3g). With no SMART monitoring (H8), a failing disk goes unnoticed.
M12 l.asturias Host scripts and exports still point at the DAS (inferred, the migration didn't touch them). backup/all.sh (H4), save_security_cams.sh / convert_stills_to_mp4.sh (write to /mnt/hdd/file_data/WebCams), save_notes.sh (commits /mnt/hdd/file_data/Notes), and the NFS export of /mnt/media all run on asturias, where the DAS is no longer mounted. Move them to l.jezerca or retire them.

🟡 Low / tidy-ups


3. Topology

flowchart LR
  subgraph Internet
    U[Browser / git client]
    CF[Cloudflare DNS<br/>apex + pass.* proxied]
  end

  subgraph MAIN["c.h.main · 78.47.148.93 · Hetzner"]
    G[gerbil<br/>:80 :443 :22 :51820/udp]
    T[traefik v3<br/>in gerbil netns]
    P[pangolin<br/>control plane]
    A[anubis<br/>PoW gate]
    NG[nginx<br/>static sites]
    SF[sftp :2221]
    MS[docker-mailserver<br/>25 143 465 587 993 4190]
    RC[roundcube]
    OM[otel-collector]
  end

  subgraph AST["l.asturias · 192.168.178.48 · LAN"]
    NW[newt<br/>WG client]
    LT[traefik LAN<br/>wildcard cert]
    AG[AdGuard DNS<br/>split horizon, whole LAN]
    GI[gitea<br/>+ registry, runner]
    SVC[(HA, vaultwarden,<br/>mcp, wg, noise, …)]
    DC[doco-cd]
    OH[otel-collector]
  end

  subgraph JEZ["l.jezerca · 192.168.178.92 · LAN"]
    NW2[newt<br/>own Pangolin site]
    SVC2[(immich, jellyfin + *arr,<br/>silverbullet, paperless)]
    DAS[(DAS: hdd, movies,<br/>kemper, media)]
    DC2[doco-cd]
    OJ[otel-collector]
  end

  subgraph APPS["c.h.apps · 167.235.31.193 · Hetzner"]
    CP[coolify-proxy traefik]
    CO[coolify]
    APPX[(15 apps / services)]
    LGTM[grafana otel-lgtm<br/>observe.max-richter.dev]
    OA[otel-collector]
  end

  U --> CF --> G
  G --- T
  T -->|pangolin HTTP provider| P
  T -->|git.*, pangolin.*| A --> T
  T -->|max-richter.dev, nodes.*, splitnir.app| NG
  T -->|mail.*| RC --> MS
  G <==>|WireGuard tunnel| NW
  G <==>|WireGuard tunnel| NW2
  NW -->|by container name on 'newt' network| SVC
  NW --> GI
  NW2 -->|'newt' network| SVC2
  SVC2 --- DAS
  LAN[LAN clients] -->|DNS| AG
  AG -->|rewrite → 192.168.178.48| LT --> SVC
  LT -->|file provider → published ports| SVC2
  GI -.->|push webhook /v1/webhook/asturias| DC
  GI -.->|push webhook /v1/webhook/jezerca| DC2
  DC & DC2 -->|git over SSH via public IP| G
  CO -->|git clone| T
  U --> CP --> APPX
  OM & OH & OJ & OA -->|OTLP/HTTP| LGTM

The Hetzner private network 10.10.0.0/16 links c.h.main (.2) and c.h.apps (.3), but
nothing in the configs uses it. Telemetry between them goes over the public
observe.max-richter.dev.


4. l.asturias — the core home server

Formerly l.macmachine. Since 30 Sep it keeps the LAN ingress, Gitea and the smaller
services; the media and data stacks run on l.jezerca.

4.1 Hardware & OS

4.2 Storage

Device Model FS Mount Size / used Holds
sda Kingston SA400 240 G SATA SSD ext4 / 218 G / 63% (30 Sep; 94% on 23 Sep) OS, /var/lib/docker (images, volumes), /opt/doco-cd (clone), /home/max (32 G)
sdb SSK USB SSD 120 G ext4 /mnt/ssd 118 G / 46% (23 Sep) State of the stacks that stayed: gitea 40 G, adguard 2.5 G, HA 1.4 G, … Also the old copies of immich, jellyfin, notes and paperless (delete after 2026-10-07), and the stale, dirty git checkout of the repo.

The four DAS disks (sdc–sdf on 23 Sep) moved to l.jezerca (5.2). Their fstab lines were
removed on 30 Sep. The lines to restore them are in the migration note's rollback section.

NFS exports (/etc/exports, 23 Sep): /mnt/media and /mnt/projects → 192.168.178.0/24 (rw,sync). Neither is a mount any more (M12).

4.3 Deployment model — doco-cd GitOps (both home servers)

4.4 Stacks (.doco-cd.asturias.yaml)

Stack Containers (image) Public hostname(s) Notes
newt fosrl/newt:1.17.0 — Pangolin site l.macmachine (100.89.128.4/30, name as of 23 Sep). Creates the shared newt bridge network every other stack joins. Has the Docker socket (read-only) for container discovery. l.jezerca runs the same folder with its own site credentials (5.4).
traefik traefik:v3.7.13 LAN only; dashboard at home.…/traefik Docker + file providers, no ACME. Default cert = *.max-richter.dev from /mnt/ssd/traefik/config/letsencrypt/wildcard.{crt,key}. Holds the network alias git.max-richter.dev on newt. readTimeout: 30m for registry pushes. Routes l.jezerca's stacks through traefik/config/traefik/dynamic/jezerca.yaml (file provider → published ports on 192.168.178.92). The traefik.* labels in jezerca's compose files are inert and serve as the reference: change a label, change the file too.
adguard adguard/adguardhome:v0.107.79 — (UI :3004) LAN DNS :53 for the whole LAN, including l.jezerca, DoT :853, DoQ :784/:853/:8853, DHCP :67. Split-horizon rewrites (see 8.3).
gitea gitea/gitea:1.27.3 (2 GiB limit), postgres:17.11, gitea/runner:3.5.0, verdaccio:6.10.4, git.max-richter.dev/max/ai-reviewer:latest git.…, gitai.… Git SSH on host :2222 (PROXY protocol only, 8.2). Also the container registry for all own images. Verdaccio npm registry on :4873. The runner has the Docker socket. The AI reviewer uses OpenRouter (qwen/qwen3.8-27b) and runs review jobs in rootless Podman via /run/user/1000/podman/podman.sock. Postgres was upgraded from 13 via dump/restore; the old cluster is kept as gitea/postgres-v13-backup. Sends the push webhooks to both doco-cd instances.
home-assistant home-assistant:2026.9.3 (+ one-shot config-seed), house-analysis (local Go build) home.… network_mode: host, privileged, Docker socket, /dev/ttyUSB0, D-Bus. Hand-written config is bind-mounted read-only from git. UI-edited automations/scripts/scenes.yaml stay in the state dir. Custom panels analysis and runs. house-analysis renders a daily 04:00 report on 127.0.0.1:8090 (energy, thermal, household patterns; see home-assistant/analysis/README.md). LAN Traefik reaches HA at http://192.168.178.48:8123 (hard-coded IP); Pangolin reaches it at host.docker.internal:8123.
bitwarden vaultwarden/server:1.37.3 pass.… (Cloudflare-proxied) Data /mnt/ssd/bitwarden/data.
mcp gitea-mcp-server:1.7.0, grafana/mcp-grafana:1.5.1 (read-only Prometheus/Loki), max/max-mcp:latest mcp.… The personal MCP server (mail, Paperless, HA, calendar, notes…). mcp/.env: SILVERBULLET_URL, PAPERLESS_URL, JELLYSEERR_URL, JELLYFIN_URL point at http://192.168.178.92:<port>; MARKA_BASE_URL is the public URL. The Jellyfin, SilverBullet and Paperless tools were checked on 30 Sep. The gitea-mcp healthcheck is overridden because upstream's is broken (distroless image, no /bin/sh).
otel otelcol-contrib:0.161.0 (compose.asturias.yml, asturias.env), speedtest-exporter (hourly) — Collects docker logs + host/docker metrics + Prometheus scrapes → LGTM. OTLP in on 127.0.0.1:4318 (the backup script pushes restic metrics here). smartctl-exporter is not deployed (H8).
wg max/wg-manager:latest wg.… No LAN rewrite, so LAN clients go out via c.h.main.
noise-analyzer max/noise-analyzer:latest noise.… Stores audio. Publishes :8000 on the host.
wordcloud max/word-analyze:latest wordcloud.… Headless Firefox (shm_size: 1gb).
octoprint octoprint/octoprint:1.11.8 — (LAN :3010) Uses a device-cgroup rule for USB serial so it starts with the printer unplugged.
filebrowser hurlenko/filebrowser:v2.63.23 files.… Disabled: in no deploy list since the migration. It mounted /mnt/hdd/file_data, /mnt/kemper (both now on l.jezerca) and all of /home/max, read-write, and was published on the internet.
(outside git) 6 × moby/buildkit builders (23 Sep) — Leftovers from docker buildx. Remove with docker buildx prune -a / docker buildx rm.

4.5 Host scripts (~/bin)

Script What Scheduled?
save_notes.sh Deno script: git add -A in Notes, has OpenAI write a conventional-commit message, commits and pushes. The Notes folder is now on l.jezerca (M12). max crontab, every 5 min
backup/all.sh → make_backup_gcs.sh restic → GCS, one repo per dataset, pushes OTLP metrics (restic.backup.*) to the local collector. Most sources are now on l.jezerca (H4). Log owned by root, so probably root's crontab (unverified, no sudo). Last run 1 Sep 00:53.
save_security_cams.sh, convert_stills_to_mp4.sh Grab stills from the ESP32 cam at 192.168.178.190 into /mnt/hdd/file_data/WebCams/<date>/ and make timelapses. That path is now on l.jezerca (M12). Not in max's crontab (unverified)
check_ping_and_restart_iwd.sh Wi-Fi watchdog Obsolete (the box is wired)
restic, otelcol-contrib.tar.gz Binaries —

sync_acme.sh is not on this host. It runs from the workstation (~/bin/sync_acme.sh, see 8.3).


5. l.jezerca — the media and data server

In service since 30 Sep. Everything here comes from ; the
OS, kernel and Docker versions weren't recorded.

5.1 Hardware & OS

5.2 Storage

Mount Device FS Size / free Holds UUID
/mnt/ssd internal NVMe (1 TB) not recorded not recorded State of the stacks here: immich/pgdata (PG 14, 4.5 GB), immich_pet-tagger-data, jellyfin/{configs,cache,gluetun,qbittorrent-ui}, notes (incl. SilverBullet's .ssh), paperless (data + DB) —
/mnt/hdd DAS: WD Red 1 T (WD10EFRX) ext4 916 G, ~166 G free (30 Sep) file_data (Notes, Documents inbox, WebCams), document_data (Paperless media), photos (Immich uploads), old gitea_data, nextcloud_data 73b28bd7-bdd0-4def-b486-d21402e5bbd4
/mnt/movies DAS: Seagate IronWolf 8 T (ST8000VN002) ext4 7.3 T / 57% used (23 Sep) COMMON_PATH: downloads, radarr/sonarr libraries, calibre, audiobooks, usenet, jellyseerr config 412bc0f2-da88-4172-ae7b-6d01e561501a
/mnt/kemper DAS: Seagate IronWolf 8 T (ST8000VN004) ext4 7.3 T / 5% used (23 Sep) Family photo archive (Immich external libraries), documents, reports 45e79310-b910-4d4f-875d-6afae9dfa481
/mnt/media DAS: Seagate Barracuda 1 T (ST31000528AS) NTFS (ntfs-3g) 932 G / 76% used (23 Sep) Pictures, drone mapping, music, projects, videos (mounted into Immich as /videos) 32D66984D6694965

The DAS moved physically, so nothing on it was copied. Only app state from asturias's
/mnt/ssd was copied (tar through a root container) and compared by entries, files, bytes
and an owner/mode fingerprint. The Immich model cache was skipped and gets rebuilt. No SMART
monitoring covers any of these disks (H8).

5.3 Deployment

5.4 Stacks (.doco-cd.jezerca.yaml, 26 containers on 30 Sep)

Image versions are from the 23 Sep inventory unless noted.

Stack Containers (image) Public hostname(s) Notes
newt fosrl/newt (same folder as asturias) — Own Pangolin site. newt/compose.jezerca.yaml swaps in jezerca.env (own NEWT_ID/NEWT_SECRET, via env_file: !override). Two hosts must never share one site's credentials. Creates the newt network here too.
immich immich-server:v3.2.2, immich-machine-learning:v3.1.0, valkey:9 (digest), immich-app/postgres:14-vectorchord0.4.3 (digest), immich-pet-tagger:cpu photos.… Uploads at /mnt/hdd/photos. External libraries: five /mnt/kemper/photos/* folders + /mnt/media/Videos. DB at /mnt/ssd/immich/pgdata (H2). Pet tagger on 127.0.0.1:2287. After the first smart search has downloaded the models, confirm the asset count and the face/search jobs (open).
jellyfin gluetun:v3.41.3 (ProtonVPN WireGuard + port-forwarding) → sabnzbd, qbittorrent, shelfmark share its netns; prowlarr, sonarr, radarr, bazarr:v1.6.2-ls366 (+subsync mod), flaresolverr:latest, jellyfin:12.0ubu2604-ls48, seerr:v3.4.1, audiobookshelf:2.36.1, calibre-web-automated:v4.0.6 movies.…, movie-request.… (+ /sonarr, /radarr, /prowlarr, /bazarr, /sabnzbd on the LAN), qbit.…, books.…, book-request.…, audiobooks.… gluetun's VPN_PORT_FORWARDING_UP_COMMAND pushes the forwarded port into qBittorrent's API. Sonarr and Radarr reach qBittorrent and SABnzbd via host gluetun (ports 8080 / 8085), never the host IP (5.1). SABnzbd: url_base = /sabnzbd, host_whitelist has movie-request.max-richter.dev and gluetun. Bazarr was crash-looping from 29 Sep 12:22 (Can't locate revision '537e9b4d10e3': something ran v1.6.1+ against the v1.6.0 database); fixed by pinning v1.6.2, safety copy …/configs/bazarr/db/bazarr.db.pre-1.6.2-2026-09-30. Jellyfin 12's API only accepts Authorization: MediaBrowser Token="…"; X-Emby-Token and ?api_key= return 401.
notes silverbullet:2.11.0-slim ×2, max/marka:latest notes.…, notizen.… (public space), marka.… Both Silverbullets mount /mnt/hdd/file_data/Notes (the public one only Notes/public). silverbullet publishes 3000 and marka 8088 for Traefik on asturias. No gitea_gitea network any more: SilverBullet's /mnt/ssd/notes/.ssh/config uses HostName 78.47.148.93 (+ HostKeyAlias, with Gitea's public host key in known_hosts); backup in /mnt/ssd/notes/ssh-backup-2026-09-30/. marka serves the notes as structured JSON (API key in plaintext in git, M3).
paperless paperless-ngx:3.2.1, valkey:9.1.2-alpine docs.… Consume dir /mnt/hdd/file_data/Documents, media /mnt/hdd/document_data, data (incl. the DB) /mnt/ssd/paperless/data. paperless-server publishes 8000 for Traefik. A post-consume script sends to Telegram.
otel otelcol-contrib (compose.jezerca.yml, jezerca.env), jellyfin_exporter (jezerca-exporter.env, token only) — Needs its own collector because host metrics, docker stats and container logs are read locally. Logs reach Grafana with host_name=l.jezerca. jellyfin_exporter reports jellyfin_up 1 against Jellyfin 12. The SMART receivers are commented out (H8).

5.5 Jellyfin hardware transcoding


6. c.h.main — the edge / ingress VPS

6.1 Machine

6.2 Deployment model — auto-sync.sh

6.3 Stacks

Stack Containers — pinned / running Role
pangolin pangolin ee-1.23.0 / ee-1.21.1 · gerbil 1.5.2 / 1.5.0 · traefik v3.7.13 / v3.7.11 · anubis v1.27.0 · otel-collector 0.161.0 / 0.159.0 The whole public ingress. gerbil owns ports 80, 443, 22, 51820/udp, 3923. Traefik runs in gerbil's network namespace and loads routers from Pangolin's HTTP provider (/api/v1/traefik-config, polled every 10 s) plus static files in config/traefik/rules/. Certificates: ACME resolver letsencrypt with Cloudflare DNS-01, prefer_wildcard_cert: true → *.max-richter.dev. The websecure entrypoint trusts Cloudflare's IP ranges for X-Forwarded-*. Access logs, metrics and traces go out over OTLP; access logs pass through the local collector with a GeoIP processor (GeoLite2-City) before reaching LGTM. Two newt sites now, one per home server.
nginx nginx 1.31.6 / 1.31.3 · sftp (atmoz/sftp:debian@sha256, :2221) Static hosting from /mnt/static-content/nginx_data. Vhosts: default.conf (apex site, /fumble/ SPA, /install.sh → proxies the dotfiles installer from Gitea, strict CSP/HSTS); nodes.conf (*.nodes.max-richter.dev → per-subdomain folders); splitnir.conf (splitnir.app, SvelteKit static, immutable caching). CI uploads over SFTP. init-ftp.sh rebuilds authorized_keys robustly.
mail docker-mailserver 16.0.1 / 15.1.0 · roundcubemail 1.7.4-apache / 1.7.3 Mail for max-richter.dev (+ silcherstrasse.com), 4 accounts. Ports 25/143/465/587/993/4190. TLS comes from Pangolin's acme.json (SSL_TYPE=letsencrypt). Rspamd is on (DKIM signing is done by Rspamd, so ENABLE_OPENDKIM=0 is expected), plus fail2ban, managesieve and quotas. ClamAV, SpamAssassin, Postgrey and DNSBL are off. Roundcube uses a custom max skin (tracked in git), SQLite, and plugins (contextmenu, persistent_login, easy_unsubscribe, …).

6.4 Static Traefik rules (pangolin/config/traefik/rules/)


7. c.h.apps — the Coolify app VPS

7.1 Machine

7.2 Coolify

7.3 Applications (from Coolify's DB + container labels)

Coolify name Hostname Source / image State
Splitnir API api.splitnir.app max/splitnir (Dockerfile) running, healthy
memorium memorium.max-richter.dev max/memorium running, healthy (466 MiB)
nodarium-app nodes.app.max-richter.dev + PR previews 27.…, 31.… max/nodarium running (previews up for 2 weeks)
plant-calendar plant-calendar.app.max-richter.dev max/plant-calendar running
datasette data.app.max-richter.dev max/bussgeld-data running twice
hn2.max-richter.dev (sic) hn.max-richter.dev max/hn.max-richter.dev running
hn.max-richter.dev (sic) hn2.max-richter.dev max/hn.svelte.dev exited
silvester-23 / 24 / 25 / 26 s23.app.…, s24.app.…, s25.app.…, s26.max-richter.dev max/silvester-2x running. s25 also publishes :3000 publicly.
silvester-24-pb s24-pb.app.… coollabsio/pocketbase:latest running
silvester-24-s3 s3-s24.app.…, s3-console-s24.app.… minio:latest running
minio s3.max-richter.dev, s3-console.max-richter.dev minio:latest running
docker-image (isync) isync.app.max-richter.dev max/isyncrasy:latest running
docker-lgtm observe.max-richter.dev grafana/otel-lgtm:latest running (1.24 GiB). Observability backend for all hosts.
umami umami.max-richter.dev umami:postgresql-latest + postgres 16 running (web analytics)
wanderer wanderer.…, admin.wanderer.… flomp/wanderer-{web,db}:v0.20.0 + meilisearch v1.36 running
cryptpad drive.max-richter.dev image exited
invoice invoice.app.max-richter.dev max/invoice exited

Outside Coolify: /srv/compose.yaml runs an otel-collector:latest on the coolify
network (host metrics + docker logs → LGTM). It was placed by hand in Dec 2025 and isn't in
any repo.


8. Cross-cutting concerns

8.1 Domain map

Hostname Served by Path
max-richter.dev, jim-fx.com nginx @ c.h.main Pangolin resource (local site). Apex is Cloudflare-proxied.
nodes.max-richter.dev, *.nodes.max-richter.dev, splitnir.app nginx @ c.h.main Pangolin resource / static rule
mail.max-richter.dev roundcube @ c.h.main Pangolin resource
pangolin.max-richter.dev pangolin @ c.h.main Anubis → UI; /api/v1 direct
git.max-richter.dev (HTTPS + SSH :22) gitea @ l.asturias Anubis → tunnel; SSH via raw resource → gitea:2222
gitai, mcp, noise, wg, wordcloud, home .max-richter.dev containers @ l.asturias Pangolin resource → newt → container name on newt (HA via host.docker.internal:8123)
photos, movies, movie-request, docs, notes, marka, qbit .max-richter.dev containers @ l.jezerca Pangolin resource; all checked from outside on 30 Sep. Whether each one targets l.jezerca's site directly or goes through Traefik on asturias isn't recorded. On the LAN, qbit goes through Traefik on asturias.
notizen, books, book-request, audiobooks .max-richter.dev containers @ l.jezerca Pangolin resource; not part of the 30 Sep check
pass.max-richter.dev vaultwarden @ l.asturias Cloudflare-proxied → c.h.main → tunnel
files.max-richter.dev — filebrowser is disabled (4.4)
traefik.max-richter.dev — Pangolin resource disabled
minecraft.max-richter.dev — Pangolin resource with no target
observe, umami, memorium, s3, s3-console, wanderer, admin.wanderer, s26, hn, *.app.max-richter.dev, api.splitnir.app c.h.apps Coolify proxy (Traefik)

8.2 Request paths

From the internet to a home service (e.g. photos.max-richter.dev):
client → 78.47.148.93:443 (gerbil) → Traefik (router from Pangolin, wildcard cert) → WireGuard (gerbil ⇄ newt) → newt on the target host → container on that host's 'newt' network. Each home server has its own newt and Pangolin site. For services on l.asturias
the LAN Traefik is not involved. For the moved stacks, a resource can also point at Traefik
on asturias, which then forwards to l.jezerca.

From the LAN (e.g. the same hostname): AdGuard answers 192.168.178.48 → Traefik on
l.asturias (wildcard cert). For services on asturias, a Docker-label router sends it to the
container. For services on l.jezerca, a file-provider router (dynamic/jezerca.yaml) sends it
to http://192.168.178.92:<published port>. Only hostnames with both an AdGuard rewrite
and a Traefik route work this way.

wg has a Traefik label but no rewrite. The others have no LAN Traefik route at all (the LAN
Traefik returns 404 for them).

Git from containers:

8.3 DNS & certificates

8.4 Deployment & updates

l.asturias / l.jezerca c.h.main c.h.apps
Trigger Gitea push webhook, per host (/v1/webhook/<host>). No polling. 60-s timer poll Coolify webhooks / manual
Applies Changed stacks in that host's list; recreates only changed services Changed dirs: up -d on compose change, restart on mapped config change Rebuilds the app
Secrets SOPS/age in git Plain .env files on the server, gitignored (mailserver.env, pangolin/.env, sftp-config/) Coolify DB
3rd-party image updates Renovate PR → merge Renovate PR → merge :latest + Coolify redeploy (mostly unpinned)
Own images force_image_pull digest compare, only when a push touches the stack — Built from git
Drift detection Only when a webhook arrives; a missed one stays missed until the next push or a redelivery No (H1) No

Both repos share a near-identical renovate.json and .gitea/workflows/renovate.yaml. The
workflows run on the home Gitea runner, as Renovate <[email protected]>.

8.5 Observability

8.6 Secrets inventory (locations only)

Where What State
home-server/**/.env, paperless/docker-compose.env Service credentials ✅ SOPS/age, key at /opt/doco-cd/secrets/ on each home server
home-server/newt/jezerca.env l.jezerca's own Pangolin site credentials Must never be shared with asturias's site
home-server/notes/compose.yaml marka API key ❌ plaintext in git
main-server/pangolin/config/traefik/traefik_config.yml LGTM ingest Basic auth ❌ plaintext in git
main-server/pangolin/config/config.yml Pangolin server.secret ⚠️ tracked in git (git ls-files lists it)
/srv/*/.env, mail/mailserver.env, nginx/sftp-config/ on c.h.main Various Untracked on disk, not backed up
/opt/doco-cd/secrets on l.jezerca Read-only deploy key for home-server Dir must be owned by max
~/bin/backup/{password,.env,*.json} on l.asturias restic password, GCS SA key ❌ mode 644 (H5)
~/bin/save_notes.sh env OPENAI_API_KEY (from .zshrc) —

8.7 Backups — current vs. needed

The only backup job (restic → GCS) runs on l.asturias. Anything that now lives on
l.jezerca is out of its reach (H4).

Data Host · location Backed up?
Notes l.jezerca · /mnt/hdd/file_data/Notes ⚠️ in git (Gitea + clones); the restic source moved away
Paperless documents l.jezerca · /mnt/hdd/document_data ⚠️ was restic; the source moved away
Paperless DB l.jezerca · /mnt/ssd/paperless/data ❌
Vaultwarden l.asturias · /mnt/ssd/bitwarden ✅ restic (live SQLite copy)
Gitea repos + LFS + registry l.asturias · /mnt/ssd/gitea/gitea_data (40 G) ❌ the script backs up /mnt/hdd/gitea_data, a stale copy that is now on l.jezerca
Gitea Postgres l.asturias · /mnt/ssd/gitea/postgres ❌
Immich uploads l.jezerca · /mnt/hdd/photos ⚠️ was restic; the source moved away
Immich DB l.jezerca · /mnt/ssd/immich/pgdata ❌
Family photo archive l.jezerca · /mnt/kemper/photos (single 8 T disk) ❌
Home Assistant state l.asturias · /mnt/ssd/home-assistant/config ❌
Media configs (*arr, Jellyfin) l.jezerca · /mnt/ssd/jellyfin/configs ❌
AdGuard l.asturias · /mnt/ssd/adguard ❌
Mail c.h.main · /srv/mail/docker-data (532 M) ❌
Pangolin DB + acme.json c.h.main · /srv/pangolin/config ❌
Coolify DB + app volumes c.h.apps ❌ (0 scheduled)
LGTM history c.h.apps volume ❌ (probably acceptable)

For databases, a pg_dump/sqlite3 .backup pre-step before restic gives consistent copies.
The simplest broad fix is now one job per home server: all of /mnt/ssd on both (after
dumping the DBs), since the repo keeps all state there, plus the DAS data on l.jezerca
(/mnt/hdd, /mnt/kemper/photos).

8.8 Dependencies & failure modes

flowchart TB
  DNS[public DNS] --> MAIN
  MAIN["c.h.main<br/>gerbil/traefik · pangolin · mail · nginx"]
  AST["l.asturias<br/>gitea + registry + runner/renovate · AdGuard (LAN DNS) · LAN traefik · HA · doco-cd"]
  JEZ["l.jezerca<br/>immich · jellyfin · notes · paperless · the DAS · doco-cd"]
  APPS["c.h.apps<br/>coolify · LGTM"]
  MAIN <-->|WireGuard| AST
  MAIN <-->|WireGuard, own site| JEZ
  MAIN -->|auto-sync git fetch| AST
  APPS -->|coolify git clone| AST
  AST -->|push webhooks · LAN traefik → published ports| JEZ
  JEZ -->|DNS · git and registry pulls via c.h.main| AST
  MAIN & AST & JEZ -->|OTLP| APPS
If this is down… …then
c.h.main Every public hostname is gone (services on both home servers, Gitea, websites), plus mail. c.h.main's own deploys stall because Gitea is only reachable through c.h.main. Coolify can't clone. doco-cd on both home servers and SilverBullet can't reach Gitea over SSH either (public address only). The LAN keeps working through AdGuard + the LAN Traefik, for the rewritten names.
l.asturias Gitea, the registry, Renovate and CI are gone, so no deploys anywhere (auto-sync can't fetch, Coolify can't build, no webhooks, own :latest images can't be pulled). LAN DNS (AdGuard) is gone for the whole LAN, including l.jezerca, and so is the LAN Traefik: on the LAN, l.jezerca's services are reachable only by IP and port. If it's also the DHCP server (port 67 is published), new LAN leases fail (unverified).
l.jezerca Photos, movies/*arr, notes, docs, marka and the books services are gone, and the MCP tools for Jellyfin, Jellyseerr, SilverBullet and Paperless fail. The DAS goes with it.
c.h.apps Observability is gone for all hosts, plus the Coolify apps.
Gitea container only doco-cd can't repair it (circular). Recovery is in home-server/README.md (decrypt in place, up -d, git checkout the env files).
Traefik/gitea redeploy The same doco-cd run can't pull git.max-richter.dev images. With no polling there's no automatic retry: redeliver the webhook from Gitea's log (4.3).
Workstation Nothing, until the LAN wildcard cert expires (2026-12-22) without sync_acme.sh being run.

9. Suggested action list

Ordered roughly by value per minute of effort. The migration's own open items are tracked in
.

  1. Backups (H4): before the next restic run on l.asturias, check what it does with the missing DAS paths and stop it from pruning good snapshots. Then add a job on l.jezerca for /mnt/ssd (after DB dumps) and the DAS (/mnt/hdd, /mnt/kemper/photos), fix asturias's job to cover its own /mnt/ssd (Gitea from /mnt/ssd/gitea/gitea_data, plus DB dumps), add c.h.main (/srv) and Coolify's DB, and alert on restic.backup.last_run.timestamp_seconds.
  2. SMART (H8): deploy smartctl-exporter on l.jezerca (DAS disk IDs + NVMe), optionally a second instance on l.asturias, re-enable the receivers in both collector configs, and add an alert.
  3. c.h.main: apply the pending images: cd /srv/nginx && docker compose up -d, cd /srv/pangolin && docker compose up -d, then mail after reading the docker-mailserver v16 notes. Add a 2 GB swapfile first (H1, H7).
  4. c.h.main: resize2fs /dev/sdb (M1). Remove 3923:3923 from gerbil's ports: (M2).
  5. l.asturias: chmod 600 ~/bin/backup/* (H5). From 2026-10-07: remove the 23 stopped containers and their images, the old copies under /mnt/ssd/{immich,jellyfin,notes,paperless}, and the idle buildx builders, then prune again. Keep house-analysis:local and notes-bridge:latest (built locally) (H3).
  6. l.asturias: move the host scripts and the NFS export that point at the DAS to l.jezerca, or retire them (M12).
  7. l.jezerca: turn Wi-Fi off, point ~/.ssh/config and the Gitea webhook at 192.168.178.92, and measure throughput with iperf3. Watch /mnt/hdd capacity (~166 GB free) (M11). Confirm Immich's asset count and face/search jobs once the models are downloaded.
  8. c.h.apps: Hetzner Cloud Firewall (22/80/443 only), PasswordAuthentication no, apt install unattended-upgrades, reboot into the new kernel (H6, M6).
  9. Secrets: move the marka key into a SOPS env file and the LGTM Basic auth out of git, then rotate both (M3).
  10. auto-sync: add drift reconciliation (compare docker compose config --images with running containers, or keep an "applied" marker) so a manual pull can't strand an update again.
  11. l.asturias: apt full-upgrade + reboot (openssh, kernel). Consider moving from sid to trixie (M5). Clean up the stale /mnt/ssd git checkout and fix the README install step.
  12. Tidy-ups from the Low list: stale comments, dead labels, update.sh, UFW rules, CrowdSec, the duplicate datasette, the PR previews, the Jellyfin Renovate regex (M8), doco-cd LOG_LEVEL.

10. Appendix — operating cheat-sheet

# ---- l.asturias / l.jezerca (doco-cd works the same on both) ----
ssh l.asturias                                          # 192.168.178.48
ssh l.jezerca                                           # alias still points at Wi-Fi .90; wired is .92
docker logs -f doco-cd                                  # deploy log
docker ps --filter label=com.docker.compose.project=<stack>
# No polling: restarting doco-cd deploys nothing. Redeliver the push from Gitea's webhook
# log, or send a signed POST locally to http://127.0.0.1:9080/v1/webhook/<host>?wait=true
SOPS_AGE_KEY_FILE=/opt/doco-cd/secrets/sops_age_key.txt sops edit <stack>/.env
# NEVER: docker compose up -d by hand where env files are SOPS-encrypted (starts with ENC[...] secrets)
tail -f /var/log/backup.log                             # l.asturias only

# ---- c.h.main ----
ssh c.h.main                                            # port 2222
journalctl -u main-server-sync -f
systemctl start main-server-sync                        # sync now
cd /srv/<dir> && docker compose config --images         # what SHOULD run
docker ps --format '{{.Names}} {{.Image}}'              # what DOES run
docker exec mailserver setup email list

# ---- c.h.apps ----
ssh c.h.apps
# Coolify UI: use its HTTPS domain, not :8000
docker exec coolify-db psql -U coolify -d coolify -c 'select name,fqdn,status from applications'

# ---- workstation ----
~/bin/sync_acme.sh                                      # refresh LAN wildcard cert (before 2026-12-22)

Repo references