Z2 Mini Server — Context for AI Assistants¶
Purpose: This document provides technical context about Gabriel's existing self-hosted infrastructure on his HP Z2 Mini G5 server. Future AI assistants should read this before suggesting changes or new projects to avoid conflicting with established configuration.
⚠️ MAJOR UPDATE — May 2026: Caddy reverse proxy added. Read 17-caddy.md. A single Caddy container (locally built = stock
caddy+ thecaddy-dns/cloudflareplugin,network_mode: host, bound100.67.235.68:443+:80) is now the only HTTPS ingress. Every web app — Immich, Navidrome, Homepage, Beszel, Radicale, Vaultwarden — was re-bound to127.0.0.1:<port>only and is reached athttps://<service>.z2mini.gabrielgabrie.comwith an auto-renewing Let's Encrypt cert (ACME DNS-01 via the Cloudflare API — the box has no public inbound, so HTTP-01/TLS-ALPN-01 are unusable). The two oldtailscale servelisteners (:443Radicale,:8443Vaultwarden) are retired. DNS forgabrielgabrie.commoved to Cloudflare (Hostinger still the registrar);*.z2miniwildcard A →100.67.235.68; the Cloudflare API token lives in/data/docker/caddy/.env(mode 600). Tailscale--operator=gabrielis set; Tailscale global resolvers are1.1.1.1+8.8.8.8with "Override local DNS" (so tailnet devices resolve*.z2mini.gabrielgabrie.com— the housing router's DNS chokes on it). Throughout this document, any reference totailscale serve, to apps being "bound to the Tailscale interface IP", or tohttp://z2mini:<port>/http://100.67.235.68:<port>URLs reflects the pre-Caddy state — translate to: app on127.0.0.1:<port>, reach it viahttps://<svc>.z2mini.gabrielgabrie.com(orhttp://127.0.0.1:<port>on the box). 17-caddy.md and 10-system-reference.md are authoritative. Caddy's whole config is under/data/docker/caddy/(rsync-able, folds into nightly backup) — unlike the oldtailscale serveconfig which lived in tailscaled state.
Last updated: May 2026 (Radicale CalDAV/CardDAV server installed 2026-05-10 — replacing iCloud Calendar as the source of truth for personal scheduling. Single tomsquest/docker-radicale:3.7.2.0 container on port 5232, files-as-truth storage at /data/docker/radicale/data/collections/. Five iCloud calendars migrated via publish-as-iCal: 255 events across Events and Reminders, Deadlines, Conestoga Timetable, Bills, Work Schedule (alarms intentionally accepted-as-lost — iCloud's public-calendar feed strips VALARMs by design; Gabriel will re-add the ~10 important recurring alarms manually rather than use a CalDAV-to-CalDAV migration tool). iOS access requires HTTPS — iOS 18+ Calendar forces TLS regardless of in-app SSL toggle, so tailscale serve --bg http://127.0.0.1:5232 fronts Radicale with a Let's Encrypt cert at https://radicale.z2mini.gabrielgabrie.com/. Thunderbird on the laptop continues to use direct HTTP https://radicale.z2mini.gabrielgabrie.com. Two-week parallel-run window with iCloud Calendar still subscribed read-only; iCloud cut planned ~2026-05-24. iCloud subscription downgraded from 2 TB → 200 GB tier ($12.99 → $3.99/mo) after iCloud Photos cleared into Immich and iMessage attachments aggressively purged on-device. Post-purge iCloud usage ~150 GB: ~60 GB iMessage, ~85 GB Family Sharing pool, ~20 GB iPhone Backup. 50 GB tier was deliberately rejected — see Decisions table. Beszel system-metrics dashboard installed — hub+agent on port 8090, Tailscale-only, unix-socket communication, direct-to-Gmail SMTP alerts independent of msmtp's smartd channel. Homepage launcher dashboard installed — port 3000, Tailscale-only, native widgets for Immich + Navidrome + Tailscale + Docker socket integration for live container status. iCloud → Immich bulk migration complete as of 2026-05-05; 29,331 assets in Immich (~470 GB), /data/icloud-import deleted, icloudpd cookie still valid for incremental sync if desired. iCloud Photos was mass-deleted 2026-05-06 keeping only the ~100 most recent items via pyicloud-based ~/scripts/icloud-trim.py; ~31,288 of 31,298 deletions succeeded (10 transient failures), now sitting in iCloud's Recently Deleted (30-day soft-restore until ~2026-06-07). iOS Immich auto-backup enabled 2026-05-08 — Camera Roll → Immich is the daily-driver loop; new iPhone captures push automatically. GPU acceleration enabled on the Quadro T2000 — Immich ML container now uses CUDA via pre-signed linux-modules-nvidia-595-generic kernel modules + nvidia-container-toolkit. Earlier this month: Navidrome music streaming installed (port 4533, Samba [music] share, Subsonic clients play); Immich install with icloudpd + immich-go pipeline; system-state backup added; USB resilience improvements.)
How to Use This Document¶
If you're an AI assistant helping Gabriel with a project that involves his Z2 Mini server, read this entire document first. Pay particular attention to:
- Reserved paths — directories already in use that should not be repurposed
- Reserved ports — services already bound that should not be conflicted with
- Existing services — what's running and how it's configured
- Design decisions — choices already made and the reasoning, so you don't suggest reverting them
- What's intentionally NOT installed — Gabriel evaluated and rejected several common tools
Before suggesting infrastructure changes, ask Gabriel to confirm rather than assume.
Hardware¶
- Model: HP Z2 Mini G5 Workstation
- CPU: Intel Core i9-10900K (10 cores, 20 threads, up to 5.3 GHz)
- RAM: 64GB DDR4-3200 SODIMM (2x32GB), upgraded from original 4GB
- GPU: NVIDIA Quadro T2000 (4GB GDDR5) — modest, only 4GB VRAM, prefer CPU-based workloads for ML
- Storage:
- Drive 1: Samsung MZVLB1T0HBLR-000H1 (1TB NVMe) — OS drive, mounted at
/ - Drive 2: SK hynix PC601 HFS001TD9TNG-L2A0A (1TB NVMe) — data drive, mounted at
/data - Drive 3: Samsung 990 PRO 1TB NVMe SSD in an ASMedia ASM2462 USB enclosure — on-site backup drive, mounted at
/mnt/backup(~916 GB usable; replaced the old Samsung T5 in May 2026; refurb verified genuine before deploy — see below) - Drive 4 (normally NOT connected — lives at parents' house): Samsung Portable SSD T5 (500GB USB) — off-site rotation drive, reformatted as
backup-offsite, mounted manually at/mnt/offsitefor an off-site sync during family visits - Network: Wi-Fi only (currently student housing network, ethernet planned for September 2026)
Operating System¶
- Distribution: Ubuntu Server 24.04 LTS
- Kernel: Linux 7.0.0-15-generic (as of last update)
- Hostname:
z2mini - Primary user:
gabriel(insudoanddockergroups) - Ubuntu Pro: Not yet activated (Gabriel intends to enable later)
- Boot: UEFI, GPT partition table
Storage Layout¶
/dev/nvme1n1 (Samsung 1TB) — OS drive
├── /dev/nvme1n1p1 → /boot/efi (1G, vfat)
└── /dev/nvme1n1p2 → / (953G, ext4) ← shows as "nvme1n1p2" in the Beszel UI (root fs can't be renamed there)
/dev/nvme0n1 (SK hynix 1TB) — Data drive
└── /dev/nvme0n1p1 → /data (953G, ext4) ← shows as "Data" in the Beszel UI
/dev/sdX (Samsung 990 PRO 1TB NVMe in an ASMedia ASM2462 USB enclosure) — on-site backup drive
└── /dev/sdX1 → /mnt/backup (~916G usable, ext4, label=backup, reserved-blocks 0 via `tune2fs -m 0`)
UUID=8795cb2e-fe34-4543-b93b-dd45b642846f
fstab: UUID=8795cb2e-... /mnt/backup ext4 defaults,nofail 0 2
smartd / smartctl: /dev/disk/by-id/usb-ASMT_2462_NVME_2504178506CB-0:0 -d sntasmedia
← shows as "Backup" in the Beszel UI
(replaced the old Samsung T5 in May 2026; rollback files /etc/fstab.pre-990pro,
/etc/smartd.conf.pre-990pro, ~/scripts/backup-files.sh.pre-990pro)
/dev/sdY (Samsung Portable SSD T5 500GB USB) — OFF-SITE rotation drive — normally NOT connected
└── /dev/sdY1 → /mnt/offsite (mounted MANUALLY only, ext4, label=backup-offsite, reserved-blocks 0)
UUID=2c8e8e38-f129-4823-a1db-1529d3296b44
NO fstab entry — `sudo mount /dev/disk/by-label/backup-offsite /mnt/offsite`
holds ~3 GB: files, music, db-dumps, service-config, system-state — NOT the Immich library
Reserved paths — do not use for new projects without consultation:
/data/files/— Samba share root, user files. Touched by SMB clients (Windows, iOS)./mnt/backup/current/— nightly mirror tree:files/(=/data/files/),immich/(= Immich'slibrary/UPLOAD_LOCATION, incl.encoded-video/and Immich's ownbackups/DB dumps),music/(=/data/music/),db-dumps/(vaultwarden-db.sqlite3,beszel-data.db,beszel-auxiliary.db,navidrome.db— refreshed every run via hostsqlite3 ".backup"),service-config/<svc>/(each/data/docker/<svc>/config —.env+ compose + config, with bulk data / live DBs / container-owned-unreadable subdirs excluded)./mnt/backup/daily/YYYY-MM-DD/— 7 daily hard-linked snapshots ofcurrent/(dated by the run's START time)./mnt/backup/weekly/YYYY-MM-DD/— 4 weekly hard-linked snapshots, Sunday only./mnt/backup/system-state/— System configuration backups (package lists incl.sqlite3inmanual-packages.txt, the/etcconfig tarballs, crontab)./mnt/backup/backup.log— Backup log file, rotated weekly./mnt/backup/RECOVERY-README.md— Documented recovery procedure for full system rebuild (covers theservice-config/+db-dumps/+ Immich restore, the package-reinstall list withsqlite3, the Caddydocker compose build && up -dstep)./mnt/offsite/— mountpoint for the off-site T5 (backup-offsite). Created on the server; NO fstab entry — mounted manually only when the drive is plugged in.~/scripts/backup-offsite.sh(MANUAL, not in cron) copiescurrent/{files,music,db-dumps,service-config}/+system-state/onto it (~3 GB; the ~520 GB Immich library does NOT fit). Writes/mnt/offsite/offsite-backup.log./data/.beszel/and/mnt/backup/.beszel/— empty placeholder dirs bind-mounted into the Beszel agent at/extra-filesystems/data__Dataand/extra-filesystems/backup__Backupso the hub UI shows the disks as "Data" and "Backup". If the backup drive is swapped, recreate/mnt/backup/.beszel/anddocker restart beszel-agent./data/docker/immich/— Immich photo-storage service. Subdirs:library/(=UPLOAD_LOCATION:library/~254 GB photos +upload/~188 GB +encoded-video/~68 GB +thumbs/~7.6 GB +backups/~979 MB = Immich's own nightly.sql.gzDB dumps +profile/; bind-mounted to container; rsync-mirrored to/mnt/backup/current/immich/nightly with--no-owner --no-group),postgres/(Postgres data dir, mode 700, owned by the container uid, MUST NOT be filesystem-rsync'd — instead Immich's built-in nightly DB auto-backup at 02:00 writesimmich-db-backup-*.sql.gzintolibrary/backups/which the 03:00 rsync picks up; that setting must stay ENABLED),model-cache/(ML model weights — NOT backed up, regenerable), plusdocker-compose.yml,docker-compose.yml.pre-cuda,hwaccel.ml.yml,.env(mode 600, contains DB password) — these config files go to/mnt/backup/current/service-config/immich/./data/docker/icloudpd/— icloudpd one-shot iCloud Photos download tool. Subdirs:cookies/(Apple session cookies, root-owned, ~2 month validity from last successful auth) anddocker-compose.yml./data/docker/navidrome/— Navidrome music server. Subdirs:data/(SQLite DBnavidrome.db+ WAL, cache, thumbnails, plugins; uid 1000 —navidrome.dbis captured nightly via hostsqlite3 ".backup"into/mnt/backup/current/db-dumps/navidrome.db, NEVER filesystem-rsync the live file;data/artwork/+data/cache/are NOT backed up — regenerable) plusdocker-compose.ymland.env(these two go toservice-config/navidrome/; the wholedata/dir is excluded from that rsync). Music files are NOT stored here — they live separately at/data/music/so Samba can write into the music folder without exposing Navidrome's runtime data./data/music/— Music library; bind-mounted into Navidrome read-only as/music. Writable from\\z2mini\musicSamba share. Ownedgabriel:gabriel, mostly AAC 256 (.m4a) but FLAC also accepted (mixed-format library is fine — Navidrome and modern Subsonic clients decode both natively). rsync-mirrored to/mnt/backup/current/music/nightly; also on the off-site T5./data/docker/homepage/— Homepage launcher dashboard. Subdirs:config/(six YAML files:services.yaml,bookmarks.yaml,widgets.yaml,settings.yaml,docker.yaml,kubernetes.yaml) plusdocker-compose.ymland.env(mode 600 — holds widget API tokens for Immich, Navidrome, Tailscale). Homepage 1.x only auto-createssettings.yamlandkubernetes.yamlon first boot — the rest are hand-written from the doc..env+config/are rsync'd nightly into/mnt/backup/current/service-config/homepage/(and to the off-site T5)./data/docker/beszel/— Beszel system-metrics hub + agent. Subdirs:data/(hub SQLite DBsdata.db+auxiliary.db— both captured nightly via hostsqlite3 ".backup"into/mnt/backup/current/db-dumps/beszel-data.db+beszel-auxiliary.db, NEVER filesystem-rsync live; also holdsdata/id_ed25519= the hub's SSH key, root-owned so the backup user can't read it — silently excluded, regenerable but regenerating it means re-pairing the one agent),socket/(transient unix socket shared between hub and agent containers — never backed up),agent-data/(agent fingerprint + buffer; rebuildable — not backed up) plusdocker-compose.ymland.env(mode 600 — holds the agent SSHKEYand websocketTOKENissued by the hub on system registration; these two go toservice-config/beszel/, the wholedata/dir is excluded from that rsync). The agent's compose bind-mounts/data/.beszel:/extra-filesystems/data__Data:roand/mnt/backup/.beszel:/extra-filesystems/backup__Backup:ro— the__<Label>suffix sets the displayed disk name in the hub UI ("Data" / "Backup"; root fs always shows as the device namenvme1n1p2). Rollback for that change:docker-compose.yml.pre-disklabels./data/docker/radicale/— Radicale CalDAV/CardDAV server. Subdirs:data/collections/(calendars + contacts as a tree of.icsand.vcffiles — files-as-truth, owned2999:2999after container's first-boot auto-chown viaCHOWNcapability, rsync-safe — load-bearing: this dir alone restores every calendar),config/(Radicale config + htpasswd users file at mode 644 — bcrypt hashes are designed to be safe at the filesystem level) plusdocker-compose.ymland.env(no secrets in.env, just version pin). All of.env+ compose +config/+data/collections/are rsync'd nightly into/mnt/backup/current/service-config/radicale/— no SQLite/pg_dumpceremony, files-as-truth; verified byte-for-byte; the calendars are also on the off-site T5. (The rsync also pulls Radicale's.Radicale.cache/— harmless.) The HTTPS front-end is now the Caddy stack under/data/docker/caddy/(in the backup), nottailscale serveconfig in/var/lib/tailscale/(that's retired)./data/docker/vaultwarden/— Vaultwarden (Bitwarden-compatible password server). Subdir:data/(owned1000:1000— the container runs asuser: 1000:1000):db.sqlite3(+-shm/-wal, WAL mode — the vault; NEVER filesystem-rsync the live file — captured nightly via hostsqlite3 ".backup"into/mnt/backup/current/db-dumps/vaultwarden-db.sqlite3, which also goes to the off-site T5),rsa_key.pem(JWT signing key — load-bearing, rsync'd intoservice-config/vaultwarden/with the DB dump; losing it logs every client out),attachments/+sends/(appear once any exist — rsync'd intoservice-config/vaultwarden/),icon_cache/+tmp/(rebuildable/transient — NOT backed up),config.json(only if the admin panel was used — and then it overrides.env; rsync'd along). Plusdocker-compose.ymland.env(mode 600 — version pin +DOMAINwhich is the WebAuthn/passkey relying-party ID, load-bearing; would also holdADMIN_TOKEN/PUSH_*if those get added) — these go toservice-config/vaultwarden/; the livedata/db.sqlite3anddata/tmp/are excluded from that rsync. The HTTPS front-end is the Caddy stack under/data/docker/caddy/(in the backup), nottailscale serveconfig in/var/lib/tailscale/(retired)./data/docker/caddy/— Caddy HTTPS reverse proxy.Dockerfile,docker-compose.yml,Caddyfile,.env(mode 600 — the Cloudflare API token, load-bearing) — all rsync'd nightly into/mnt/backup/current/service-config/caddy/(and the off-site T5).config/caddy/(autosave) anddata/caddy/(ACME cert/key store) are NOT backed up — container-owned mode-700, unreadable by the backup user, and fully regenerable (Caddy re-issues every cert via the Cloudflare DNS-01 challenge on restore, given a valid.envtoken).— used as transient staging during the 2026-05 iCloud → Immich migration; deleted after immich-go confirmed ingest. Re-created if a future migration runs./data/icloud-import//home/gabriel/scripts/backup-files.sh— the nightly 3 AM backup script (0 3 * * *incrontab -l). Rsync mirrors (/data/files/, Immichlibrary/,/data/music/) +sqlite3 ".backup"of the 4 SQLite DBs intocurrent/db-dumps/+ per-service config rsync intocurrent/service-config/<svc>/+ hard-linked daily/weekly snapshots + prune + system-state. Depends on the hostsqlite3package. Old pre-990-PRO version preserved asbackup-files.sh.pre-990pro./home/gabriel/scripts/backup-offsite.sh— MANUAL, not in cron — the off-site rotation. Ritual (also in the script's header comment): plug the T5 into z2mini →sudo mount /dev/disk/by-label/backup-offsite /mnt/offsite→~/scripts/backup-offsite.sh→sudo umount /mnt/offsite→ unplug → carry off-site. Copiescurrent/{files,music,db-dumps,service-config}/+system-state/onto the T5 (~3 GB). Does NOT copycurrent/immich/(the ~520 GB photo library won't fit on the 500 GB T5 — accepted gap until a >1 TB off-site drive). Writes/mnt/offsite/offsite-backup.log./home/gabriel/scripts/check-backup-mount.sh— hourly cron — emails an alert if/mnt/backupisn't mounted (catches USB disconnect between the daily 3 AM runs). Silent when fine./home/gabriel/scripts/immich-go—immich-gov0.31.0 binary, used for bulk-importing files into Immich via its API (preserves storage-template behavior)./home/gabriel/scripts/icloudpd-bulk-download.sh— Wrapper script that runs the full icloudpd download command. Designed to run insidetmux new -s icloud-migration ..../home/gabriel/scripts/icloud-trim.py—pyicloud-based "keep newest N, delete the rest" tool. Used 2026-05-06 to mass-delete iCloud Photos down to ~100 items after the bulk migration verified. Requires the venv at/home/gabriel/scripts/icloud-trim-venv/. Idempotent (re-running picks up wherever iCloud's current state is). Invoke from a tmux session — long runs (hours) and ssh disconnect kills the script otherwise./home/gabriel/scripts/icloud-trim-venv/— Python venv withpyicloudfor the trim script./home/gabriel/.pyicloud/— pyicloud's session cookie cache (separate from/data/docker/icloudpd/cookies/which is icloudpd's). ~2-month validity./home/gabriel/scripts/tailscale-token-age-check.sh— Weekly cron (Mondays 09:00) — emails the rotation procedure when Homepage's Tailscale API token is older than 75 days (90-day Tailscale cap; no permanent tokens exist; OAuth was evaluated and rejected — see decisions table). Reads/home/gabriel/.tailscale-token-rotated(rotation timestamp file, mode 600). Silent unless past threshold./home/gabriel/.tailscale-token-rotated— Rotation timestamp file (mode 600) consumed by the age-check script./home/gabriel/.immich-api-key— Immich admin API key (mode 600). Used by immich-go./tmp/icloudpd-bulk.log— Live log of the running bulk download.
Available paths for new projects:
/data/docker/<new-service>/— Pattern for new Docker services./data/docker/immich/and/data/docker/icloudpd/already follow this./data/<new-project>/— Use new subdirectories for new projects./home/gabriel/scripts/— Existing home for shell scripts and small one-off binaries (e.g.,immich-go)./home/gabriel/<new-project>/— Acceptable for non-Docker development work.
Checklist: bringing up a new service¶
Run through all of this when a new self-hosted service goes onto the Z2. Step 6 (the Homepage tile) is the one that's been missed — Radicale and Vaultwarden both went live and stayed invisible on the Homepage dashboard for days because nobody edited services.yaml. Homepage does not auto-discover containers; the tile is a manual edit, every time.
- Pick a free port and bind it
127.0.0.1:PORT:PORT— not0.0.0.0, not the tailnet IP. Check the "Reserved ports" table below — never reuse a bound one. (Pre-Caddy services bound the tailnet IP100.67.235.68:PORT; that pattern is retired — everything is127.0.0.1behind Caddy now.) - Add a Caddy site block in
/data/docker/caddy/Caddyfile:<svc>.z2mini.gabrielgabrie.com { log; reverse_proxy 127.0.0.1:PORT }, thendocker compose -f /data/docker/caddy/docker-compose.yml exec caddy caddy reload --config /etc/caddy/Caddyfile. Caddy gets the Let's Encrypt cert via DNS-01 within ~30-60 s; the*.z2miniwildcard A record already resolves the name. See 17-caddy.md. (This replaces the old "front it with atailscale servelistener" step.) - Follow the
/data/docker/<service>/layout.docker-compose.yml+.env(mode 600 if it holds a secret) + the data subdir. Mirror the upstream compose template verbatim; record the customizations in a numberedNN-<service>.mddoc page and add that page tomkdocs.yml'snav:. - Update the reference tables (this doc and
10-system-reference.md): add the port to "Reserved ports", the container(s) to the Docker-managed services table, data paths to "Reserved paths", a row to the decisions table, and a "## N." subsection to "Services Running" in this doc. - Plan the backup story in the service's doc page — pure rsync for files-as-truth (Radicale),
pg_dumpfor Postgres (Immich), SQLite online.backupfor SQLite (Navidrome, Beszel, Vaultwarden). Never filesystem-rsync a live database. (The Caddy site block + cert are under/data/docker/caddy/like every other stack — nothing special to re-run after a rebuild beyonddocker compose build && up -dfor the Caddy stack itself.) - Add a Homepage tile. Edit
/data/docker/homepage/config/services.yaml:icon+href: https://<svc>.z2mini.gabrielgabrie.com+server: my-docker+container: <name>. Add awidget:block only if Homepage ships one for the service (check https://gethomepage.dev/widgets/services/) with the token in.envasHOMEPAGE_VAR_*;widget.urlmust be the Caddy hostname (https://<svc>.z2mini.gabrielgabrie.com) — the Homepage container can't reach the app's127.0.0.1:<port>on the host. Bump the group'scolumns:insettings.yamlif it now has 5+ tiles. Full procedure: 13-homepage.md → Adding a new service tile. Beszel needs nothing — its agent auto-discovers containers via the docker socket.
Networking¶
Tailscale (mesh VPN)¶
- Tailnet name:
elk-kanyu.ts.net - Z2 Mini hostname on tailnet:
z2mini.elk-kanyu.ts.net - Tailscale SSH: Enabled (
tailscale up --ssh). Authentication uses tailnet identity, not password. - MagicDNS: Enabled. Devices addressable by short hostname (e.g.,
ssh gabriel@z2mini). - Operator:
gabriel(sudo tailscale set --operator=gabriel) —tailscale serve/setetc. work without sudo. - Global DNS resolvers:
1.1.1.1+8.8.8.8, with "Override local DNS" ON. So tailnet devices resolve*.z2mini.gabrielgabrie.comreliably (the student-housing router's DNS chokes on it — stale negative cache and/or it strips100.64.0.0/10CGNAT answers). - HTTPS: All web services are fronted by the Caddy reverse proxy (17-caddy.md) on
100.67.235.68:443—https://<svc>.z2mini.gabrielgabrie.com, auto-renewing Let's Encrypt certs via ACME DNS-01 through the Cloudflare API. Tailscale's own cert features (tailscale cert,tailscale serve) are not used anymore; the two oldtailscale servelisteners (:443Radicale,:8443Vaultwarden) were retired in May 2026 when Caddy took over. - Devices on tailnet: Z2 Mini, Gabriel's Windows laptop, Gabriel's iPhone.
- Plan: Free Personal plan. Headscale (self-hosted alternative) was discussed but rejected for now due to complexity.
Network policy¶
- NO public internet exposure. Every web app binds
127.0.0.1:<port>only; Caddy (network_mode: host) binds100.67.235.68:443+:80(the Tailscale interface only). Samba binds all interfaces (LAN + tailnet) on 137-139/445. - No port forwarding configured. Services are reachable only via the tailnet (Samba also via the local Wi-Fi LAN).
- The Z2 Mini sits behind student housing NAT — public-facing services are not feasible until Gabriel moves in September 2026.
Reserved ports¶
Services currently bound on the Z2:
| Port | Service | Interface |
|---|---|---|
| 22 | SSH (handled by Tailscale SSH) | tailnet only |
| 80 | Caddy — HTTP→HTTPS redirect | 100.67.235.68 only |
| 137-139 | Samba (NetBIOS) | all interfaces |
| 443 | Caddy — HTTPS reverse proxy for all web services (<svc>.z2mini.gabrielgabrie.com); Let's Encrypt via ACME DNS-01/Cloudflare |
100.67.235.68 only |
| 445 | Samba (SMB) | all interfaces |
| 2019 | Caddy admin API (config/reload) | 127.0.0.1 only (host loopback — Caddy is network_mode: host) |
| 2283 | Immich web UI + mobile API | 127.0.0.1 only (fronted by Caddy → immich.z2mini.gabrielgabrie.com) |
| 3000 | Homepage launcher dashboard | 127.0.0.1 only (fronted by Caddy → home.z2mini.gabrielgabrie.com) |
| 4533 | Navidrome web UI + Subsonic API | 127.0.0.1 only (fronted by Caddy → navidrome.z2mini.gabrielgabrie.com) |
| 5232 | Radicale CalDAV/CardDAV (HTTP) | 127.0.0.1 only (fronted by Caddy → radicale.z2mini.gabrielgabrie.com) |
| 8080 | Vaultwarden web vault + API (HTTP, ROCKET_PORT=8080) |
127.0.0.1 only (fronted by Caddy → vault.z2mini.gabrielgabrie.com) |
| 8082 | OpenProject internal proxy (publishes the stack) | 127.0.0.1 only (fronted by Caddy → openproject.z2mini.gabrielgabrie.com) |
| 8090 | Beszel hub web UI + agent ingest | 127.0.0.1 only (fronted by Caddy → beszel.z2mini.gabrielgabrie.com) |
When suggesting new services, avoid these ports unless specifically replacing one of them. New web services go behind Caddy: bind 127.0.0.1:<new-port>, add a Caddyfile block.
Services Running¶
1. Samba (file sharing)¶
- Config:
/etc/samba/smb.conf(backup at/etc/samba/smb.conf.backup) - Service:
smbd(managed by systemd) - Defined shares:
[files]—/data/files, read-write, usergabrielonly[backup]—/mnt/backup, read-only (intentional safety), usergabrielonly,lost+foundhidden viaveto files[music]—/data/music, read-write, usergabrielonly. Verbatim mirror of[files](same fruit/macOS settings, same gabriel-only access) just pointed at/data/music. Consumed by Navidrome (read-only mount inside the container).- macOS/iOS compatibility:
vfs objects = catia fruit streams_xattrplus fullfruit:*configuration block. Do not remove these without testing iOS Files behavior. - Authentication: Samba's own password database (separate from Linux passwords). Set via
smbpasswd.
2. Tailscale (always-on)¶
- Service:
tailscaled(managed by systemd) - Persists across reboots: Yes (
systemctl enabled) - Configuration: Via Tailscale admin panel (https://login.tailscale.com/admin), not local files
3. Backup automation¶
- On-site backup drive: Samsung 990 PRO 1TB NVMe in an ASMedia ASM2462 USB enclosure, mounted
/mnt/backup, ~916 GB usable, ext4 labelbackup, UUID8795cb2e-fe34-4543-b93b-dd45b642846f, reserved-blocks 0 (tune2fs -m 0). fstab line:UUID=8795cb2e-... /mnt/backup ext4 defaults,nofail 0 2. Replaced the old 500 GB Samsung T5 in May 2026 — a refurb verified genuine before deploy (Samsung vendor ID0x144d, model "Samsung SSD 990 PRO 1TB", firmware7B2QJXD7, SMART PASSED, 0% used, ~12 power-on hr; ~1056/1009 MB/s = the USB 3.2 Gen 2 link saturating, not the drive's ceiling). Hot-swap (both drives USB, no shutdown). Rollback files:/etc/fstab.pre-990pro,/etc/smartd.conf.pre-990pro,~/scripts/backup-files.sh.pre-990pro. After the first full run (~520 GB, ~9 min over USB) +tune2fs -m 0: ~396 GB free. - Off-site backup drive: the demoted 500 GB Samsung T5 — reformatted as ext4 label
backup-offsite, UUID2c8e8e38-f129-4823-a1db-1529d3296b44, reserved-blocks 0. Lives at parents' house; normally NOT connected. Mounted manually at/mnt/offsitefor a sync during family visits (no fstab entry). - Script:
/home/gabriel/scripts/backup-files.sh(run as usergabrielvia cron —0 3 * * *; crontab unchanged. Old versionbackup-files.sh.pre-990pro). Steps: (1) auto-remount/mnt/backupif needed; (2) abort if still not mounted; (3) rsync mirrors with--delete --no-owner --no-group:/data/files/→current/files/,/data/docker/immich/library/→current/immich/,/data/music/→current/music/; (4)sqlite3 "<live db>" ".backup '<dest>'"(hostsqlite3) forvaultwarden-db.sqlite3,beszel-data.db,beszel-auxiliary.db,navidrome.db→current/db-dumps/; (5) per-service config rsync (--delete-excluded) →current/service-config/<svc>/for immich, vaultwarden, navidrome, radicale, beszel, homepage, caddy (excludes bulk data / live DBs / container-owned-unreadable subdirs — see the per-service "Reserved paths" entries above); (6) hard-linkedcp -alsnapshot →daily/<YYYY-MM-DD>/(dated by run START time); (7) Sundays only →weekly/<YYYY-MM-DD>/; (8) prune (7 daily, 4 weekly); (9) system-state capture. - New host dependency:
sqlite3(sudo apt install sqlite3) — needed for step 4. Now insystem-state/manual-packages.txt; add to any "packages to reinstall" list. - Off-site script:
/home/gabriel/scripts/backup-offsite.sh— MANUAL, not in cron. Ritual (also in the script's header): plug the T5 into z2mini →sudo mount /dev/disk/by-label/backup-offsite /mnt/offsite→~/scripts/backup-offsite.sh→sudo umount /mnt/offsite→ unplug → carry off-site. Copiescurrent/{files,music,db-dumps,service-config}/+system-state/onto the T5 (~3 GB). Does NOT copycurrent/immich/— the ~520 GB photo library won't fit on the 500 GB T5; that's a known, accepted gap (the photos have copies only at the apartment:/datalive +/mnt/backupbackup) until a >1 TB off-site drive. (Unexercised option: fit just the ~442 GB of irreplaceable photo originals —library/library/+library/upload/— onto the T5 by skipping thumbs/transcodes/DB-dumps; deferred, Gabriel's call.) Writes/mnt/offsite/offsite-backup.log. - Method: rsync mirror +
sqlite3 .backupfor live DBs + hard-linked snapshots (Time Machine-style) - Retention: 7 daily, 4 weekly snapshots; 7 system-state tarballs
- Safety: Aborts if
/mnt/backupnot mounted (prevents writing backups to OS drive) - Logs:
/mnt/backup/backup.log, rotated weekly via/etc/logrotate.d/backup-files - Immich's Postgres DB: NOT filesystem-rsync'd. Immich's own nightly DB auto-backup (Admin → Settings → Backup Settings — default ON, runs 02:00, keeps last 14) writes
immich-db-backup-*.sql.gz(~137 MB each) intolibrary/backups/, which the 03:00current/immich/rsync picks up. That Immich setting must stay enabled or the DB stops being captured silently. - The SQLite DBs (Vaultwarden, Beszel ×2, Navidrome): captured with
sqlite3 ".backup"from the host binary — a proper online backup, safe while the container holds the WAL-mode DB open. Never raw-rsync a live*.sqlite3/*.db(or its-wal/-shm) — corrupt snapshot. - System state capture: Same script also captures system configuration daily:
- Package lists (
dpkg --get-selectionsandapt-mark showmanual—manual-packages.txtnow includessqlite3) - User crontab
- Critical config files:
/etc/samba/smb.conf,/etc/smartd.conf,/etc/msmtprc,/etc/apparmor.d/local/usr.bin.msmtp,/etc/fstab,/etc/hostname,/etc/hosts,/etc/logrotate.d/*,/home/gabriel/scripts/ - Bundled into timestamped
system-config-YYYY-MM-DD.tar.gzarchives in/mnt/backup/system-state/ - Last 7 archives retained
- Sudo permissions for backup:
/etc/sudoers.d/gabriel-backupgrantsgabrielpasswordless sudo for THREE specific commands — unchanged by the May 2026 overhaul; the expanded script (Immich, the SQLite dumps, the per-service config rsync) all runs asgabrielin thedockergroup with no new grant: /usr/local/sbin/backup-system-state.sh— wrapper for tar archiving of/etcconfigs (modern sudo rejects wildcards in path arguments, so wrapper-script pattern is required)/usr/bin/mount /mnt/backup— used by backup script's auto-remount safety net if drive transiently disconnects/usr/bin/msmtp *— used by hourly mount-check script to send alert emails (msmtp config is root-readable only). Wildcard for recipient email argument; sudo permits wildcards in non-path args. Do not modify these grants or the wrapper script's permissions (must beroot:rootand755) or the backup workflow will silently fail.- Wrapper script:
/usr/local/sbin/backup-system-state.sh(root-owned, contains the privileged operations: tar archiving of/etcconfigs and chown of resulting archive) - Mount-check script:
/home/gabriel/scripts/check-backup-mount.shruns hourly via cron. Silently exits if/mnt/backupis mounted; sends an email alert if not. Catches USB disconnect events between daily backups so they're noticed within an hour. - Auto-remount in backup script: before its safety check,
backup-files.shattemptssudo mount /mnt/backupif the drive isn't mounted. Handles transient disconnects without backup failure. Logs a NOTICE entry to backup.log when this fires (so frequency can be monitored). - Restoring a deleted file: the path inside
current//daily//weekly/now has afiles/subdir — e.g./mnt/backup/daily/<date>/files/path/to/file(and…/music/…for music,…/immich/…for a raw photo file).\\z2mini\backupSMB browsing still works. - Recovery procedure: Documented at
/mnt/backup/RECOVERY-README.md— covers the package-reinstall list (incl.sqlite3), the/etcconfig extract, theservice-config/+db-dumps/+ Immich-.sql.gzrestore, and the Caddydocker compose build && up -dstep.
4. Drive health monitoring (smartd)¶
- Config:
/etc/smartd.conf(rollback:/etc/smartd.conf.pre-990pro) - Service:
smartd(managed by systemd) - Devices monitored:
/dev/nvme0n1,/dev/nvme1n1(the two internal NVMe drives, unchanged), and the USB backup drive — now the 990 PRO behind the ASM2462 enclosure:/dev/disk/by-id/usb-ASMT_2462_NVME_2504178506CB-0:0 -d sntasmedia -a -m gabrielgabrie99@gmail.com. The off-site T5 is NOT monitored (normally disconnected). - Why
-d sntasmedia: the backup drive is an NVMe SSD behind an ASMedia ASM2462 USB↔NVMe bridge — without this flagsmartctl/smartdonly sees a generic USB mass-storage device and can't read the drive's real NVMe SMART data.-d sntasmediauses the ASMedia bridge's SCSI-translation to talk NVMe through the enclosure (works on the box's smartmontools 7.5). Other bridge chips:-d sntjmicron,-d sntrealtek. - Why the by-id path (not
/dev/sdX): USB device names change after disconnect/reconnect cycles. The/dev/disk/by-id/usb-...-0:0path is stable, and the-0:0whole-disk form is mandatory because-d sntasmedianeeds the whole disk, not a partition (so a/dev/disk/by-uuid/path wouldn't work). Do not change it to/dev/sdX. - No
-sself-test schedule on the USB backup drive: USB drives don't get scheduled self-tests anyway, and the ASM2462 bridge doesn't pass the NVMe self-test admin command (0x14) through to the drive — so a-sschedule would just error. - Self-tests: Daily short tests at 02:00 on the two internal NVMe drives only (configured via
-s S/../.././02) - Manual check on the backup drive:
sudo smartctl -a -d sntasmedia /dev/sdb(or whateverlsblkshows the enclosure as). - Alert mechanism: Email via msmtp on any pre-failure indicator
5. Email relay (msmtp)¶
- Config:
/etc/msmtprc(mode 600, owned by root — contains Gmail app password) - SMTP: Gmail via
smtp.gmail.com:587with STARTTLS and app password authentication - Send-from address:
gabrielgabrie99@gmail.com - AppArmor profile: Custom local override at
/etc/apparmor.d/local/usr.bin.msmtpallowingrwkon/var/log/msmtp.log. Do not remove this file or msmtp will silently fail to log. - Log:
/var/log/msmtp.log(rotated weekly via/etc/logrotate.d/msmtp) sendmailsymlink:/usr/sbin/sendmail → /usr/bin/msmtp(provided bymsmtp-mtapackage)
6. Immich (photo storage)¶
- Compose:
/data/docker/immich/docker-compose.yml..envat the same path holds the DB password (mode 600). Compose mirrors upstream Immichv2.7.5template verbatim except for two intentional customizations: (a) port published only on the Tailscale interface IP100.67.235.68:2283:2283, (b) the ML model cache is a bind mount to/data/docker/immich/model-cache/instead of a Docker named volume so it lives under/datarather than/var/lib/docker/. - Containers (managed via
docker compose):immich-server(web UI + API + microservices),immich-machine-learning(face recognition, smart search, OCR — GPU-accelerated via the:cudaimage variant +nvidia-container-toolkitsince 2026-05-05),immich-redis(Valkey 9 — queue/cache),immich-postgres(vectorchord 0.4.3 — main DB + vector embeddings). - Image pinning:
IMMICH_VERSIONlives in.env; postgres + redis pinned by tag + SHA256 digest in compose. Updates are deliberate (docker compose pull+ restart), never automatic. - Storage Template Engine: ON. Files organized at
library/admin/{:%Y/%Y-%m-%d}/IMG_*.HEIC. Original filenames preserved. - Bound interface: Tailscale only —
https://immich.z2mini.gabrielgabrie.com(orhttp://127.0.0.1:2283). NOT reachable onlocalhostfrom the server itself; immich-go and other on-server tools must use the tailnet IP. - First-run admin: Set via web UI on first launch.
- API keys: Account Settings → API Keys. The bulk-import key is saved at
/home/gabriel/.immich-api-key(mode 600) for use byimmich-go. - Backup considerations: In the nightly backup since May 2026. Two pieces: (1) the photo files — the entire
UPLOAD_LOCATION(/data/docker/immich/library/, incl.encoded-video/deliberately) is rsync-mirrored to/mnt/backup/current/immich/at 03:00 with--no-owner --no-group(the files are owned by Immich's container uid; on restore re-apply ownership or let Immich fix it); (2) the Postgres DB — captured via Immich's OWN built-in nightly DB auto-backup (Admin → Settings → Backup Settings — default ON, runs 02:00, keeps last 14) which writesimmich-db-backup-*.sql.gz(~137 MB) intolibrary/backups/, and the 03:00 rsync picks those up →current/immich/backups/. Thepostgres/data dir is NEVER filesystem-rsync'd (live Postgres = corrupt snapshot) and Immich's "Database Backups" setting must stay enabled or the DB stops being captured silently.model-cache/is NOT backed up (regenerable). Config (.env,docker-compose.yml,docker-compose.yml.pre-cuda,hwaccel.ml.yml) →service-config/immich/. Off-site: the photo library is NOT on the off-site T5 — ~520 GB doesn't fit on the 500 GB T5, so the photos have copies only at the apartment (/datalive +/mnt/backupbackup) — accepted gap until a >1 TB off-site drive. (iCloud is no longer the safety net either — iCloud Photos was cleared after the migration, iCloud downgraded to the 200 GB tier.) Restore = follow Immich's official backup/restore docs (recreate the DB + extensions, load the.sql.gz), then rsynclibrary/back. See08-recovery.mdStep 6b. - GPU acceleration: Enabled 2026-05-05. Quadro T2000 with NVIDIA driver 595 (Canonical's pre-signed
linux-modules-nvidia-595-generic— bypasses DKMS so Secure Boot stays on, no MOK enrollment needed).nvidia-container-toolkitexposes the GPU to the ML container viaextends: hwaccel.ml.yml service: cudain the compose. ML container uses the:cuda-suffixed image variant. CLIP + face recognition (buffalo_l) + OCR all run on GPU; ~3.5 GB of the 4 GB VRAM is used at peak when all three models are loaded with active inference. Models auto-unload after 300 s of idle. - iOS Immich app: Installed, authenticated, and auto-backup ENABLED 2026-05-08 (Camera Roll album, both Background and Foreground backup on). The migration is complete and iCloud was mass-deleted to ~100 items first, so auto-backup has minimal historical work — it just picks up new captures from this point forward. This is the daily-driver loop.
- What's not yet done: External-library mode is not used (Immich manages all files via storage template). (Immich IS in the nightly backup now — done May 2026; the only remaining backup gap is off-site, which needs a >1 TB drive.)
7. icloudpd (one-shot bulk migration tool)¶
- Compose:
/data/docker/icloudpd/docker-compose.yml, image pinned aticloudpd/icloudpd:1.32.2. Not a long-running service — invoked withdocker compose run --rm(noup -d). Cookies persist between invocations via thecookies/bind mount. - Authentication: Apple ID
gabrielgabrie@hotmail.com. First run was interactive (Apple ID password + 2FA code from trusted device); cookie now valid for ~2 months. Advanced Data Protection must remain OFF on the iCloud account during the migration window — icloudpd will hard-fail withACCESS_DENIEDif ADP is on. Gabriel has never had ADP enabled and does not plan to, so the post-migration "re-enable ADP" step that's in the public docs page does not apply to him. - Bulk download (historical): Started 2026-05-04 02:13 UTC, ran in
icloud-migrationtmux session via/home/gabriel/scripts/icloudpd-bulk-download.shwith--watch-with-interval 60. Completed 2026-05-05 ~12:54 UTC after ~35 hr; ~36,750 files / 458 GB downloaded into/data/icloud-import/. - immich-go ingest (historical): Three immich-go runs on 2026-05-05 — first bulk pass uploaded ~31k files before bailing on the 23rd transient 500 (default
--on-errors stop); resume run with--on-errors continue --concurrent-tasks 4 --pause-immich-jobs=falsegot further; final pickup pass after disk-recovery cleanup uploaded the remaining 1,030 files cleanly. Final state: 29,331 assets in Immich (Live Photo pairs merged into single assets vs. file count). - Status now: Bulk migration complete.
/data/icloud-import/deleted post-verify./data/docker/icloudpd/and the cookie are kept around — useful for incremental sync via--until-found Nif Gabriel ever wants Immich to one-way-mirror iCloud. - Apple session cookie validity: ~2 months from last successful auth (~2026-07-04 ish before re-auth needed for any future incremental run).
- Lessons captured (see
feedback_double_storage_migration.mdin memory): during the migration, both/data/icloud-import/and/data/docker/immich/library/lived on the same 938 GB/datafilesystem. Combined size hit 100% at the 86% upload mark, which surfaced as PostgresNo space left on deviceerrors → 500 Internal Server on every immich-go upload → cascading container restarts. Recovery: parsed immich-go's~/.cache/immich-go/*.logforuploaded successfully|server has duplicateevents to identify safely-deletable staging files (35,424 of them),sudo rm -rfthose (root-owned because icloudpd runs as root in container), freed 441 GB, queue resumed draining. Future migrations should either stage on a different filesystem or delete-as-you-go.
8. Navidrome (music streaming)¶
- Compose:
/data/docker/navidrome/docker-compose.yml..envat the same path holds the version pin (NAVIDROME_VERSION=0.61.2). Compose mirrors the upstream Navidrome template verbatim except for: (a) port published only on the Tailscale interface IP100.67.235.68:4533:4533, (b) music bind mount at/data/music:/music:ro— the:rois the container's view, the host filesystem is unaffected and Samba writes work fine, © version pinned via${NAVIDROME_VERSION}from.env, (d)TZandND_LOGLEVELset explicitly. - Container (managed via
docker compose):navidrome(deluan/navidrome:0.61.2as of install). Single container — Navidrome ships its own SQLite + ffmpeg, no external dependencies. ~50 MB RAM at idle, near-zero CPU. - Image pinning:
NAVIDROME_VERSIONin.env. Updates are deliberate (docker compose pull+up -d), never automatic. - Bound interface: Tailscale only —
https://navidrome.z2mini.gabrielgabrie.com. NOT reachable onlocalhostfrom the server itself; on-server scripts must usehttp://127.0.0.1:4533orhttps://navidrome.z2mini.gabrielgabrie.com. Same gotcha as Immich. - First-run admin: Set via web UI on first launch. Separate user database from any other service.
- Music writes:
[music]Samba share (added to/etc/samba/smb.conf) exposes/data/music/for laptop / iOS Files / macOS Finder writes. Built-in file watcher detects new files and triggers a partial scan within ~10 seconds. - Library format: Mostly AAC 256 (
.m4a) — iTunes Store native, Apple Music app CD rips set to AAC 256, native iOS decoding (no transcoding lag), audibly transparent on car/BT-speaker/loudspeaker gear, ~5 MB/track. FLAC also accepted (Bandcamp / library CD lossless rips); decoded natively by modern Subsonic clients. Mixed-format library is fine. - Tagging: Navidrome reads embedded tags (ID3 / MP4 atoms / Vorbis comments) — folder structure and filenames are cosmetic. For sources with bad/missing tags (yt-dlp downloads, random rips), use MusicBrainz Picard on the laptop (NOT iOS, NOT the server) to acoustic-fingerprint and tag before drop. iTunes Store purchases come pre-tagged and can be dropped in directly.
- iOS clients: Arpeggio (free) + Narjo (free, TestFlight beta) currently. Subsonic-API-compatible — server URL
https://navidrome.z2mini.gabrielgabrie.com, same Navidrome admin credentials. CarPlay support on both. Amperfy was the original pick but requires iOS 26 (not available on the iPhone's current iOS). - Laptop client: Feishin (
github.com/jeffvli/feishin) — free FOSS Subsonic desktop client, cross-platform, modern Electron UI. Daily-driver since the web UI's shuffle is limited (shuffles within current view, not smartly across library) and Feishin's queue management is meaningfully better. Connects via Subsonic API tohttps://navidrome.z2mini.gabrielgabrie.comwith Navidrome admin creds. The web UI is still useful for first-time setup, library administration, and one-off play from any browser. - Backup considerations: In the nightly backup since May 2026.
/data/music/rsync-mirrored to/mnt/backup/current/music/(also on the off-site T5).navidrome.db(playlists, play counts, listening history) captured via hostsqlite3 ".backup"→/mnt/backup/current/db-dumps/navidrome.db(also off-site) — never raw-rsync the livenavidrome.db/-wal/-shm..env+ compose →service-config/navidrome/(the wholedata/dir is excluded from that rsync).data/artwork/+data/cache/NOT backed up — regenerated on the next scan. Restore: dropdb-dumps/navidrome.dbintodata/navidrome.db, restore config, rsync music back,docker compose up -d. See08-recovery.mdStep 6b. - Acquisition list: Working list of music to acquire is at the repo root in
music-acquisition-list.mdonfeat/music-installbranch. Tiered by priority with source/cost annotations. - Use-case split: At-home listening (laptop, BT speaker on phone) is streamed from Navidrome over home Wi-Fi. Commute listening (CarPlay) uses the iOS app's offline-download feature to pre-cache albums — eliminates cellular dead zone stutters.
- What's not yet done: Mass acquisition not yet performed — only a handful of FLAC files dropped to validate the pipeline end-to-end. (Navidrome IS in the nightly backup now — done May 2026.)
9. Homepage (launcher dashboard)¶
- Compose:
/data/docker/homepage/docker-compose.yml..envat the same path holds the version pin (HOMEPAGE_VERSION) and the widget API tokens (Immich, Navidrome Subsonic token+salt, Tailscale access token + device ID — all referenced fromservices.yamlvia{{HOMEPAGE_VAR_*}}substitution;.envis mode 600). Compose mirrors the upstream Homepage template verbatim except for: (a) port published on127.0.0.1:3000only (fronted by Caddy →home.z2mini.gabrielgabrie.com), (b)HOMEPAGE_ALLOWED_HOSTS=home.z2mini.gabrielgabrie.com,localhost:3000,127.0.0.1:3000, © version pinned via${HOMEPAGE_VERSION}from.env, (d)TZset explicitly, (e)/var/run/docker.sock:/var/run/docker.sock:romounted for live container status display. - Container (managed via
docker compose):homepage(ghcr.io/gethomepage/homepage:v1.12.3as of install). Single container — Homepage is self-contained Next.js, no external dependencies. ~80 MB RAM at idle, near-zero CPU. - Image pinning:
HOMEPAGE_VERSIONin.env. Updates are deliberate (docker compose pull+up -d), never automatic. - Bound interface:
127.0.0.1:3000only, fronted by Caddy →https://home.z2mini.gabrielgabrie.com. On-box scripts usehttp://127.0.0.1:3000(e.g. the Tailscale-token-age-check'scurl http://127.0.0.1:3000/api/services).http://z2mini:3000/http://100.67.235.68:3000no longer work (removed in the Caddy migration). - Config files:
/data/docker/homepage/config/{services,bookmarks,widgets,settings,docker}.yaml— bind-mounted from host. Live-reloaded on save (no container restart needed). Bothhref:andwidget.url:are the Caddy hostname (https://<svc>.z2mini.gabrielgabrie.com) — the bridged Homepage container can't reach the apps'127.0.0.1:<port>on the host, so widget queries go through Caddy too; tile-only entries (Radicale, Vaultwarden) have onlyhref. - Tiles under "Self-hosted" (
services.yaml): Immich, Navidrome, Radicale, Vaultwarden — all withserver: my-docker+container: <name>for the status dot. Layout is 4 columns (settings.yaml). Tile under "Network": Tailscale. - Widgets active:
immich(asset count, video count, total storage — uses Immich API key withserver.statisticsscope),navidrome(track/album count — uses Subsonic-stylemd5(password+salt)token),tailscale(device IP, last-seen, expiry — uses access token fromlogin.tailscale.com/admin/settings/keys, NOT an auth key; rotates on the 80-day cycle viatailscale-token-age-check.sh). Radicale and Vaultwarden are tile-only — Homepage ships no native widget for either, so they're just clickable launchers with a status dot. Vaultwarden'shrefis thetailscale serveHTTPS endpointhttps://vault.z2mini.gabrielgabrie.com(the container is bound to127.0.0.1:8080only — no tailnet-IP form). - Docker integration: Read-only socket mount lets
docker.yaml(my-docker: socket: /var/run/docker.sock) drive per-tile container-status dots.services.yamlreferences containers by name (e.g.,container: immich_server— note the underscore in Immich's compose-generated name;radicaleandvaultwardenare plain). - Backup considerations: In the nightly backup since May 2026.
.env+config/(the six YAML files) rsync'd into/mnt/backup/current/service-config/homepage/(and to the off-site T5). Small and rsync-safe — no live database..envpermission (600 — widget API tokens) preserved byrsync -a. The config is also committed verbatim in13-homepage.md, so total backup loss is recoverable by re-pasting + re-minting the tokens. Restore: rsyncservice-config/homepage/back,docker compose up -d; re-mint any expired widget token (the Tailscale one rotates ~80-day). See08-recovery.mdStep 6b. - What's not yet done: New self-hosted stacks get appended to
services.yamlas tile-only first; a widget is added later only if Homepage ships one for that service. (Homepage IS in the nightly backup now — done May 2026.)
10. Beszel (system-metrics dashboard)¶
- Compose:
/data/docker/beszel/docker-compose.yml..envat the same path holds the version pin (BESZEL_VERSION) and the agent'sKEY(SSH public key issued by the hub) +TOKEN(websocket registration token)..envis mode 600. Compose mirrors the upstream Beszel template verbatim except for: (a) hub port published only on the Tailscale interface IP100.67.235.68:8090:8090, (b)APP_URLset tohttps://beszel.z2mini.gabrielgabrie.comso email-link rendering uses the tailnet hostname, © all bind-mounts anchored under/data/docker/beszel/, (d) agent uses unix socket via sharedsocket/volume (LISTEN: /beszel_socket/beszel.sock,NETWORK: unix) — agent has no TCP listener at all, (e) version pinned via${BESZEL_VERSION}from.env. - Containers (managed via
docker compose):beszel(hub —henrygd/beszel:0.18.7, web UI + SQLite-backed time-series DB + alert engine + SMTP sender, ~30 MB RAM idle) andbeszel-agent(agent —henrygd/beszel-agent:0.18.7,network_mode: hostfor/procand host network-stack access, reads Docker socket read-only for per-container metrics, ~20 MB RAM idle). - Image pinning: Same
BESZEL_VERSIONfor hub and agent — they have a wire protocol that can change between minor versions, version skew breaks metric ingest. Updates are deliberate (docker compose pull+up -d), never automatic. - Bound interface: Tailscale only —
https://beszel.z2mini.gabrielgabrie.com. NOT reachable onlocalhostfrom the server itself; on-server scripts must usehttp://127.0.0.1:8090orhttps://beszel.z2mini.gabrielgabrie.com. Same gotcha as Immich/Navidrome/Homepage. Exception: the agent'sHUB_URL: http://localhost:8090works because the agent isnetwork_mode: host— its localhost IS the host's localhost, where the hub's published port is reachable. - First-boot flow (two-phase): (1)
docker compose up -d beszel— hub only, agent can't start withoutKEY/TOKEN. (2) Hub UI athttps://beszel.z2mini.gabrielgabrie.com→ set up admin user → "+ Add System" with host=/beszel_socket/beszel.sock(unix socket path) → copy the displayedKEY(with quotes — contains spaces) andTOKENinto.env→chmod 600 .env. (3)docker compose up -d beszel-agent. System flips Pending → Up within ~30 s. - Email alerts: SMTP configured in hub UI (Settings → Mail Settings) — NOT via env vars. Direct to
smtp.gmail.com:587with a separate Gmail app password (created at https://myaccount.google.com/apppasswords with namebeszel-z2mini), deliberately distinct from msmtp's app password. Reason: revocation independence — if Beszel's password is compromised, smartd's email channel via msmtp keeps working. Per-system alerts (CPU > 80% / 10min, mem > 85% / 5min, disk/data> 90%, disk/> 80%, agent down > 2min, container down > 2min) are configured in the system detail view, not globally. - Disk monitoring: the agent's compose bind-mounts marker dirs so the hub UI gives the extra disks display names:
/data/.beszel:/extra-filesystems/data__Data:roand/mnt/backup/.beszel:/extra-filesystems/backup__Backup:ro— the__<Label>suffix on the container path is how you set the displayed name. Result in the hub UI: "Data" (=/data, the SK hynix internal NVMe), "Backup" (=/mnt/backup, the 990 PRO over USB), andnvme1n1p2for the OS root drive (Beszel doesn't support renaming the root/main filesystem — it always shows the device name there). Rollback:docker-compose.yml.pre-disklabels. After a backup-drive swap, recreate/mnt/backup/.beszel/anddocker restart beszel-agent. (After relabeling, the hub briefly shows stale "ghost" entries —nvme0n1p1,sdb1,sda1— until they age out of retention; harmless.) - Backup considerations: In the nightly backup since May 2026. Both hub SQLite DBs —
data/data.dbANDdata/auxiliary.db(the metrics history is the operationally valuable part) — captured via hostsqlite3 ".backup"→/mnt/backup/current/db-dumps/beszel-data.db+beszel-auxiliary.db(also off-site). Never raw-rsync an open SQLite DB..env(agentKEY/TOKEN, mode 600) + compose →service-config/beszel/(the wholedata/dir is excluded). NOT backed up:data/id_ed25519(the hub's SSH key — root-owned, the backup user can't read it; regenerable, but regenerating it means re-pairing the one agent),agent-data/(rebuildable),socket/(transient). Restore: drop the twodb-dumps/beszel-*.dbintodata/, restore.env+ compose,docker compose up -d(hub regeneratesid_ed25519), re-register the agent. See08-recovery.mdStep 6b. - What's not yet done: SMTP and per-system alerts are configured during install. Future agents (laptop, off-site backup VPS, parents'-house drive station) can register against the same hub without restructuring. (Beszel IS in the nightly backup now — done May 2026.)
11. Radicale (CalDAV / CardDAV — calendar and contacts)¶
- Compose:
/data/docker/radicale/docker-compose.yml..envat the same path holds the version pin (RADICALE_VERSION=3.7.2.0) and nothing else (no secrets — htpasswd file is separate). Compose mirrors the upstreamtomsquest/docker-radicaletemplate verbatim except for: (a) two port bindings —100.67.235.68:5232:5232for direct HTTP access from Thunderbird/scripts, plus127.0.0.1:5232:5232sotailscale servecan proxy from outside; (b)TZset toAmerica/Toronto; © bind-mount paths anchored under/data/docker/radicale/; (d) version pinned via${RADICALE_VERSION}from.env. Hardening features (init: true,read_only: true,security_opt: no-new-privileges,cap_drop: ALL+ minimalcap_add, memory + pids limits) are unchanged from upstream. - Container (managed via
docker compose):radicale(tomsquest/docker-radicale:3.7.2.0as of install). Single container — Radicale is a small Python daemon, no external dependencies. ~30 MB RAM at idle. Runs as non-root (UID 2999); the image's "Option 0" pattern auto-chowns the data dir on first boot via theCHOWNcapability. - Image pinning:
RADICALE_VERSIONin.env.tomsquesttags follow[Radicale version].[image revision](e.g.,3.7.2.0); pin to a specific tag, never:latest(latest is rebuilt daily and bypasses the deliberate-update posture). The image's README claimshtpasswdis included — as of3.7.2.0it isn't; use the venv Python form (docker run --rm --entrypoint /venv/bin/python3 ... -c "import bcrypt; ...") to generate hashes. - Two endpoints, two purposes:
https://radicale.z2mini.gabrielgabrie.com(plain HTTP, tailnet IP) — used by Thunderbird on the Windows laptop, and by scripts/curl on z2mini itself. Tailscale already encrypts the wire so HTTP-in-container is fine.https://radicale.z2mini.gabrielgabrie.com/(TLS viatailscale serve) — used by iOS Calendar. iOS 18+ forces TLS on initial CalDAV verification regardless of the in-app "Use SSL" toggle (verified via Radicale logs showing ~12 TLS ClientHellos rejected before iOS gives up).sudo tailscale serve --bg http://127.0.0.1:5232puts a Tailscale-managed Let's Encrypt cert in front of the container at port 443. Cert auto-renews on Tailscale's 60/90-day cycle; the serve config persists in tailscaled state and is restored automatically on reboot.
- Auth: htpasswd with bcrypt. Single line in
/data/docker/radicale/config/users(mode 644 — bcrypt hashes are designed to be safe at the filesystem level, same posture as Apache's default for htpasswd files). Cost factor 12 (the image's older htpasswd default of 5 is too weak on modern hardware). Both$2b$(Python bcrypt) and$2y$(Apache htpasswd) prefixes are accepted. - Storage layout:
/data/docker/radicale/data/collections/collection-root/gabriel/<uuid>/— each collection (calendar or contact book) is a folder with a.Radicale.props(JSON metadata: displayname, color, type) and one.ics(or.vcf) file per event/contact. Pure files-as-truth. Files are owned2999:2999(post-first-boot chown) but the container'sumask 0022keeps them mode 644, sogabrielcan read them for inspection without elevation. - Calendars (as of 2026-05-10 migration):
Personal(test calendar),Events and Reminders(49 events / 39 recurring),Deadlines(72 / 7),Conestoga Timetable(12 / 12),Bills(22 / 20),Work Schedule(100 / 93). Total 255 events spanning 2021-04-30 → 2026-09-04. Migration via iCloud "Public Calendar" → download.ics→ Thunderbird import-into-collection → CalDAV sync. - Alarms intentionally NOT preserved across migration. iCloud's public-calendar feed strips all
VALARMcomponents by design (Apple treats alarms as personal preferences that shouldn't leak to subscribers). The decision was to accept this loss and re-add alarms manually on the ~10 important recurring events (bills, key class times) rather than take onvdirsyncer-or-similar CalDAV-to-CalDAV migration tooling. Future events created in Radicale carry their alarms normally — this is a one-time migration loss only. - Phase 6 parallel-run window: 2026-05-10 → ~2026-05-24 (two weeks). During this window, all new events go into Radicale only; iCloud Calendar stays logged in but read-only as a safety net. Phase 7 (cut iCloud Calendar via Settings → iCloud → Calendars OFF + delete on iCloud.com) follows after the parallel run is clean.
- Thunderbird (Windows laptop) clients: Native CalDAV via
https://radicale.z2mini.gabrielgabrie.com/gabriel/. Plus subscribed read-only feeds for Conestoga Outlook publish-as-iCal, Gmail, and the clinic appointment feed. Conestoga publish gives titles + locations only (tenant policy); Laurier was dropped after Gabriel quit the job. - iOS clients: Native CalDAV via
https://radicale.z2mini.gabrielgabrie.com/. Plus separate Exchange accounts for Conestoga (writable for school events; iOS sees full Exchange detail) and Google account for Gmail (side-account awareness). The Conestoga publish feed is hidden on iOS (Exchange already shows the same events with full body/attendees/RSVP) but visible on Thunderbird (where Exchange isn't easily accessible). - Backup considerations: In the nightly backup since May 2026.
.env+ compose +config/(config + htpasswdusersfile) +data/collections/(all calendars/events as plain.icsfiles — "files-as-truth", load-bearing: this dir alone restores every calendar) rsync'd into/mnt/backup/current/service-config/radicale/— nopg_dump, no SQLite ceremony, just rsync; verified byte-for-byte; the calendars are also on the off-site T5. (The rsync also pulls Radicale's.Radicale.cache/— harmless.) Restore: rsyncservice-config/radicale/config/+data/collections/back,sudo chown -R 2999:2999 /data/docker/radicale/data /data/docker/radicale/config,docker compose up -d. See08-recovery.mdStep 6b. The HTTPS front-end is the Caddy stack under/data/docker/caddy/(in the backup), nottailscale serveconfig in/var/lib/tailscale/— that's retired (no moretailscale servere-init after a rebuild; bring up the Caddy stack instead). - CardDAV (contacts) — deferred. The same daemon and same auth handle CardDAV at the same port; standing up a contacts collection is "create a
gabriel/contacts/collection in the web UI and add the same server URL as a CardDAV account on iOS." Gabriel intends to follow up on contacts after the calendar parallel-run window completes. - What's not yet done: CardDAV (contacts) not yet wired. Phase 7 (cut iCloud Calendar) pending the end of the parallel-run window. Manual alarm re-add on the important recurring events pending. (Radicale IS in the nightly backup now — done May 2026.)
12. Vaultwarden (password manager)¶
- What it is: a self-hosted, Bitwarden-compatible password manager replacing iCloud Passwords (formerly iCloud Keychain). Vaultwarden is only the server (Rust reimplementation of Bitwarden's server API); every client is the official Bitwarden software pointed at this server — Bitwarden desktop (Windows/macOS/Linux), browser extensions, iOS/Android apps,
bwCLI. There is no "Vaultwarden client" (anything in an app store calling itself one is unofficial). The only Vaultwarden-served UI is the web vault at the HTTPS URL, used in a browser for account creation, bulk import, and password-health reports. - Compose:
/data/docker/vaultwarden/docker-compose.yml..envat the same path (mode 600) holds the version pin,DOMAIN, andSIGNUPS_ALLOWED— plus commented-outADMIN_TOKEN/PUSH_*/ SMTP placeholders for later. Compose mirrors the upstream Vaultwarden example except for: (a) image pinned via${VAULTWARDEN_VERSION}from.env, never:latest; (b)user: "1000:1000"instead of the image-default root — sodata/staysgabriel-owned (clean backup, no sudo) and a non-root, capability-less process can run, hencecap_drop: ALLworks; ©ROCKET_PORT=8080instead of the image default 80 — a non-root process can't bind <1024 withoutCAP_NET_BIND_SERVICE, and dropping to 8080 removes that need; (d) port published on127.0.0.1:8080:8080only — nothing reaches it buttailscale serve(which runs on the host); (e)TZAmerica/Toronto; (f)no-new-privileges, memory (256M) + pids (100) limits, and a/alivecurl healthcheck added; (g) bind-mount under/data/docker/vaultwarden/. The original root-default run crash-looped withError creating private key 'data/rsa_key.pem' ... PermissionDeniedprecisely becausecap_drop: ALLstripsCAP_DAC_OVERRIDE, so root couldn't write into thegabriel-owned dir —user: 1000:1000is the fix, not adding caps back or chowning to root. - Container (managed via
docker compose):vaultwarden(vaultwarden/server:1.36.0as of install — the Debian variant, the upstream:latestdefault; the-alpinetag is smaller but the Debian one is what upstream docs assume). Single container — Vaultwarden bundles its own web server (Rocket) and uses SQLite, no external DB. ~10–15 MB RAM at idle. WebSocket live-sync (ENABLE_WEBSOCKET=true, on by default) is served on the same port; no separate websocket port. - HTTPS via
tailscale serveon port 8443:sudo tailscale serve --bg --https=8443 http://127.0.0.1:8080puts a Tailscale-managed Let's Encrypt cert in front of Vaultwarden athttps://vault.z2mini.gabrielgabrie.com/. Required because the Bitwarden mobile app flatly refuses a non-HTTPS self-hosted server and the web vault's PBKDF2 key derivation needs a secure context. Port 8443 (not 443) because Radicale already owns 443. Cert auto-renews on Tailscale's 60/90-day cycle; the serve config persists in tailscaled state and is restored on reboot. A subpath on 443 (/vault) was the alternative — rejected as the fragile option (Vaultwarden subpath support is finicky with some clients; Radicale's CalDAV discovery wants root). Everything uses this one endpoint — there is no plain-HTTP tailnet endpoint like Radicale's (the container is127.0.0.1-only); on the server itself, scripts hithttp://127.0.0.1:8080. - Account: single account
gabrielgabrie99@gmail.com, created 2026-05-10 via the web vault.SIGNUPS_ALLOWEDwas flippedtrue→ register →false(registration open ~2 minutes on a private tailnet). To add another account: flip it back, register, flip off,docker compose up -deach time. The admin panel (/adminviaADMIN_TOKEN) and SMTP are left disabled — single-user box doesn't need invites/hints/email-2FA; admin panel can be enabled later by generating an Argon2 hash (docker run --rm -it vaultwarden/server /vaultwarden hash) and uncommenting.env. - Storage layout:
/data/docker/vaultwarden/data/(owned1000:1000):db.sqlite3(+-shm/-wal— WAL mode) is the vault (logins, TOTP secrets, passkeys, folders);rsa_key.pemis the JWT signing key (load-bearing — losing it invalidates every session);attachments/andsends/appear once any exist;icon_cache/andtmp/are rebuildable/transient;config.jsonexists only if the admin panel was used (and then overrides.envat runtime). Vault contents are end-to-end encrypted client-side with a key derived from the master password — the server (and root on the box) stores only ciphertext;sqlite3queries can show that an item exists and its metadata (ciphers.atype: 1=login, 2=note, 3=card, 4=identity) but not its name/username/password/TOTP/passkey. - Clients: server URL for all of them is
https://vault.z2mini.gabrielgabrie.com(set only the base URL — Vaultwarden serves api/identity/icons/notifications from that one origin). All four clients live: Bitwarden iOS app (region → Self-hosted, Face ID unlock, set as the iOS primary password manager + passkey provider in iOS Settings → General → AutoFill & Passwords — iCloud Passwords toggled OFF for AutoFill); Bitwarden Desktop on the MacBook (added when Gabriel got the Mac, ~2026-05-22); Bitwarden Desktop + browser extension on the Windows laptop. The iPhone needs Tailscale connected to sync; the vault is cached encrypted on-device so AutoFill of existing entries works offline. - TOTP + passkeys plan (decided May 2026, gradual execution): consolidate both into the Vaultwarden vault. Vaultwarden's TOTP-in-vault is free (a paid Bitwarden-cloud feature) — store a site's TOTP secret in its login entry, Bitwarden autofills the code with the password. Bitwarden is already set as the iOS passkey provider, so new passkeys land in the vault. Existing passkeys: 8 in iCloud Passwords; can't be exported by anyone, by design — re-create per-site in Vaultwarden over time, and only delete each iCloud passkey after the Vaultwarden replacement is registered on that site (lockout risk otherwise). Moving off Microsoft Authenticator means re-enrolling 2FA per-site (no clean seed export); the few accounts that mandate the Microsoft Authenticator app (school/work Conditional Access, number-matching push — e.g. Conestoga) stay on it. The standalone Bitwarden Authenticator app (TOTP-only, would separate codes from the vault) was considered and not used; Ente Auth is the better standalone pick if separation is ever wanted.
- iCloud Passwords migration — COMPLETE (~2026-05-22). Gabriel got his own MacBook → Passwords app → File → Export All Passwords → CSV → Bitwarden web vault → Tools → Import data → format
Apple Passwords (csv)→ verified all entries present (minus Wi-Fi passwords and passkeys, both expected — neither can be exported from iCloud) → CSV securely deleted. iOS Bitwarden is now the primary password manager; iCloud Passwords AutoFill toggled OFF. New entries go to Vaultwarden only. The earlier MacinCloud cloud-Mac attempt (2026-05-11) had failed Apple's datacenter-IP anti-fraud — own/family/friend's physical Mac is the only path that works for the export. - iCloud Passwords cleanup — pending (low-pressure). The iCloud Passwords entries still exist (the migration only copied them); ~400 entries. Recommended bulk-delete from the MacBook: Passwords app → Cmd+A → Delete → authenticate; syncs to all Apple devices in ~a minute. Do NOT disable iCloud Keychain itself — keep that on for Wi-Fi password sync (which can't migrate to Vaultwarden by design). Recommended after a few days of confirmed Bitwarden-only use; do this opportunistically, no urgency.
- Backup considerations: In the nightly backup since May 2026.
db.sqlite3(WAL-mode, live) captured via hostsqlite3 "<live db>" ".backup '<dest>'"→/mnt/backup/current/db-dumps/vaultwarden-db.sqlite3(also off-site) — never raw-rsync the live file (same rule as Beszel's hub DB and Navidrome'snavidrome.db).rsa_key.pem(load-bearing) +attachments/+sends/+config.json(if present) +.env(mode 600 — version pin +DOMAIN= WebAuthn relying-party ID, load-bearing; +ADMIN_TOKEN/PUSH_*if added) + compose →service-config/vaultwarden/; the livedata/db.sqlite3anddata/tmp/are excluded from that rsync;icon_cache/not backed up (rebuildable). Restore: stop the container, dropdb-dumps/vaultwarden-db.sqlite3in asdata/db.sqlite3(remove stale-wal/-shm), restorersa_key.pem+attachments/+sends/+config.json+.env+ compose,chown -R 1000:1000 data/,docker compose up -d. See08-recovery.mdStep 6b. The HTTPS front-end is the Caddy stack under/data/docker/caddy/(in the backup), nottailscale serveconfig in/var/lib/tailscale/— that's retired. - What's not yet done: delete the iCloud Passwords entries (~400, still synced in iCloud — bulk-delete from the MacBook when ready; keep iCloud Keychain service on for Wi-Fi). Re-create the 8 existing passkeys in Vaultwarden, one site at a time (only delete each iCloud passkey AFTER its Vaultwarden replacement is registered — lockout risk otherwise). TOTP per-site re-enroll off Microsoft Authenticator. Run the password-health report (reused/weak/HIBP-exposed — free on Vaultwarden) and rotate the genuinely-bad ones. Optional: SSH keys in vault (Bitwarden's SSH-agent feature — useful for the GitHub key, syncs across MacBook + Windows laptop; doesn't apply to Tailscale SSH / iOS / headless CI); mobile push (instant iOS sync via free
bitwarden.com/hostregistration +PUSH_*in.env); admin panel (ADMIN_TOKENArgon2 hash in.env).
13. OpenProject (project management)¶
- What it is: a self-hosted FOSS project-management tool — WBS, Gantt with dependencies + critical path, baselines, milestones, work-package hierarchy, agile boards + sprints, budgets/cost, time tracking. The point of running it here is learning: the WBS + Gantt + baseline + EVM workflow is the same mental model as Microsoft Project and Primavera P6, which are what utility / power / EPC job postings ask for; the agile boards cover the Jira side of PMP-Agile. Same single tool teaches both predictive and agile sides of the PMBOK.
- Install path:
/data/docker/openproject/— the upstreamopf/openproject-deployrepostable/17branch cloned verbatim (git clone --depth=1 --branch=stable/17 ... /data/docker/openproject). The upstreamdocker-compose.ymlis untouched — every customization is in.env(mode 600).git pullonstable/17is the upgrade path. - Compose (upstream, do not edit): nine containers —
web(Rails Puma, 4–16 threads),worker(background jobs),cron(scheduled jobs),seeder(runsdb:migrate+db:seedon everyup, then exits —restart: on-failure),db(postgres:17),cache(memcached),proxy(locally-builtopenproject/proxy— internal nginx/Caddy publishing on127.0.0.1:8082),hocuspocus(openproject/hocuspocus:17.4.0— WebSocket for collaborative editing),autoheal(willfarrell/autoheal:1.2.0— restartswebif its healthcheck fails). Allrestart: unless-stoppedexceptseeder. ~1.5–2 GB RAM at idle (the heaviest single service on the box). .envcustomizations (the only file edited):TAG=17-slim,POSTGRES_VERSION=17(the upstream compose still defaults to PG13 for legacy installs; new installs override to 17 via.env)OPENPROJECT_HTTPS=true,OPENPROJECT_HOST__NAME=openproject.z2mini.gabrielgabrie.com,PORT=127.0.0.1:8082— TLS-terminating proxy out front, Rails emitshttps://URLsPGDATA=/data/docker/openproject/postgres,OPDATA=/data/docker/openproject/assets— override the upstream named volumes to bind mounts under/data/docker/openproject/so the backup user can reach them.postgres/is mode 700 uid 999 (matches Immich's Postgres pattern);assets/is uid 1000 (thewebcontainer's run-as user).- Three randomly-generated secrets:
SECRET_KEY_BASE(64 chars),COLLABORATIVE_SERVER_SECRET(32 chars),POSTGRES_PASSWORD(32 chars).POSTGRES_PASSWORDis also embedded inDATABASE_URLin.env— must match. RegeneratingSECRET_KEY_BASEinvalidates every session and every 2FA recovery code. COLLABORATIVE_SERVER_URL=wss://openproject.z2mini.gabrielgabrie.com/hocuspocus— Caddy auto-upgrades WebSocket connections, no extra config in the Caddyfile block.IMAP_ENABLED=false; SMTP unconfigured. Single-user setup — no inbound email-to-ticket flow, no outbound notifications yet. Add later viaIMAP_*(inbound) orEMAIL_DELIVERY_METHOD=smtp+SMTP_*(outbound) — with a separate Gmail app password from msmtp's (revocation-independence, same as Beszel's).
- HostAuthorization gotcha: Rails rejects any request whose
Host:header isn'tOPENPROJECT_HOST__NAME. Directcurl http://127.0.0.1:8082/from on-box returns HTTP 400 until you pass-H "Host: openproject.z2mini.gabrielgabrie.com" -H "X-Forwarded-Proto: https". Going through Caddy (https://openproject.z2mini.gabrielgabrie.com) works because Caddy preserves the originalHost:. This is the gate working, not a bug — note it when smoke-testing. - Bound interface:
127.0.0.1:8082only, fronted by Caddy →https://openproject.z2mini.gabrielgabrie.com(auto-renewing Let's Encrypt cert via Cloudflare DNS-01; cert issued May 2026, valid until Aug 2026 then auto-renews). Not reachable on the tailnet directly; not on the public internet. autohealwith Docker-socket-RW (upstream verbatim, kept): the autoheal container mounts/var/run/docker.sockread-write to call the Docker API and restart unhealthy containers. A compromised autoheal container would be effectively root on the host. Accepted because: (a) single-user, tailnet-only, no public inbound; (b) OpenProject is the heaviest Rails app on the box and most likely to go unhealthy under memory pressure or a slow query — auto-restart is genuinely useful; © keeping the upstream compose unedited is the documented "use upstream config verbatim" stance. Reconsider if a security posture change demands it.- First-boot behavior:
seederrunsdb:migrate+db:seed(~30 s on a fresh DB; longer on major upgrades), thenwebgoes healthy. Default login isadmin/admin; OpenProject forces a password change on first login. Set a strong password and store it in Vaultwarden. - Caddy block (already deployed):
Reload via the admin API:
docker compose -f /data/docker/caddy/docker-compose.yml exec -T caddy caddy reload --config /etc/caddy/Caddyfile. Gotcha: at install (May 2026),caddy reloadhad to be re-issued once — the first reload validated cleanly but never showed the new host in/config/apps/http/servers/srv0/routes. The second identical reload took effect. If a new site block doesn't appear in the admin API's routes list within a few seconds, reload again. - Backup considerations: In the nightly backup since install. Three pieces, mirroring Immich's pattern:
- Postgres dumped via
pg_dumpinside thedbcontainer → gzipped →/data/docker/openproject/backups/openproject-db-YYYY-MM-DD.sql.gz(last 14 retained). Thebackups/dir is then included in the per-service rsync. Never raw-rsync thepostgres/data dir — live Postgres = corrupt snapshot. assets/(uploaded attachments + exports, uid 1000, files-as-truth) — rsynced as part of the per-service config..env+ upstreamdocker-compose.yml+proxy/build context + the rest of the cloned repo — rsynced toservice-config/openproject/. The whole cloned tree is included (small — ~50 KB excluding the bind mounts). Off-site (T5):service-config/openproject/is included inbackup-offsite.sh. Restore: re-clone upstream, drop.env+assets/back, restore Postgres from the latest.sql.gz(gunzip -c <dump> | docker compose exec -T db psql -U postgres -d openproject),docker compose up -d.
- Postgres dumped via
- What's not yet done: First-login password change + first project + first work-package WBS are user actions, not install steps. SMTP for notifications not configured. OpenProject Homepage tile present (added at install time). No connection to Vikunja / Trello data — Gabriel's chosen project-management home is OpenProject; data migration from any prior tool is manual.
Design Decisions Already Made¶
These are settled choices. Suggesting reversal requires good reason and explicit discussion with Gabriel.
| Decision | Rationale | Don't suggest reverting |
|---|---|---|
| Linux server (no Windows) | Lower resource use, better Docker, career-relevant skills | Don't propose dual-boot or Windows-based solutions |
| File sharing via SMB (not Nextcloud or WebDAV) | Best iOS Files experience, cross-platform native support | Don't suggest installing Nextcloud "for files" |
| No Docker for Samba | Samba is infrastructure, runs better natively | Don't suggest moving to a Samba container |
| Tailscale (managed, free tier) | Working well, complexity of Headscale not justified | Don't suggest Headscale until Gabriel asks |
| ext4 filesystems | Simple, reliable, well-supported | Don't suggest ZFS/Btrfs migration unless data integrity becomes a stated goal |
| OS and data on separate drives | Failure isolation | Don't propose merging drives or RAID 0 |
| No RAID on data drive | Backups protect against drive failure; complexity not warranted for 1 user | Don't suggest mirror/RAID 1 unless uptime becomes a stated requirement |
/data/files as the user data root |
Already configured in Samba, backups, scripts | Don't change this path |
| Tailscale-only services (no public internet) | Privacy + security; student housing network limits anyway | Don't suggest opening ports until after September 2026 move |
| Caddy as the single HTTPS reverse-proxy ingress + custom domain (May 2026) | Every web app is at https://<svc>.z2mini.gabrielgabrie.com (Let's Encrypt cert via ACME DNS-01 through the Cloudflare API; auto-renews). Apps were re-bound to 127.0.0.1:<port> and speak plain HTTP behind Caddy — Tailscale still encrypts that wire. Replaced the per-service tailscale serve listeners. Caddy image is built locally = stock caddy + caddy-dns/cloudflare. See 17-caddy.md. |
Don't propose ripping out Caddy or reverting to per-service tailscale serve. Don't propose Traefik/nginx/NPM instead unless Caddy proves limiting. New services go behind Caddy (bind 127.0.0.1, add a Caddyfile block) — see the checklist. |
| Local backups + a manually-rotated off-site drive (no cloud) | Nightly on-site backup to /mnt/backup (the 990 PRO) covers everything; off-site is the demoted T5 (backup-offsite.sh) carried to parents' house and synced during visits — protects everything except the ~520 GB photo library, which doesn't fit on the 500 GB T5 (accepted gap until a >1 TB off-site drive). |
Don't push for paid cloud backup. Don't suggest copying the Immich library to the 500 GB off-site T5 — it doesn't fit; flag a >1 TB off-site drive as the next want instead. |
Samsung 990 PRO 1TB (refurb, verified genuine) as the on-site /mnt/backup; the old 500 GB T5 → off-site (May 2026) |
The refurb was authenticated before deploy (Samsung vendor ID 0x144d, model "Samsung SSD 990 PRO 1TB", firmware 7B2QJXD7, SMART PASSED, 0% used, ~12 power-on hr, 0 errors; ~1056/1009 MB/s = the USB 3.2 Gen 2 link saturating, not the drive's PCIe 4.0 ceiling). 1 TB fits the full backup incl. the ~520 GB photo library; the 500 GB T5 couldn't — so the bigger drive took the nightly on-site role and the T5 was demoted to off-site rotation. ext4 reserved-blocks set to 0 (tune2fs -m 0) on both since they only ever hold backups. smartd reads it via -d sntasmedia through the ASM2462 USB↔NVMe bridge. |
Don't suggest swapping the roles back. Don't suggest a /dev/sdX reference in fstab/smartd — UUID / /dev/disk/by-id/usb-...-0:0 are the stable ones. Don't re-enable ext4 reserved blocks on these drives. |
| Immich for photo storage | Best self-hosted Immich-vs-PhotoPrism-vs-Ente-vs-Photoview tradeoff for iPhone integration + on-server ML + project momentum | Don't propose alternative photo-management apps |
| Storage Template ON (files organized by capture date) | Recoverable / browsable backups; date-organized library has meaningful filesystem layout if Immich's DB is ever lost | Don't suggest turning it off — flipping it triggers a one-time job that physically moves every file |
| Immich port 2283 bound to Tailscale interface only | Same security posture as everything else; Immich must not be publicly reachable | Don't suggest binding to 0.0.0.0 or adding a reverse proxy without Gabriel asking |
ML cache as bind mount under /data/docker/immich/model-cache/ |
Keeps everything Immich-related under one path; ~2-3 GB of model weights stay on /data (the data drive) instead of /var/lib/docker/ (OS drive) |
Don't switch back to a named volume |
| GPU acceleration enabled (Quadro T2000, May 2026) | Used 1,030 file-pickup pass to validate, peak VRAM ~3.5/4 GB across CLIP + face + OCR with all loaded; ~3-5 hr of post-ingest ML on GPU vs ~12-25 hr on CPU | Don't propose disabling GPU; the install used pre-signed Canonical kernel modules to keep Secure Boot on |
iCloud → Immich migration via icloudpd (not CopyTrans Cloudly) |
Server-side execution, FOSS, no laptop dependency, no $20 license, no SMB hop. CopyTrans is a credible Plan B if icloudpd's auth fails mid-migration | Don't switch to CopyTrans unless icloudpd is genuinely blocked |
immich-go for bulk import (not the web UI or iOS app) |
Web UI fragile at 31k items; iOS app only sees photos locally on the device. immich-go is purpose-built for bulk via Immich's API and respects storage template |
Don't suggest dragging files into library/ directly — Immich's DB won't know about them |
| iCloud subscription downgraded to 200 GB tier ($3.99/mo), not the 50 GB tier ($1/mo) — May 2026 | Post-migration iCloud usage settled at ~150 GB across iMessage (~60 GB after attachment purge), Family Sharing pool (~85 GB used by family members), and iPhone Backup (~20 GB). Photos are now in Immich and effectively zero in iCloud. The 50 GB tier was rejected for three reasons: (1) iMessage alone is ~60 GB and won't shrink further without losing meaningful conversation history; (2) iPhone has 128 GB total / ~58 GB free, so the "Messages in iCloud OFF, hold the full 60 GB on-device" path doesn't fit, and turning Messages in iCloud OFF would just shift the bytes into iCloud Backup (not save quota); (3) Family Sharing pools storage — downgrading would force family members onto their own paid plans. Cost delta is ~$3/mo, judged not worth the architectural inconvenience. | Don't suggest dropping further to the 50 GB tier — was deliberately evaluated and rejected. Don't suggest "turn off Messages in iCloud to save space" — it just moves bytes between buckets in the same quota |
| iCloud Photos cleared before a real off-site backup exists (accepted risk window) | Originally, iCloud Photos was kept full as the off-site safety net for Immich until a 2 TB external arrived. Gabriel chose to clear iCloud Photos at the same time as the iCloud downgrade rather than maintain it as a safety net, accepting that Immich on /data is the only copy of photos until the 2 TB external arrives. Local nightly rsync to /mnt/backup doesn't survive theft, fire, or rm -rf. |
Don't suggest re-enabling iCloud Photos to recreate the safety net — the explicit choice was to live with the risk window. Do flag the off-site drive as the next priority when it comes up. |
| OpenProject chosen for project management / PMP learning (May 2026) — not Planka / Vikunja / Kanboard / WeKan / Taiga / Redmine | The lighter Trello clones teach kanban only; OpenProject's WBS + Gantt + baselines + EVM is the same mental model as MS Project and Primavera P6, which are what utility / power / EPC job postings actually list. Agile boards + sprints are in the same tool, so the Jira-Scrum side is covered too. The 5–10× RAM cost vs. Planka is the price of teaching the right thing. Upstream opf/openproject-deploy stable/17 cloned verbatim into /data/docker/openproject/; customization is .env-only. autoheal with Docker-socket-RW kept upstream-verbatim (single-user tailnet box, auto-restart on Rails OOM is useful). |
Don't suggest swapping to a lighter Trello clone — explicitly evaluated and rejected; the PMP / utility-industry transfer is the whole point. Don't edit docker-compose.yml directly — customize via .env; the upstream compose is git pull-managed. Don't suggest removing autoheal to "harden" the stack without weighing the auto-restart benefit. |
| iOS Immich auto-backup ON (since 2026-05-08), iCloud mass-deleted to ~100 items first | Avoids re-uploading photos Gabriel already pruned in Immich web UI; iOS Photos = "what should be in Immich" simplifies the mental model | Don't suggest reverting to manual share-sheet uploads — the daily-driver pattern is auto-backup |
| Navidrome for music streaming | Audio-only purpose-built; lightweight (~50 MB RAM); Subsonic-API ecosystem (many iOS clients with CarPlay); FOSS; no Plex Pass / subscription requirement | Don't suggest Plex (Plex Pass cost), Funkwhale (smaller community), or Jellyfin specifically for music (overkill for audio-only — Jellyfin can be added later for video without disturbing Navidrome) |
/data/music/ separate from /data/docker/navidrome/ |
Lets Samba [music] share write into music without exposing Navidrome runtime data; future Jellyfin-for-video can point at the same folder if music+video unification is ever wanted |
Don't suggest moving music inside /data/docker/navidrome/library/ or similar |
Music dir mounted read-only inside Navidrome container (:ro) |
Navidrome only reads files; the :ro is the container's view (host is unaffected, Samba writes work fine) |
Don't suggest making it :rw — there's no reason to and it removes a safety property |
| Library format AAC 256 with FLAC accepted | iTunes Store delivers AAC 256 natively; native iOS decoding; audibly transparent on car/BT-speaker gear; ~5 MB/track. FLAC also accepted (decoded natively by modern Subsonic clients). Mixed-format library is fine. | Don't suggest converting between formats just for consistency — lossless-to-lossy is destructive, lossy-to-lossless is pointless |
| Navidrome port 4533 bound to Tailscale interface only | Same security posture as everything else; Navidrome must not be publicly reachable | Don't suggest binding to 0.0.0.0 or adding a reverse proxy without explicit ask |
| Self-host as primary at-home + offline-cache for commute | At-home (laptop, BT speaker) is streamed; commute (CarPlay, away from Wi-Fi) uses iOS app's offline downloads to avoid cellular dead-zone stutters. Self-hosting earns its keep on the streaming half. | Don't suggest dropping the server in favor of "just sync iTunes purchases manually" — multi-device + cache-everywhere + central library is the value |
| iOS clients Arpeggio + Narjo (not Amperfy) | Amperfy's recent versions require iOS 26, which isn't on the iPhone. Arpeggio (App Store) and Narjo (TestFlight) both support CarPlay and connect cleanly to Navidrome via the Subsonic API. | Don't suggest Amperfy as the iOS client until iOS 26 is in use |
| Homepage for the launcher dashboard | Native widgets for Immich, Navidrome, Tailscale — no scraping required; YAML config (versionable, copy-pasteable in docs); active development; ~80 MB RAM idle. Glance is lighter but lacks integrations; Dashy is heavier; Heimdall/Homer/Flame are static-tile-only without live data. | Don't suggest swapping for Glance/Dashy/Heimdall/Homer/Flame unless Homepage proves limiting |
| Homepage port 3000 bound to Tailscale interface only | Same security posture as everything else; never publicly reachable | Don't suggest binding to 0.0.0.0 or adding a reverse proxy without explicit ask |
| Homepage Docker-socket integration mounted read-only | Lets Homepage display live container status dots (running / stopped / unhealthy) without needing Portainer. :ro is the safety property — Homepage cannot start/stop/exec into containers via this mount. |
Don't suggest making it :rw or adding write capabilities unless Gabriel asks for container management from the dashboard |
Homepage widget secrets in .env (mode 600), referenced via {{HOMEPAGE_VAR_*}} |
Keeps API tokens out of services.yaml (which would otherwise be committable as documentation); same pattern as Immich's .env for the DB password |
Don't suggest hardcoding tokens directly into services.yaml |
| Manual Tailscale token rotation (~80-day cycle) with cron-based email reminder | Tailscale enforces a 90-day max on access tokens — no plan offers permanent. OAuth client credentials issue 1-hour tokens which would force ~48 Homepage container restarts per day (env_file is loaded at container start, not live). Widget data (IP / last_seen / key-expiry) is low-value enough that auto-renewal isn't worth that complexity. Weekly tailscale-token-age-check.sh cron at Monday 09:00 emails the rotation steps when the token is older than 75 days. |
Don't propose OAuth auto-renewal again unless Homepage adds live env reload, or unless multiple Tailscale-token-consuming services arrive that would amortize the cost. Don't propose dropping the rotation check — once silently expired the widget breaks invisibly. |
| Beszel for system-metrics dashboard | Single Go binary per side, ~50 MB RAM total for hub + agent, modern UI, built-in alerting with email; Netdata is heavier (~150 MB) and nags toward its cloud; Glances has no time-series persistence; Grafana stack is 4+ containers and overkill for one server | Don't suggest Netdata / Glances / Grafana+Prometheus / Checkmk / Zabbix / Nagios unless Beszel proves limiting |
| Beszel hub + agent in same compose file via unix socket | Standard Beszel single-host pattern. Agent has zero TCP listener, eliminates an entire class of agent-exposure concerns. network_mode: host on the agent is still required for /proc and host network-stack access (metric reading), but no inbound port is opened. |
Don't suggest splitting to TCP listener mode on a single-host setup |
| Beszel port 8090 bound to Tailscale interface only | Same security posture as everything else; never publicly reachable | Don't suggest binding to 0.0.0.0 or adding a reverse proxy without explicit ask |
| Beszel emails directly to Gmail (not piped through msmtp) | Revocation independence — Beszel uses a separate Gmail app password (beszel-z2mini) so revoking it doesn't affect smartd's email channel via msmtp. Beszel's hub has built-in SMTP support; piping through localhost msmtp would add complexity for no security gain. |
Don't suggest reusing msmtp's app password for Beszel — defeats the revocation-independence reason for splitting |
| Hub + agent always pinned to the same Beszel version | Wire protocol can change between minor versions; skew breaks metric ingest until both align. | Don't suggest updating one independently |
| Docker socket mounted read-only on Beszel agent | Per-container metric collection; :ro is the safety property — agent cannot start/stop containers via this mount. Same pattern as Homepage. |
Don't suggest making it :rw |
| Radicale for self-hosted CalDAV/CardDAV (not Nextcloud, not Baïkal) | Files-as-truth storage (each event is an .ics file on disk — same recoverability property as Immich's storage template), ~30 MB RAM, single container, pure rsync backup with no DB ceremony, AGPL FOSS. Nextcloud has a much prettier calendar UI but is a 5+ container PHP platform with quarterly major-version upgrade maintenance — explicitly rejected on the "fewer moving parts during exam crunch" rationale. Baïkal stores in SQLite (more backup ceremony) and adds an admin GUI a single user doesn't need. |
Don't propose Nextcloud, Baïkal, AgenDAV, SOGo, or InfCloud as a Radicale replacement unless Radicale itself proves limiting. Don't propose Nextcloud "for the prettier calendar UI" — was deliberated and the moving-parts trade-off won. |
Radicale dual-endpoint pattern: plain HTTP for Thunderbird/scripts + HTTPS via tailscale serve for iOS |
iOS 18+ Calendar forces TLS on initial CalDAV verification regardless of the in-app "Use SSL" toggle (verified via Radicale logs showing ~12 TLS ClientHellos rejected before iOS gives up). tailscale serve --bg http://127.0.0.1:5232 puts a Tailscale-managed Let's Encrypt cert in front of Radicale — auto-renews, zero cert maintenance, real publicly-trusted cert (no mobile-config trust profile needed on iOS). Thunderbird and on-server scripts continue to use direct HTTP since they don't force TLS. |
Don't propose self-signed certs + iOS profile install (was rejected — tailscale serve is cleaner). Don't propose binding Radicale on 0.0.0.0 or removing the 127.0.0.1 port binding — tailscale serve proxies to localhost specifically to avoid bouncing back through the tailnet interface. |
| Radicale auth via htpasswd + bcrypt cost factor 12 (mode 644 on the users file) | Single-user setup; one bcrypt line is sufficient. Mode 644 because bcrypt hashes are designed to be safe at the filesystem level (Apache's default for htpasswd files is also 644) and 644 lets the container at UID 2999 read the file without chowning the host directory away from gabriel. Cost factor 12 (bcrypt's gensalt(rounds=12)) is current best practice; the image's old htpasswd -B default of 5 is too weak on modern hardware. |
Don't suggest LDAP or OAuth for Radicale auth — premature complexity for one user. Don't suggest mode 600 + chown to 2999 — adds a sudo step for no security gain (mode 644 + bcrypt is cryptographically safe). |
Radicale rights = owner_only |
Default-safe right policy: only the user who owns a collection can read/write it. Moot for one user but the right default if a second user is ever added (e.g., a shared family calendar). | Don't change to authenticated (any logged-in user could read all collections) without explicit ask. |
iCloud Calendar migration via publish-as-iCal .ics download (not vdirsyncer or Mac export) |
Universal path: works from any browser, uses Apple's official "Public Calendar" feature, no extra tooling. Trade-off: iCloud's public-cal feed strips all VALARM components by design — alarms are lost across the migration. Decision was to accept this and re-add alarms manually on the ~10 important recurring events (bills, key class times) rather than take on vdirsyncer (a Python tool with Apple app-specific-password dependency) for one-time migration tooling. |
Don't suggest re-running the migration with vdirsyncer to recover alarms — was evaluated and the manual-re-add path was chosen. Don't suggest dragging events directly into the collection-root filesystem; use Thunderbird's Import-into-collection workflow which round-trips correctly via CalDAV. |
| Conestoga Outlook stays as a separate Exchange account on iOS (not bridged into Radicale) | Org-managed Microsoft 365 doesn't expose CalDAV. DavMail-style bridges are fragile under modern auth. Cleaner architecture: Conestoga Exchange = native invite acceptance + writing school events on iOS (full detail visible). Conestoga's "Publish a calendar" .ics URL is subscribed read-only in Thunderbird only (publishes titles + locations only — tenant policy). |
Don't suggest a DavMail-style Outlook→CalDAV bridge. Don't suggest also subscribing the Conestoga publish feed on iOS — duplicates what Exchange already shows in fuller detail. |
| Laurier Outlook account dropped (not migrated) | Job ended; keeping a logged-in account against an org Gabriel no longer works for is attack-surface and credential-rotation hassle for zero ongoing benefit. Old events viewable via OWA in a browser if ever needed. | Don't suggest re-adding the Laurier account or migrating Laurier events into Radicale. |
| Vaultwarden for self-hosted passwords (not the official Bitwarden self-host, not KeePassXC) | Vaultwarden is a Rust reimplementation of Bitwarden's server — ~10–15 MB RAM, single container, SQLite, and it unlocks Bitwarden's "premium" features (TOTP-in-vault, attachments, Sends, password-health reports) for free. The official "unified" image is ~1 GB+ RAM with a bundled DB engine and license-gated features. KeePassXC + a .kdbx on the Samba share is the "files-as-truth" purist option (server-less, rsync-safe) but loses on iOS: third-party iOS clients, sync-conflict risk, clunkier autofill — and "integrates with iOS nicely" was the brief. Same lightweight-single-binary pattern as Beszel and Radicale; clients are the official Bitwarden apps/extensions pointed at the self-hosted URL. |
Don't propose the official Bitwarden self-host image, KeePassXC, Passbolt, or Padloc as a replacement unless Vaultwarden proves limiting. Don't suggest a separate "Vaultwarden client" — there isn't one. |
Vaultwarden runs as user: 1000:1000 (gabriel), not the image-default root, with ROCKET_PORT=8080 and cap_drop: ALL |
Running as the directory owner means no capability is needed to write data/ or bind a high port — so the hardened cap_drop: ALL stands and the data stays human-owned (clean backup, no sudo). The image's start.sh just execs the binary (no privilege-drop / chown), so non-root "just works". A root-default container crash-loops with PermissionDenied creating rsa_key.pem because cap_drop: ALL strips CAP_DAC_OVERRIDE and root then can't write a gabriel-owned dir. ROCKET_PORT=8080 (vs the image default 80) is what lets a non-root, capability-less process bind the port. |
Don't suggest reverting to root + adding CAP_DAC_OVERRIDE/CAP_CHOWN back, or chown -R 0:0 data/ — running as uid 1000 is the deliberate, cleaner fix. Don't suggest port 80 inside the container. |
Vaultwarden via tailscale serve HTTPS on port 8443 (container 127.0.0.1-only, no plain-HTTP tailnet endpoint) |
Bitwarden clients (mobile especially) require HTTPS for self-hosted; the web vault's PBKDF2 needs a secure context. Radicale already owns :443, so a second listener: tailscale serve --bg --https=8443 http://127.0.0.1:8080 — real auto-renewing Let's Encrypt cert, zero maintenance. Unlike Radicale (which exposes plain HTTP on the tailnet IP for Thunderbird/scripts), nothing here needs that — every client uses :8443, so the container binds 127.0.0.1 only (smaller surface). |
Don't propose a subpath on :443 (/vault) — finicky with some clients, and Radicale's CalDAV discovery wants root. Don't propose binding the container to 0.0.0.0 or the tailnet IP — tailscale serve reaches 127.0.0.1 fine. Don't propose self-signed certs + an iOS trust profile — tailscale serve's publicly-trusted CA path is cleaner (same reasoning as Radicale). |
Vaultwarden registration closed (SIGNUPS_ALLOWED=false); admin panel and SMTP left disabled |
Single-user box. Registration was open ~2 minutes on a private tailnet to create the one account, then closed. Admin panel (/admin) and SMTP add moving parts a single user doesn't need yet; both documented-but-commented in .env for later. |
Don't suggest leaving signups open. Don't suggest wiring SMTP unless invites/hints/email-2FA become needed — and if so, it gets its own Gmail app password (revocation independence, like Beszel). |
| TOTP + passkeys to be consolidated into the Vaultwarden vault (decided May 2026; not yet executed) | One app for passwords + 2FA codes (Vaultwarden's TOTP-in-vault is free); Bitwarden can also be the iOS passkey provider so new passkeys land in the vault. The "all eggs in one basket" trade is accepted given a strong master password (with the option to add 2FA to the vault login later). Migrating off Microsoft Authenticator = re-enroll 2FA per-site (no clean seed export); accounts that mandate the MS app (Conditional Access, number-matching push) stay on it. | Don't suggest the standalone Bitwarden Authenticator app (TOTP-only, would defeat the consolidation goal) — Ente Auth is the better pick only if separation is ever explicitly wanted. Don't suggest keeping TOTP solely in MS Authenticator long-term. |
What Was Considered and Rejected¶
These are tools Gabriel evaluated but chose not to install. Each could be revisited later, but suggest them with awareness that they were already deliberated.
- Nextcloud — rejected because iOS Files integration is poor; SMB chosen instead
Caddy + Tailscale plugin for HTTPS — abandoned mid-setup— superseded: Caddy is the reverse proxy as of May 2026, via a different path — a custom domain (*.z2mini.gabrielgabrie.com, DNS on Cloudflare) with Let's Encrypt certs from the ACME DNS-01 challenge through the Cloudflare API (not thecaddy-tailscaleplugin /tailscale certpath that was abandoned). See 17-caddy.md.- Traefik / nginx / Nginx Proxy Manager — evaluated as the reverse proxy; Caddy chosen (smallest config, automatic HTTPS + renewal built in, single static binary)
- TrueNAS Scale / Unraid (NAS-specific OSes) — rejected because dedicated NAS OSes optimize for multi-drive setups Gabriel doesn't have
- Snap packages (Nextcloud snap, Wekan snap, etc.) — rejected in favor of Docker-based deployments for future services
- Off-site cloud backup (Backblaze B2, etc.) — rejected; off-site backup will be a manually rotated drive at parents' house
- PhotoPrism / Ente / Photoview — evaluated as Immich alternatives in May 2026; Immich won on iPhone integration, on-server AI/ML, and project momentum
- CopyTrans Cloudly ($20 Windows GUI) — evaluated for iCloud → server bulk download; rejected in favor of
icloudpdfor server-side execution and FOSS. Kept available as Plan B if icloudpd's auth fails - iCloud for Windows (free) — evaluated for iCloud → server bulk download; rejected because it requires 458 GB+ of free local storage on the laptop (sync model, not download model) and a separate transfer step
- Apple Privacy Portal export — evaluated as last-resort fallback for iCloud migration; not used. Available if both icloudpd and CopyTrans fail (5-7 day wait, big zip files)
- Immich External Library mode — considered for ingesting
/data/icloud-import/directly without moving files; rejected because it bypasses storage template — files would stay in icloudpd's date layout instead of getting reorganized into Immich's managed library - Plex (with Plex Pass for Plexamp) — evaluated for music streaming; rejected because Plex Pass ($120 lifetime) violates the no-subscription constraint
- Jellyfin specifically for music — evaluated; rejected because Navidrome's purpose-built music admin/scanning is cleaner and lighter. Jellyfin remains a candidate for future video — both can coexist at
/data/docker/<service>/ - Funkwhale, Ampache, Mopidy — evaluated as Navidrome alternatives; rejected for various reasons (community size, dated UX, "building block not finished product")
- Amperfy as the iOS Subsonic client — chosen on paper; rejected on install because recent versions require iOS 26 (not available on the iPhone's current iOS). Arpeggio + Narjo (TestFlight) chosen instead
- Picard installed on the server / on iOS — evaluated location; not viable — Picard is a desktop GUI app, runs on the Windows laptop only
- All-FLAC music library — evaluated; rejected for the user's listening environments (car / BT speaker / loudspeaker — lossless not audible) and for storage/workflow simplicity. FLAC is accepted on a per-file basis (Bandcamp / lossless CD rips), just not pursued as the standard format
- Glance — evaluated as a Homepage alternative; lighter and faster (single Go binary) but lacks native Immich/Navidrome/Tailscale widgets — would require RSS-style scraping. Reconsider if Homepage proves heavy
- Dashy — evaluated as a Homepage alternative; heavier (Vue SPA), rebuild-on-config-change flow, more theme options. Worse fit for YAML-as-code config
- Heimdall / Homer / Flame — older static-tile launcher pages, no live data widgets. Mostly superseded by Homepage / Glance
- Portainer / Dockge — evaluated as a Docker-management UI complement to Homepage; declined because hand-written compose files in
/data/docker/<service>/are already the source of truth, and adding a GUI layer trades simplicity for a feature not currently needed (only ~3 stacks). Reconsider above ~5 stacks or if multiple operators need access - Netdata — evaluated as a Beszel alternative; rejected for higher RAM footprint (~150 MB vs ~50 MB for Beszel hub+agent), opinionated cloud-signup nudges, and more metric depth than this scale needs. Self-host fully if revisited
- Glances — evaluated as a Beszel alternative; lightweight but no time-series persistence (graphs are real-time only, no history). Better as a souped-up
htopthan a dashboard - Grafana + Prometheus + node_exporter + cAdvisor — evaluated as a Beszel alternative; the "industry-standard" stack but 4+ containers, hours of YAML, real learning curve. Right answer for a job, overkill for one homelab. Could be revisited if many machines + complex dashboards become the goal
- Checkmk / Zabbix / Nagios — enterprise-monitoring tools; wrong scale entirely
- Tailscale OAuth client auto-renewal for Homepage's widget token — evaluated 2026-05-07; rejected because OAuth tokens expire in 1 hour (Tailscale design constant) and Homepage's env_file is loaded only at container start, forcing ~48 daily container restarts for low-value widget data. Replaced with weekly cron + email reminder for manual 80-day rotation. Reconsider only if Homepage gains live env reload, or if multiple Tailscale-API-consuming services emerge to amortize the complexity.
- Nextcloud (Calendar app only) for self-hosted calendar — evaluated 2026-05-09 as a Radicale alternative; the calendar UI is the closest-to-iCloud.com FOSS web calendar that exists, includes a polished Tasks app, and federation/sharing features Radicale lacks. Rejected because Nextcloud is a 5+ container PHP platform requiring quarterly major-version upgrade care; Gabriel explicitly chose "fewer moving parts during exam crunch" robustness over UI polish. Reconsider only if (a) Radicale proves limiting in some specific way, or (b) Gabriel's tolerance for Nextcloud-style platform maintenance changes.
- Baïkal as a Radicale alternative — evaluated 2026-05-09; sabre/dav-based, same protocol guarantees, slightly more polished admin UI. Rejected because storage is SQLite (vs Radicale's flat files — more backup ceremony) and the admin GUI is unnecessary for a single user.
vdirsyncerfor CalDAV-to-CalDAV migration (preserving alarms) — evaluated 2026-05-10; would have preservedVALARMcomponents that the iCloud public-cal feed strips. Rejected because (a) past-event alarms don't matter, (b) future alarms get re-added once in Radicale and persist forever via CalDAV, © it adds a Python tool with an Apple app-specific-password dependency for a one-time migration. Worth revisiting if a future migration involves alarms-as-data.- Self-signed cert + iOS mobile-config trust profile — evaluated 2026-05-10 as the iOS-HTTPS path; would avoid
tailscale serve. Rejected because Tailscale's Let's Encrypt path uses a publicly-trusted CA (no trust profile install needed, no perpetual "trust this device" footnote). - DavMail / similar Outlook-CalDAV bridges — evaluated for Conestoga and Laurier Outlook accounts; rejected as fragile under modern auth, and solving the wrong problem (Conestoga Exchange already works fine on iOS natively; the publish-as-iCal feed handles Thunderbird).
- AgenDAV / SOGo / InfCloud (third-party CalDAV web frontends pairing with Radicale) — evaluated as a way to get a prettier calendar UI without taking on Nextcloud; rejected because all three look "2014" — if pretty UI matters enough to take on a separate frontend, Nextcloud's UI is better.
- Official Bitwarden self-hosted ("unified" container) — evaluated 2026-05-10 as the Vaultwarden alternative; same clients, but ~1 GB+ RAM, a bundled DB engine, and a license key for a few features (free for personal, still a moving part). Heavier than the rest of the stack for no gain at this scale.
- KeePassXC + KeePassium/Strongbox (iOS) +
.kdbxover Samba/WebDAV — evaluated 2026-05-10 as the server-less, files-as-truth password option (matches Radicale's storage philosophy, trivially rsync-safe). Rejected on iOS integration: third-party iOS apps, sync-conflict risk if two devices edit at once, browser autofill needs the desktop app running. The brief was "integrates with iOS nicely". - Passbolt / Padloc — Passbolt is team-oriented and PHP+MySQL-heavy; Padloc's iOS story is weak. Neither beats Vaultwarden for a single user.
- Standalone Bitwarden Authenticator app — a TOTP-only companion that keeps codes outside the password vault. Not used — Vaultwarden's built-in TOTP-in-vault is free and the goal is consolidation, not separation. Ente Auth (open-source, clean import/export) is the better choice if separation is ever wanted.
- Cloud-Mac rental (MacinCloud / Scaleway / AWS EC2 Mac) for the iCloud Passwords export — attempted 2026-05-11 (MacinCloud 48-hour managed-server trial, RDP). Apple's anti-fraud blocks iCloud sign-in from datacenter IPs — the device-passcode verification looped with "There was an error verifying the passcode on your iPhone" despite a correct passcode. Abandoned; the migration was eventually done on Gabriel's personal MacBook (~2026-05-22). The cloud-Mac route remains a dead end for anything iCloud-related — Apple's datacenter-IP rejection is a hard wall.
Future Plans (informed by Gabriel's stated priorities)¶
These are projects Gabriel has expressed interest in. If asked about them, you can build on this context.
Drop iCloud Photos to 50 GB tier— Done in modified form (May 2026): iCloud Photos cleared, subscription downgraded to 200 GB tier ($3.99/mo) instead of 50 GB. 50 GB was rejected because iMessage post-purge (~60 GB) + Family Sharing pool (~85 GB) wouldn't fit, and the iPhone (128 GB / ~58 GB free) can't hold the full Messages history offline anyway. See Decisions table for full rationale.2 TB external SSD for off-site backup— Resolved in modified form (May 2026): a refurbished Samsung 990 PRO 1TB was acquired and verified genuine, and became the on-site/mnt/backup(~916 GB usable — fits the full backup incl. the photo library); the demoted 500 GB T5 is now the off-site rotation drive (backup-offsite.sh— carried to parents' house, synced during visits). A >1 TB off-site drive is still wanted eventually so the photo library can be off-site too — right now the off-site T5 covers everything except the ~520 GB Immich library (it doesn't fit on 500 GB; accepted gap). Interim option: fit just the ~442 GB of irreplaceable photo originals onto the T5 by skipping thumbs/transcodes/DB-dumps — deferred, Gabriel's call.- OS-drive shrink + new partition — Decided 2026-05-03, deferred to a physical-access session. Plan: shrink
/dev/nvme1n1p2from 953 GB → 200 GB, create/dev/nvme1n1p3~750 GB ext4 mounted at/mnt/scratch. Method: GParted Live USB. Prereqs: USB stick, monitor + keyboard physically attached, ~45-60 min uninterrupted, fresh backup + system-state tarball run immediately before. - Energy Observatory — Continuous scraping/storage of Ontario IESO data into a database, building a dataset for power systems analysis. Expected to use Docker + PostgreSQL or SQLite.
- GridShift — Web tool modeling neighborhood-level transformer impacts from electrification adoption. Would benefit from being hosted on Z2 with public access via reverse proxy after September move.
- GridPulse Canada — Real-time visualization of Canadian provincial grids.
- Off-Diesel — Stretch project for remote community renewable feasibility analysis.
Off-site backup at parents' house— Implemented (May 2026): the demoted 500 GB Samsung T5, reformatted asbackup-offsite(UUID2c8e8e38-f129-4823-a1db-1529d3296b44), lives at parents' house. During a family visit: plug it into z2mini →sudo mount /dev/disk/by-label/backup-offsite /mnt/offsite→~/scripts/backup-offsite.sh→sudo umount /mnt/offsite→ unplug → carry back. Copiescurrent/{files,music,db-dumps,service-config}/+system-state/(~3 GB) — NOT the ~520 GB Immich library (doesn't fit; a >1 TB off-site drive would close that). Writes/mnt/offsite/offsite-backup.log. See05-backups.md.- Mac connection setup — Will mirror Windows setup using
smb://z2minifrom Finder. - Migration to ethernet — Planned for September 2026 move; will improve reliability and bandwidth.
- HTTPS with custom subdomains — Deferred; will be tackled when there's a public-facing service to justify it.
- Ansible playbook for full system reproduction — Future iteration of recovery: instead of restoring configs, write infrastructure-as-code that recreates the system on any hardware. Replaces the current tar-based config backup with a more powerful, version-controlled approach.
Wire Immich into nightly backup— Done (May 2026): thelibrary/UPLOAD_LOCATION(incl.encoded-video/) is rsync-mirrored to/mnt/backup/current/immich/; the Postgres DB is captured via Immich's own built-in nightly auto-backup writing.sql.gzdumps intolibrary/backups/(default ON — must stay on), which the 03:00 rsync picks up.postgres/is never filesystem-rsync'd;model-cache/not backed up. See11-immich.md/05-backups.md.Wire Navidrome into nightly backup— Done (May 2026):/data/music/rsync-mirrored;navidrome.dbcaptured via hostsqlite3 ".backup"intocurrent/db-dumps/;.env+compose inservice-config/navidrome/;data/artwork/+data/cache/not backed up (regenerable). Never raw-rsync the livenavidrome.db.- Music acquisition — working list at
music-acquisition-list.mdonfeat/music-installbranch. Tier 1 (Imagine Dragons, Linkin Park, Bastille, Maroon 5, Ed Sheeran), Tier 2 (Latin / hip-hop / pop top hits), Tier 3 (curated playlists — classical canon, hymns, Christian pop, sports/hype, World Cup tracks). Mostly iTunes Store; library borrows for classical and older Latin; Bandcamp for indie acoustic. Wire Beszel into nightly backup— Done (May 2026): both hub SQLite DBs (data.dbANDauxiliary.db) captured via hostsqlite3 ".backup"intocurrent/db-dumps/;.env+compose inservice-config/beszel/; the hub'sdata/id_ed25519SSH key not backed up (root-owned, unreadable — regenerable, but means re-pairing the one agent);agent-data/+socket/not backed up. Also: the agent's disk-monitoring labels —/extra-filesystems/data__Data+/extra-filesystems/backup__Backupshow as "Data"/"Backup" in the hub UI; the OS root shows asnvme1n1p2(Beszel can't rename the root fs).- Cut iCloud Calendar (Phase 7 of the Radicale migration) — Planned ~2026-05-24, after the two-week parallel-run window completes cleanly. Steps: iOS Settings → [name] → iCloud → Calendars OFF → "Delete from iPhone"; then iCloud.com → Calendar → delete each calendar to actually purge from Apple's servers. Don't disable iCloud Calendar before the parallel-run window completes — recovery from a Radicale problem is much harder if iCloud is already gone.
- Re-add alarms on the ~10 important recurring events post-migration — iCloud's public-calendar feed stripped all VALARMs during the 2026-05-10 migration. Re-add alarms once in Thunderbird or iOS for: monthly bills, key class times, deadline-style recurring reminders. Each alarm is set on the recurring master event and persists forever via CalDAV.
Wire Radicale into nightly backup— Done (May 2026):.env+compose+config/+data/collections/rsync'd intoservice-config/radicale/— nopg_dump, no SQLite ceremony, files-as-truth; verified byte-for-byte; the calendars are on the off-site T5 too.- Self-host CardDAV (contacts) on the same Radicale server — Deferred follow-up after the calendar parallel-run window completes. Same daemon, same auth, same storage tree; just create a
gabriel/contacts/collection of typeaddressbookand add the same server URL as a CardDAV account on iOS. Contact sync is a one-shot to-Radicale move similar to the calendar migration. - Update
RECOVERY-README.mdfor the Caddy stack — After a Z2 rebuild: restore/data/docker/caddy/(or recreateDockerfile+docker-compose.yml+Caddyfile+.envwith the Cloudflare token),docker compose build && docker compose up -d, andsudo tailscale set --operator=gabrielafter Tailscale re-auth. The Caddyfile already has every service's block, so all the<svc>.z2mini.gabrielgabrie.comendpoints come back at once. (This replaces the old "re-runsudo tailscale serve ...for Radicale and Vaultwarden" step — those listeners are retired.) See 17-caddy.md. Migrate iCloud Passwords into Vaultwarden— Done (~2026-05-22) on Gabriel's personal MacBook: Passwords app → File → Export All Passwords → CSV → web vault → Tools → Import data → formatApple Passwords (csv)→ verified all present (minus Wi-Fi passwords + passkeys — both expected) → CSV deleted. iOS AutoFill switched to Bitwarden, iCloud Passwords AutoFill off. The MacinCloud cloud-Mac attempt (2026-05-11) had failed Apple's datacenter-IP anti-fraud; a personal trusted Mac was the path that worked.Configure the Windows Bitwarden clients— Done: desktop app + browser extension on Windows, plus Bitwarden Desktop on the MacBook. All four clients (iOS, Mac, Windows, browser ext) pointed athttps://vault.z2mini.gabrielgabrie.com.- Delete the iCloud Passwords entries (post-migration cleanup) — the migration only copied; the entries still exist in iCloud. Recommended: from the MacBook, Passwords app → Cmd+A → Delete → authenticate; syncs the deletions to all Apple devices. Do NOT disable iCloud Keychain itself — keep that on for Wi-Fi password sync (can't migrate to Vaultwarden by design). Low-pressure; do after a few days of confirmed Bitwarden-only use.
- Re-create the 8 existing passkeys in Vaultwarden, per-site — Bitwarden already set as the iOS passkey provider; new passkeys go to the vault automatically. Existing passkeys can't be exported (by design), so per site: register a new Vaultwarden passkey → confirm it works → then delete the iCloud passkey for that site. Doing it in that order avoids lockout.
- Consolidate TOTP into the Vaultwarden vault — per-site re-enroll off Microsoft Authenticator — no clean seed export from MS Authenticator, so per site: security settings → remove old authenticator → add new → scan QR into the matching Bitwarden login entry. MS Authenticator stays only for accounts that mandate the MS app (Conestoga / Conditional Access / number-matching push). Gradual.
- Run the Vaultwarden password-health report and rotate bad passwords — web vault → reused / weak / HIBP-exposed report (a paid Bitwarden-cloud feature, free on Vaultwarden) → rotate the genuinely-bad ones. This (not deleting stale entries) is the high-value cleanup post-migration.
- Optional: SSH keys in Vaultwarden — Bitwarden has an SSH key item type + the desktop app can act as an SSH agent. Worth doing for the GitHub key (cross-device sync between MacBook + Windows laptop; vault unlock per use). Wire-up: Bitwarden Desktop → Settings → SSH agent → enable → point the OS's SSH client at the Bitwarden agent socket. Does NOT apply to
ssh gabriel@z2mini(Tailscale SSH uses tailnet identity, not a key), to iOS (no usable agent integration), or to headless/CI SSH (still needs a bare key file). Wire Vaultwarden into nightly backup— Done (May 2026):db.sqlite3captured via hostsqlite3 ".backup"intocurrent/db-dumps/vaultwarden-db.sqlite3;rsa_key.pem+attachments/+sends/+config.json+.env+ compose inservice-config/vaultwarden/(livedb.sqlite3+tmp/excluded from that rsync). Never raw-rsync the live WAL-modedb.sqlite3(same rule as Beszel / Navidrome). Vault DB dump is on the off-site T5 too.- Optional: enable mobile push for instant iOS sync — register (free) at
https://bitwarden.com/host/, addPUSH_ENABLED=true+PUSH_INSTALLATION_ID+PUSH_INSTALLATION_KEYto.env,docker compose up -d. Without it the iOS app syncs on open / manual pull rather than instantly. - Optional: enable the Vaultwarden admin panel — generate an Argon2 hash (
docker run --rm -it vaultwarden/server /vaultwarden hash), setADMIN_TOKEN='$argon2id$...'(quoted) in.env,docker compose up -d, then/adminfor server config / user list.
Operational Notes¶
Common tasks¶
# SSH access (from Gabriel's laptop)
ssh gabriel@z2mini
# Manual backup trigger (nightly on-site)
~/scripts/backup-files.sh
# Off-site sync (MANUAL — T5 plugged in)
sudo mount /dev/disk/by-label/backup-offsite /mnt/offsite
~/scripts/backup-offsite.sh
sudo umount /mnt/offsite
# Check backup status
tail -50 /mnt/backup/backup.log
ls /mnt/backup/current/ # files/ immich/ music/ db-dumps/ service-config/
# Drive health spot-check
sudo smartctl -a /dev/nvme0n1
sudo smartctl -a /dev/nvme1n1
sudo smartctl -a -d sntasmedia /dev/sdb # the 990 PRO over the ASM2462 USB enclosure (use whatever lsblk shows)
# System updates (recommended monthly)
sudo apt update && sudo apt upgrade -y
# Disk usage check
df -h
# Service status check
sudo systemctl status smbd tailscaled smartd
# Immich — current container state
cd /data/docker/immich && docker compose ps
# Immich — tail the server logs
cd /data/docker/immich && docker compose logs -f --tail 50 immich-server
# Immich — disk use
du -sh /data/docker/immich/library/ /data/docker/immich/postgres/ /data/docker/immich/model-cache/
# Immich — total asset count via API (requires API key in ~/.immich-api-key)
curl -sH "x-api-key: $(cat ~/.immich-api-key)" http://127.0.0.1:2283/api/server/statistics | python3 -m json.tool
# icloudpd — re-attach to the running bulk download
tmux attach -t icloud-migration
# icloudpd — tail the bulk-download log
tail -f /tmp/icloudpd-bulk.log
# icloudpd — check tmux session status
tmux ls
# icloudpd — count files downloaded so far
find /data/icloud-import -type f | wc -l && du -sh /data/icloud-import
# immich-go — bulk import staging into Immich (after icloudpd finishes)
/home/gabriel/scripts/immich-go upload from-folder \
--server http://127.0.0.1:2283 \
--api-key "$(cat ~/.immich-api-key)" \
--manage-heic-jpeg StackCoverJPG \
--no-ui \
/data/icloud-import
# Navidrome — current container state
cd /data/docker/navidrome && docker compose ps
# Navidrome — tail server logs
cd /data/docker/navidrome && docker compose logs -f --tail 50 navidrome
# Navidrome — disk use (music library + Navidrome state)
du -sh /data/docker/navidrome/data/ /data/music/
# Navidrome — manual scan trigger via Subsonic API (replace PASS)
curl -sS "http://127.0.0.1:4533/rest/startScan.view?u=admin&p=PASS&v=1.16.1&c=cli&f=json"
# Navidrome — count of indexed tracks via API (replace PASS)
curl -sS "http://127.0.0.1:4533/rest/getSongs.view?u=admin&p=PASS&v=1.16.1&c=cli&f=json&size=1" | python3 -m json.tool
# Homepage — current container state
cd /data/docker/homepage && docker compose ps
# Homepage — tail server logs
cd /data/docker/homepage && docker compose logs -f --tail 50 homepage
# Homepage — show widget env vars resolved inside container (sanity-check token loading)
docker compose -f /data/docker/homepage/docker-compose.yml exec homepage env | grep HOMEPAGE_VAR_
# Homepage — verify Docker socket reachable from inside the container (live status dots)
docker compose -f /data/docker/homepage/docker-compose.yml exec homepage ls -la /var/run/docker.sock
# Beszel — current container state (hub + agent)
cd /data/docker/beszel && docker compose ps
# Beszel — tail hub or agent logs
cd /data/docker/beszel && docker compose logs -f --tail 50 beszel
cd /data/docker/beszel && docker compose logs -f --tail 50 beszel-agent
# Beszel — disk use of hub DB
du -sh /data/docker/beszel/data/
# Beszel — SQLite-online backup (use this, NEVER raw rsync of an open SQLite DB)
docker compose -f /data/docker/beszel/docker-compose.yml exec beszel \
sqlite3 /beszel_data/data.db ".backup /beszel_data/data-backup.db"
# Radicale — current container state
cd /data/docker/radicale && docker compose ps
# Radicale — tail server logs (auth failures, PROPFIND requests visible)
cd /data/docker/radicale && docker compose logs -f --tail 50 radicale
# Radicale — disk use of collections tree
du -sh /data/docker/radicale/data/collections/
# Radicale — list collections (per user)
ls /data/docker/radicale/data/collections/collection-root/gabriel/
# Radicale — count events in a specific collection
ls /data/docker/radicale/data/collections/collection-root/gabriel/<uuid>/*.ics | wc -l
# Radicale — verify HTTPS endpoint via tailscale serve (should return HTTP 207 PROPFIND)
curl -s -o /dev/null -w 'HTTP %{http_code}\n' \
-X PROPFIND -H 'Depth: 0' \
-u 'gabriel:<password>' \
https://radicale.z2mini.gabrielgabrie.com/gabriel/
# Radicale — show tailscale serve proxy config
sudo tailscale serve status
# Radicale — generate a new bcrypt hash (e.g. password rotation)
docker run --rm --entrypoint /venv/bin/python3 \
tomsquest/docker-radicale:3.7.2.0 \
-c "import bcrypt; print('gabriel:' + bcrypt.hashpw(b'<new-password>', bcrypt.gensalt(rounds=12)).decode())"
# Vaultwarden — current container state
cd /data/docker/vaultwarden && docker compose ps
# Vaultwarden — tail server logs (failed logins, sync, WebSocket connections visible)
cd /data/docker/vaultwarden && docker compose logs -f --tail 50 vaultwarden
# Vaultwarden — health + resource use
curl -fsS https://vault.z2mini.gabrielgabrie.com/alive # returns a timestamp
docker stats --no-stream vaultwarden # idles ~10–15 MiB
# Vaultwarden — disk use
du -sh /data/docker/vaultwarden/data/
# Vaultwarden — inspect the vault at the DB level (metadata only — contents are encrypted)
docker run --rm -v /data/docker/vaultwarden/data:/data:ro --entrypoint sh keinos/sqlite3 -c '
sqlite3 -header -column /data/db.sqlite3 "SELECT email, updated_at FROM users;"
sqlite3 -header -column /data/db.sqlite3 "SELECT count(*) AS n_items FROM ciphers WHERE deleted_at IS NULL;"'
# Vaultwarden — show tailscale serve proxy config (both Radicale :443 and Vaultwarden :8443)
sudo tailscale serve status
# Vaultwarden — SQLite online backup (NEVER raw-rsync the live WAL-mode db.sqlite3)
docker run --rm -v /data/docker/vaultwarden/data:/data --entrypoint sh keinos/sqlite3 \
-c 'sqlite3 /data/db.sqlite3 ".backup /data/db-backup.sqlite3"'
# Vaultwarden — generate an Argon2 hash for the admin panel (then put ADMIN_TOKEN='...' in .env, quoted)
docker run --rm -it vaultwarden/server /vaultwarden hash
Recovery procedures¶
- Accidental file deletion within 7 days: Browse to
/mnt/backup/daily/<date>/files/and copy back (note thefiles/subdir —current//daily//weekly/also holdimmich/,music/,db-dumps/,service-config/). - Accidental file deletion within 4 weeks: Browse to
/mnt/backup/weekly/<date>/files/. - Z2 hardware failure: Plug the 990 PRO (in its ASM2462 USB enclosure) into another Linux machine, mount, copy data from
/mnt/backup/current/{files,immich,music,db-dumps,service-config}/. The off-site T5 (backup-offsite) is a second copy of everything except the Immich library. - Full system rebuild: Follow
/mnt/backup/RECOVERY-README.md(and08-recovery.md) for step-by-step. Restores OS configuration, package list (incl.sqlite3), crontab, data, and the Docker stacks (service-config/<svc>/+ thedb-dumps/SQLite DBs + the Immichlibrary/+.sql.gzDB dump per Immich's docs +docker compose build && up -dfor Caddy). Estimated time: ~1 hour for the OS/config baseline; the Immich library rsync-back is the long pole if/datawas lost. - After any full rebuild — bring up the Caddy stack:
/data/docker/caddy/(Dockerfile + compose + Caddyfile +.envwith the Cloudflare token) is under/data/docker/, so it's in the backup. Restore it, thendocker compose build && docker compose up -dandsudo tailscale set --operator=gabriel(after Tailscale re-auth). The Caddyfile already has every service's block — all thehttps://<svc>.z2mini.gabrielgabrie.comendpoints come back at once. (No moretailscale servere-init — those listeners are retired. See 17-caddy.md.) - Vaultwarden vault restore: stop the container; copy
db-dumps/vaultwarden-db.sqlite3in asdata/db.sqlite3(remove any stale-shm/-wal); restorersa_key.pem+attachments/+sends/+config.json+.env+ compose fromservice-config/vaultwarden/;chown -R 1000:1000 data/;docker compose up -d. Losingrsa_key.pemis survivable (every client just has to log in again) but it's backed up anyway. - Other-service restore (Navidrome / Beszel / Radicale / Homepage / Caddy / Immich):
service-config/<svc>/→/data/docker/<svc>/; the SQLite ones get theirdb-dumps/<name>.dbdropped in as the live DB; Immich = rsynccurrent/immich/→library/, then recreate Postgres from the most recentlibrary/backups/*.sql.gzper Immich's docs; Caddy =docker compose build && up -d(re-issues certs). See08-recovery.mdStep 6b for the exact commands. - SSH lockout: Physical access required. Monitor and keyboard live somewhere accessible, not in deep storage.
Known quirks¶
- Backup log lives on the backup drive itself — if the backup drive is unmounted, errors silently fail to log. The script's mount check prevents this becoming dangerous but does mean diagnostic info is lost in that scenario.
lost+founddirectory is hidden from the SMB share viaveto files(Windows clients only) — it may still appear visible from iOS Files.- AppArmor enforces strict permissions on msmtp; modifying its config requires updating the local override file.
- The backup drive's NVMe SMART data is only readable through the ASM2462 USB enclosure with
smartctl -d sntasmedia; without that flag you see a generic USB mass-storage device. NVMe scheduled self-tests don't pass through the bridge (admin command0x14unsupported), so the smartd line for it has no-sschedule. - After the backup drive was swapped (T5 → 990 PRO) the Beszel agent kept reporting the old
sda1untildocker restart beszel-agent; the hub also shows stale "ghost" disk entries (nvme0n1p1,sdb1,sda1) for a while after a relabel until they age out of retention — harmless. - A live SQLite DB (Vaultwarden's
db.sqlite3, Beszel'sdata.db/auxiliary.db, Navidrome'snavidrome.db) must never be raw-rsync'd — the backup script usessqlite3 ".backup"for these. Likewise Immich'spostgres/data dir is never rsync'd — the.sql.gzdumps inlibrary/backups/(written by Immich's own nightly auto-backup, default ON) are the DB copy.
Things to Avoid Suggesting Without Explicit Request¶
- Reformatting drives or changing partition layouts (the OS-drive shrink is decided but stays deferred until Gabriel chooses a physical-access session)
- Removing or replacing core services (Samba, Tailscale, smartd)
- Changing the backup script logic or retention policy
- Installing alternative file-sharing systems (Nextcloud, Seafile, etc.) or alternative photo apps (PhotoPrism, Ente, Photoview — already evaluated)
- Moving to dedicated NAS hardware or NAS-focused operating systems
- Adding services that bind to common ports already in use
- Cloud-based backup services or paid SaaS replacements
- VPN alternatives to Tailscale unless Tailscale specifically fails
- Disabling GPU acceleration on Immich's ML container (it's been enabled on the Quadro T2000 with pre-signed kernel modules; rolling back loses 4-5× speedup on face/CLIP/OCR jobs)
- Dropping the iCloud subscription further to the 50 GB tier (deliberately rejected May 2026 — iMessage ~60 GB and Family Sharing pool ~85 GB don't fit; iPhone storage can't hold full Messages history offline; see Decisions table)
- Re-enabling iCloud Photos to recreate the off-site safety net for Immich (the explicit choice was to accept the risk window until the 2 TB external arrives, not to keep paying for iCloud as redundant storage)
- "Turn off Messages in iCloud to save iCloud space" — it doesn't save quota, it just moves bytes from the Messages bucket into the iCloud Backup bucket in the same plan
- Filesystem rsync of
/data/docker/immich/postgres/(live Postgres data dir is corruption-unsafe under rsync — usepg_dump) - Dropping files into Immich's
library/directly (the DB won't know about them; storage template won't apply; faces and search won't work) - Plex / Plex Pass alternatives to Navidrome (Plex Pass cost violates the no-subscription rule)
- Converting the AAC music library to FLAC for "quality" reasons (audibly transparent on the user's listening gear; cost is 7× storage and a destructive workflow)
- Treating
/data/music/as part of/data/docker/navidrome/— they're deliberately separate so Samba can write to one without exposing the other - Filesystem-rsync of
/data/docker/navidrome/data/navidrome.dbwhile the container is running (use SQLite's online.backupAPI or stop the container first) - Suggesting Amperfy as the iOS Subsonic client until iOS 26 is in use (its recent versions require iOS 26)
- Replacing Homepage with Glance/Dashy/Heimdall/Homer/Flame (already evaluated — Homepage's native widgets for Immich/Navidrome/Tailscale are the deciding factor)
- Hardcoding Homepage widget API tokens directly into
services.yaml(the.env+{{HOMEPAGE_VAR_*}}substitution pattern is intentional — keeps secrets out of a file that's otherwise safe to share as documentation) - Mounting the Docker socket read-write into Homepage (the
:romount is a safety property; Homepage only needs read access to enumerate container status) - Replacing Beszel with Netdata / Glances / Grafana+Prometheus / enterprise monitoring tools (already evaluated — Beszel's RAM footprint, modern UI, and built-in alerting are the deciding factors at this scale)
- Updating the Beszel hub or agent independently (wire protocol can skew between minor versions — pin and update both together)
- Reusing msmtp's Gmail app password for Beszel SMTP (defeats the revocation-independence reason for using a separate one)
- Filesystem-rsync of
/data/docker/beszel/data/data.dbwhile the hub container is running (use SQLite's online.backupAPI or stop the container first — same corruption risk as Navidrome's SQLite) - Splitting Beszel hub and agent into separate compose files on a single-host setup (the unix-socket pattern in one compose is the documented norm and avoids exposing the agent on TCP)
- Replacing Radicale with Nextcloud / Baïkal / AgenDAV / SOGo / InfCloud (already evaluated — files-as-truth storage, ~30 MB RAM, and "fewer moving parts during exam crunch" are the deciding factors)
- Suggesting Nextcloud "for the prettier calendar UI" — was deliberated and the moving-parts trade-off won
- Replacing Vaultwarden with the official Bitwarden self-host image / KeePassXC / Passbolt / Padloc (already evaluated — Vaultwarden's RAM footprint, free "premium" features, and best-in-class iOS integration via the official Bitwarden clients are the deciding factors)
- Looking for a "Vaultwarden client" — there isn't one; the clients are the official Bitwarden desktop app / browser extensions / mobile apps pointed at
https://vault.z2mini.gabrielgabrie.com - Reverting Vaultwarden to run as root (with
CAP_DAC_OVERRIDE/CAP_CHOWNre-added) orchown -R 0:0of itsdata/— running asuser: 1000:1000is the deliberate fix that keepscap_drop: ALLviable and the data human-owned; a root container crash-loops onrsa_key.pemundercap_drop: ALL - Putting Vaultwarden on port 80 inside the container, or binding it to
0.0.0.0/the tailnet IP —ROCKET_PORT=8080+127.0.0.1-only is intentional (lets a non-root capability-less process bind it; Caddy reaches localhost fine vianetwork_mode: host) - Proposing a
/vaultsubpath, self-signed certs + an iOS trust profile, or per-servicetailscale servelisteners for Vaultwarden — all evaluated/superseded; the chosen path is Caddy frontinghttps://vault.z2mini.gabrielgabrie.comon:443with a Let's Encrypt cert (ACME DNS-01 via Cloudflare).DOMAINin.envmust match that URL exactly (it's the WebAuthn/passkey RP-ID). - Filesystem-rsync of
/data/docker/vaultwarden/data/db.sqlite3while the container is running (use SQLite's online.backupAPI or stop the container first — same corruption risk as Navidrome's and Beszel's SQLite); and always back uprsa_key.pemalongside it - Re-running the cloud-Mac route for the iCloud Passwords export (MacinCloud was attempted 2026-05-11; Apple's anti-fraud blocks iCloud sign-in from datacenter IPs — the export needs a trusted physical Mac)
- Suggesting the standalone Bitwarden Authenticator app, or telling Gabriel to keep TOTP in Microsoft Authenticator long-term (the plan is TOTP-in-the-Vaultwarden-vault; MS Authenticator stays only for accounts that mandate the MS app)
- Suggesting Gabriel pre-clean / delete stale entries from his ~400 iCloud Passwords before the bulk import (same one-shot import either way; clean up opportunistically in Bitwarden afterward, and the password-health report is the high-value cleanup)
- Changing Radicale's
127.0.0.1:5232:5232binding to0.0.0.0or the tailnet IP —127.0.0.1-only is intentional; Caddy reaches it there (network_mode: host) and is the only thing exposed on the tailnet - Removing Radicale's Caddyfile block / its HTTPS front-end (iOS 18+ Calendar will refuse to add the account — TLS is forced regardless of the in-app SSL toggle; Caddy supplies the real cert)
- Suggesting self-signed cert + iOS mobile-config trust profile for Radicale's HTTPS, or reverting to
tailscale serve(both superseded — Caddy's Let's Encrypt-via-DNS-01 path is cleaner, no profile install, single ingress) - Re-running the iCloud → Radicale migration with
vdirsyncerto recover alarms (was evaluated and the manual-re-add path was deliberately chosen for the ~10 important recurring events) - Dropping
.icsfiles directly into/data/docker/radicale/data/collections/<user>/<uuid>/(use Thunderbird's Import-into-collection workflow; direct file drops bypass CalDAV's etag/sync-collection bookkeeping and clients can desync) - Filesystem permission changes on Radicale's
config/usersto mode 600 + chown to UID 2999 (mode 644 is intentional — bcrypt hashes are designed to be safe at the filesystem level, and the current perms keepgabrielas the file owner without needing sudo for routine ops) - Disabling iCloud Calendar before the Phase 6 parallel-run window completes (planned ~2026-05-24; recovery from a Radicale problem is much harder once iCloud is gone)
- Adding a DavMail-style Outlook-CalDAV bridge for Conestoga or Laurier (was rejected as fragile under modern auth; Conestoga Exchange + the publish-as-iCal feed cover the use case)
- Re-adding the Laurier Outlook account (Gabriel quit; the account was dropped intentionally)
When in doubt: ask Gabriel before changing existing infrastructure.
This document is intended to be living. If significant infrastructure changes are made, this file should be updated to reflect the new state.