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
- The short version
- Findings, by priority
- Topology
- l.asturias — the core home server
- l.jezerca — the media and data server
- c.h.main — the edge / ingress VPS
- c.h.apps — the Coolify app VPS
- 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
- 8.1 Domain map · 8.2 Request paths · 8.3 DNS & certificates · 8.4 Deployment & updates ·
- Suggested action list
- 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
- c.h.main dead config:
- The Traefik labels in
mail/compose.yaml(certresolver=cloudflare,traefik-forward-auth@docker) do nothing: Pangolin's Traefik hasproviders.docker: null, and Roundcube is actually routed by a Pangolin resource. auto-sync.shmapsmail/roundcube-config/*, which doesn't exist (the config ismail/roundcube/config/, gitignored).update.shat the repo root is the pre-Renovate "pull everything andup -d" script. Running it now would fight the pinned-version workflow. Delete it.certbot.timerruns with no certificates in/etc/letsencrypt/live.- fstab lists
HC_Volume_8007847, which isn't attached (harmless thanks tonofail). - Stale UFW rules for
7890,27896,8080. - The
openobserve-agentuser (also on c.h.apps) has a login shell. OpenObserve has been replaced by LGTM.
- The Traefik labels in
- c.h.main nginx writes a large header dump to
/var/log/nginx/test.logfor every request (incoming_headerslog format). It looks like a debugging leftover. It also counts against the 88%-full root disk. - Pangolin config:
api.insecure: trueon Traefik. Port 8080 isn't published, so this is internal only. Themmconfig atpangolin/mm/expects it onhost.docker.internal:8080.serversTransport.insecureSkipVerify: true.- The otel exporter uses
tls.insecure: truetowards an HTTPS endpoint. - On 23 Sep the
qbitPangolin resource pointed at0.0.0.0:9999on c.h.main (a placeholder). qbit was among the hostnames checked from outside on 30 Sep, so it may have a real target now. Theminecraftresource has no target.
- home-server stale docs:
renovate.jsonstill says "watchtower deploys those".filebrowser/compose.yamlmentions "the deploy timer"..gitignoresays "ten" SOPS files; there were twelve on 23 Sep, and the per-hostnewt/otelenv files came since.doco-cdstill hadLOG_LEVEL: debugon 23 Sep ("until the migration has settled", meaning the earlier move to doco-cd, which has settled).README.md's install stepcd /mnt/ssd && git pulldoesn't work on l.asturias: that checkout is stale and dirty, and has an unreadableimmich/pgdata.stale-2026-08-21/.
- Unpinned third-party images on l.jezerca:
flaresolverr:latest,immich-pet-tagger:cpu, and bazarr'sDOCKER_MODS=…:latest(the bazarr image itself is pinned tov1.6.2-ls366since 30 Sep). The Immich server (v3.2.2) and ML (v3.1.0) versions also differed on 23 Sep. Immich expects them to match. - gluetun publishes
51820/tcp+udpon the LAN for no clear reason (it's a WireGuard client). jellyseerrkeeps its config under${COMMON_PATH}/configs/(the movies disk), while every other *arr keeps it under/mnt/ssd/jellyfin/configs/.- c.h.apps Coolify clutter:
datasetteruns twice (datasetteanda4gswc8…-223935664408, same Host rule).- The nodarium PR previews
pr-27andpr-31have been up for two weeks. - Three apps are
exited(cryptpad, the old hn, invoice). - The app named
hn.max-richter.devserveshn2.…and vice versa.
- Unused / leftover services on l.asturias:
iwd/wpa_supplicantandcheck_ping_and_restart_iwd.sh(the box is on wiredenp3s0f0).virtlogd/virtlockd(libvirt).podman-auto-update.timer(inactive)./etc/exportsexports/mnt/projects, which isn't a mount (and/mnt/media, see M12).- The 23 stopped pre-migration containers (
restart=no) and the old copies under/mnt/ssd/{immich,jellyfin,notes,paperless}. Kept for rollback until 2026-10-07.
- Privacy note:
~/bin/save_notes.sh(cron on l.asturias, every 5 min) sends each notes diff, up to 8 000 chars, to OpenAI (gpt-4o-mini) to write the commit message.
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
- Apple Mac mini 7,1 (Late 2014), Intel i5-4278U (Haswell, 2 cores / 4 threads), 7.6 GiB RAM. Swap is 9.5 GiB in total: a 976 MiB partition,
/swapfile(its fstab line was re-added on 30 Sep), and 4.6 GiB of zram. - Haswell iGPU at
/dev/dri. VAAPI works only through the i965 driver, with no HEVC/VP9/AV1 hardware decode. It used to transcode for Jellyfin; that now runs on l.jezerca (5.5). - Debian GNU/Linux forky/sid, kernel
7.1.7+deb14(23 Sep). Docker 29.8.0, Compose v5.5.1. Rootless Podman runs for usermax(used by the AI reviewer). - Wired:
enp3s0f0, 192.168.178.48/24, default gateway FritzBox 192.168.178.1, plus a global IPv6 address.- During the migration a bad cable dropped it off the LAN, and two DHCP clients fought:
systemd-networkd(metric 10) anddhcpcd(metric 1002), started by the udev unit[email protected]. Fixed withsystemctl mask [email protected]. - Once it had no IPv6 default route (RA routes only).
ping -6worked after a reboot. Keep an eye on it.
- During the migration a bad cable dropped it off the LAN, and two DHCP clients fought:
- Host services:
docker,ssh,fail2ban,smartmontools,nfs-server(+ rpcbind),exim4(localhost only),avahi,bluetooth,iwd,libvirtlog/lock daemons,sysstat,lynis.timer(daily audit). /etc/docker/daemon.jsonsets the log tag{{.Name}}|{{.ImageName}}|{{.ID}}(which the otel collector parses into Loki'sservice_name) and container DNS1.1.1.1 / 8.8.8.8 / 8.8.4.4. The public resolvers are also what lets containers reach Gitea's SSH (8.2). Nomax-sizeis set for json logs, unlike both VPSes.
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)
- The repo lives at
git.max-richter.dev/max/home-server(on l.asturias's Gitea). - doco-cd 0.110.1 (
_doco/doco-cd.compose.yaml, deployed by hand) runs on both home servers and clones the repo into/opt/doco-cd/data(mode 700). On l.asturias it was recreated fromgit show origin/main:_doco/doco-cd.compose.yaml, because the/mnt/ssdcheckout there is stale. - Per-host deploy lists, selected by the webhook path: Gitea's push webhook calls
/v1/webhook/<host>on port 9080, and doco-cd reads.doco-cd.<host>.yaml. A stack belongs in exactly one list, exceptnewtandotel, which run on both. Auto-discovery is off on purpose so doco-cd never redeploys itself. doco-cd never stops a stack that leaves a list: stop the old containers by hand. - No polling (it polled every 5 min until the migration). This means:
- a restart of doco-cd deploys nothing;
- a missed webhook stays missed until the next push. Redeliver it from Gitea's webhook log;
force_image_pull: true(noise-analyzer, mcp, wg, gitea, wordcloud; it replaces watchtower) only runs when a push touches that stack. A new build of an own:latestimage isn't picked up on its own;- the first deploy on a host needs a webhook delivery. A signed local POST to
http://127.0.0.1:9080/v1/webhook/<host>?wait=trueworks when Gitea's delivery failed.
- Git access: doco-cd clones over SSH through the public address (8.2). l.jezerca's deploy key is read-only and lives in
/opt/doco-cd/secrets(thesecretsdir must be owned bymax). - Secrets: the
.envfiles are SOPS/age-encrypted and committed. The age key is at/opt/doco-cd/secrets/sops_age_key.txton each host. doco-cd decrypts them in place inside its clone. That's why runningdocker compose up -dby hand in a checkout starts services withENC[…]values. The README warns about this. - Renovate runs in Gitea Actions every Monday at 04:00 UTC and opens one PR per service directory. Database majors are excluded.
- doco-cd errors seen on 22/23 Sep, all transient:
- 22 Sep: Docker Hub pulls failed with "x509: certificate is valid for …fritz.box…". The FritzBox briefly answered DNS for
registry-1.docker.io. The daemon's own lookups ignoredaemon.jsondns, which applies only to containers. - 23 Sep:
ssh: handshake failed: EOFand502pulls while Traefik and Gitea restarted. This is the known self-dependency: the registry sits behind the thing being redeployed.
- 22 Sep: Docker Hub pulls failed with "x509: certificate is valid for …fritz.box…". The FritzBox briefly answered DNS for
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
- AMD Ryzen 5 7530U (6 cores / 12 threads), 15 GiB RAM, 1 TB NVMe. Radeon iGPU (amdgpu, Mesa radeonsi), used by Jellyfin for VAAPI (5.5).
- Network: wired 2.5 GbE at
192.168.178.92, with a FritzBox DHCP reservation ("Persistent IPv4"). It first linked at 100 Mbit/s because of a cable; now 1000 Mbit/s. No throughput measurement yet (noiperf3).- Wi-Fi
wlp3s0(192.168.178.90) is still up as a fallback.~/.ssh/configand possibly the Gitea webhook still use it. Switching both to.92and turning Wi-Fi off is open.
- Wi-Fi
- ufw is active. Docker-published ports bypass it, but a container that talks to the host's own IP goes through ufw's INPUT chain and gets dropped (
UFW BLOCK IN=br-… SRC=172.22.x DST=192.168.178.92 DPT=8085). Containers must reach each other by service name (gluetun), never via the host IP. The temporary8080,8085/tcp from 172.16.0.0/12rule is gone again. /etc/docker/daemon.json: same as l.asturias (publicdns1.1.1.1 / 8.8.8.8 / 8.8.4.4, log tag{{.Name}}|{{.ImageName}}|{{.ID}}). Containers only pick up the tag when they are created.- Name resolution for the host comes from AdGuard on l.asturias. While asturias is down, l.jezerca has no DNS.
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
- doco-cd as in 4.3, reading
.doco-cd.jezerca.yamlon webhook path/v1/webhook/jezerca. - Read-only deploy key in
/opt/doco-cd/secrets. - Don't run
docker composeby hand where env files are SOPS-encrypted. To stop a stack,docker stopits containers.
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
- VAAPI via Mesa radeonsi. Decode: H.264, HEVC Main/Main10, VP9 (incl. 10-bit), MPEG-2, VC-1. Encode: H.264, HEVC. Not supported: AV1, HEVC 12-bit/4:4:4, VP8.
encoding.xml:HardwareDecodingCodecsish264, hevc, mpeg2video, vc1, vp9(backupencoding.xml.bak-2026-09-30, which had asturias's Haswell list).AllowHevcEncodingis off; tone mapping is off (no HDR sources).- Same 4K HEVC 10-bit file: 1.3× realtime with CPU decode on asturias, 3.45× GPU end to end here. 1080p HEVC 10-bit: 9.1×. The ffmpeg log shows
-hwaccel vaapi,scale_vaapi,h264_vaapi, and nohwuploadorlibx264.
6. c.h.main — the edge / ingress VPS
6.1 Machine
- Hetzner Cloud vServer (
main-01), 1 vCPU Skylake, 1.9 GiB RAM, no swap. Debian 13, kernel 6.12.107. - Disks:
sda19 G root (88%).sdbis a Hetzner volume,HC_Volume_102570017, mounted at/mnt/static-content: 100 GiB device, 50 GiB filesystem (M1). It holds Docker'sdata-root(13 G), containerd (5.1 G),nginx_data(4.7 G), an oldMail(580 M),notes,sync_data. - Docker 29.7.2, Compose v5.4.0.
daemon.json: log rotation 10 M × 3, the otel tag,data-root=/mnt/static-content/docker. - sshd on port 2222, key-only. Host port 22 belongs to gerbil (Gitea SSH).
- UFW active (allow 22, 2222, 80, 443, mail ports, 2221, 51820/udp; stale 7890, 27896, 8080;
DENY 3923, which Docker bypasses). fail2ban active. CrowdSec bouncer active without its agent (M9).unattended-upgradeson.
6.2 Deployment model — auto-sync.sh
- The repo is checked out at
/srv(root-owned) from[email protected]:max/main-server. main-server-sync.timerruns every 60 s (OnUnitActiveSec).auto-sync.sh:timeout 60 git fetch. Gitea is reached through this box's own gerbil/Traefik, so a restart can leave a half-open connection.- Fast-forward only, because commits are sometimes made on the server.
- For each changed top-level dir:
docker compose up -d --remove-orphansif the compose file changed, thendocker compose restart <svc>for config files, using a path → service table (pangolin/config/traefik/*→ traefik,pangolin/config/*→ pangolin,nginx/*.conf→ nginx, …). The Traefik file provider has nowatch, so a restart is needed.
- The weak spot (H1): the apply step is keyed on
git diff old..new. Any out-of-bandgit pullmovesHEADwithout applying anything, and nothing ever reconciles it. - Renovate works like the home server's (weekly PRs, DB majors off, Traefik majors need dashboard approval because the
badgerplugin is pinned intraefik_config.yml). _archive/holds retired stacks (drone-runner, excalidraw, headscale, minio, notes, portainer, prometheus, syncthing, old traefik+CrowdSec, wireguard).
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. |
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/)
anubis.yml:pangolin.max-richter.dev. The UI goes through Anubis (priority 200)./api/v1bypasses it (priority 201). Anubis sends traffic back to entrypointanubis(:3923), where the UI and API routers pick it up.gitea.yml:git.max-richter.devon 443 → Anubis →:3923→http://100.89.128.4:55792, which is l.asturias's newt site address, i.e. through the tunnel to Gitea. HTTP on 80 redirects to HTTPS.secure-headersforcesX-Forwarded-Proto: httpsso the registry generates correct URLs.dynamic_config.yml: HTTP→HTTPS redirect for Pangolin, and the*.nodes.max-richter.devwildcard (its ownnodes.max-richter.dev+ SAN cert) → nginx.- Git SSH: the Pangolin raw TCP resource
git.max-richter.dev | SSHon :22 →gitea:2222on l.asturias's site. gerbil sends it with the PROXY protocol, which Gitea's SSH requires (8.2).
7. c.h.apps — the Coolify app VPS
7.1 Machine
- Hetzner Cloud vServer (
apps-01), 4 vCPU, 7.6 GiB RAM (5.5 G used), no swap. Debian 13, running kernel 6.12.90, 6.12.107 installed (69 days up). - Disks:
sdb38 G root (37%). A 100 G Hetzner volumeHC_Volume_103853122at/mnt/docker-overlayis Docker'sdata-root(47%). - Docker 29.8.0.
daemon.json: log rotation, the otel tag,default-address-pools 10.0.0.0/8 /24. - No firewall, no fail2ban,
PasswordAuthentication yes, no unattended-upgrades (H6, M6). - Only
rootandopenobserve-agenthave login shells.
7.2 Coolify
- Coolify 4.3.23 (+
coolify-dbpostgres 15,coolify-redis,coolify-realtime1.0.19,coolify-sentinel). Its proxy is Traefik v3.6 (coolify-proxy, :80/:443/:443-udp/:8080), with ACME at/data/coolify/proxy/acme.json. - UI published on
0.0.0.0:8000over HTTP, realtime on :6001–6002. - One server registered (
localhost). 0 scheduled database backups. - Git sources are
[email protected]:max/*, so every build depends on the home Gitea being reachable through c.h.main.
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.
- Rewritten to the LAN (23 Sep):
git,movies,photos,docs,notes,home,noise,movie-request,qbit. - Resolve to the public IP (hairpin through c.h.main):
wg,gitai,notizen,wordcloud,files,marka,mcp,books,audiobooks. - Through Cloudflare:
passand the apex. - 30 Sep, all Traefik routes to l.jezerca answer: movies, photos, notes, docs, qbit, and movie-request including
/bazarr,/radarr,/prowlarr,/sabnzbd./sonarrreturns 401 by design.
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:
- Git over SSH only works via the public address. On the LAN,
git.max-richter.dev:22is l.asturias's own sshd, and Gitea's SSH (host port 2222) only accepts PROXY-protocol connections, which only gerbil sends. Containers resolve the public IP78.47.148.93because Docker'sdnspoints at public resolvers. doco-cd on both hosts and SilverBullet on l.jezerca use this path; SilverBullet pinsHostName 78.47.148.93in its SSH config. - Over HTTPS, Traefik on l.asturias holds the alias
git.max-richter.devon thenewtnetwork, so containers there (runner, reviewer) reach Gitea without leaving the host. - doco-cd deliberately stays off
newt: via AdGuard, that name resolves to a newt-local address that refuses port 22.
8.3 DNS & certificates
- Public DNS: Cloudflare. Most records point directly at
78.47.148.93.max-richter.devandpass.are proxied (orange cloud). - Public certs: Pangolin's Traefik, Let's Encrypt, Cloudflare DNS-01,
*.max-richter.devwildcard preferred (stored in/srv/pangolin/config/letsencrypt/acme.json). docker-mailserver reads the sameacme.json. Coolify's Traefik on c.h.apps has its own ACME store. - LAN DNS: AdGuard on l.asturias serves the whole LAN, l.jezerca included. While asturias is down, nothing on the LAN resolves names. Containers on both home servers use public resolvers instead (
daemon.jsondns); the Docker daemon's own lookups (image pulls) still go to the host resolver. - LAN certs: the LAN Traefik has no resolver.
~/bin/sync_acme.shon the workstation pipesacme.jsonfrom c.h.main to l.asturias over two SSH hops, extracts the wildcard withjq, validates it (not expired, key matches), installs it and restarts Traefik. It runs by hand only. The current cert (installed 23 Sep 13:41) expires 2026-12-22. Pangolin renews around day 60, so re-run it in late October / November. The LAN falls back to Traefik's self-signed cert if this is forgotten. - AdGuard rewrites must use
$dnsrewritesyntax. Hosts-style entries return::for AAAA.
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
- Backend:
grafana/otel-lgtm:lateston c.h.apps (observe.max-richter.dev): Loki, Prometheus/Mimir, Tempo and Grafana in one container, with a single Grafana data volume and no backup. Ingest is protected with HTTP Basic auth. - Collectors (all
otelcol-contrib, all using the Docker log tagname|image|idto deriveservice.name):- l.asturias: docker logs, host/docker metrics, Prometheus scrapes (speedtest), OTLP in on
127.0.0.1:4318(restic metrics). - l.jezerca: docker logs (
host_name=l.jezerca), host/docker metrics,jellyfin_exporter(jellyfin_up 1). - Both home collectors come from one
otel/folder inhome-server(compose.asturias.yml/compose.jezerca.yml, a collector config per host, project nameotelon both). - c.h.main: docker logs, host/docker metrics, plus the Traefik access-log pipeline with GeoIP. Traefik also exports metrics and traces straight to LGTM.
- c.h.apps:
:latest, hand-deployed in/srv.
- l.asturias: docker logs, host/docker metrics, Prometheus scrapes (speedtest), OTLP in on
- Component names differed between hosts on 23 Sep (
file_log/host_metrics/otlp_httpon the Mac mini vsfilelog/hostmetrics/otlphttpon c.h.main), and the versions differ too (0.161 pinned, 0.159 running,latest). Both parse today. Aligning them avoids surprises on the next upgrade. - Queryable from Claude:
grafana-mcp(read-only Prometheus + Loki) in themcpstack on l.asturias. - Gaps: there is no SMART data at all (H8). Nothing alerts on backups (the
restic.backup.last_run.timestamp_secondsmetric exists), disk fill or cert expiry (unverified: I checked for alert rules in the repos only, not in Grafana).
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 |
❌ |
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
.
- 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 onrestic.backup.last_run.timestamp_seconds. - SMART (H8): deploy
smartctl-exporteron 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. - 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). - c.h.main:
resize2fs /dev/sdb(M1). Remove3923:3923from gerbil'sports:(M2). - 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. Keephouse-analysis:localandnotes-bridge:latest(built locally) (H3). - l.asturias: move the host scripts and the NFS export that point at the DAS to l.jezerca, or retire them (M12).
- l.jezerca: turn Wi-Fi off, point
~/.ssh/configand the Gitea webhook at192.168.178.92, and measure throughput withiperf3. Watch/mnt/hddcapacity (~166 GB free) (M11). Confirm Immich's asset count and face/search jobs once the models are downloaded. - c.h.apps: Hetzner Cloud Firewall (22/80/443 only),
PasswordAuthentication no,apt install unattended-upgrades, reboot into the new kernel (H6, M6). - Secrets: move the marka key into a SOPS env file and the LGTM Basic auth out of git, then rotate both (M3).
- auto-sync: add drift reconciliation (compare
docker compose config --imageswith running containers, or keep an "applied" marker) so a manual pull can't strand an update again. - l.asturias:
apt full-upgrade+ reboot (openssh, kernel). Consider moving from sid to trixie (M5). Clean up the stale/mnt/ssdgit checkout and fix the README install step. - 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-cdLOG_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
home-server/README.md: doco-cd rules, Gitea-down recovery, SOPS usage (its install step doesn't work on l.asturias)home-server/.doco-cd.asturias.yaml,home-server/.doco-cd.jezerca.yaml: the per-host lists of deployed stackshome-server/traefik/config/traefik/dynamic/jezerca.yaml: LAN routes from Traefik on asturias to l.jezercahome-server/newt/compose.jezerca.yaml,home-server/otel/compose.{asturias,jezerca}.yml: per-host overrideshome-server/home-assistant/analysis/README.md: the house-analysis reportmain-server/_deploy/README.md: auto-sync behaviour and the restart tablemain-server/pangolin/config/traefik/: static ingress config- : migration log, lessons learned and rollback