Home Server Reference

Pi4 Patch Panel

Model
Pi 4B rev 1.1
OS
Debian 13 trixie
Ethernet
192.168.4.157
Wi-Fi (backup)
192.168.4.156

System Vitals

connecting…
Temp
Throttling
CPU
Memory
Disk
Uptime
load avg — only reachable from the LAN or Tailscale, same as AdGuard/NetAlertX above
AdGuard Home :80 · :53
on lan192.168.4.157
tailscalepi4.tail907ff3.ts.net
dns192.168.4.157:53
  • Login — user shash, password on file (native install, not Docker)
  • Upstream — Quad9 + Cloudflare + Google, parallel-race mode
  • Filters — AdGuard DNS + AdAway, Safe Browsing on
  • Runs as a systemd service at /opt/AdGuardHome
  • adguard.home.ssswebd.com only resolves for devices using AdGuard itself as DNS (LAN, or a Tailscale device with the subnet route) — no public record exists
NetAlertX :20211
on lan192.168.4.157:20211
tailscalepi4.tail907ff3.ts.net:20211
api / mcp127.0.0.1:20212
  • Login — password only, no username; password on file
  • API token — pinned in app.conf, won't rotate on restart
  • Discovery — via UniFi API only, local scanners disabled on purpose
  • Alerts — new devices only, via Telegram @shash_home_alerts_bot
  • Docker: /opt/netalertx/docker-compose.yml
UniFi Gateway Cloud GW Ultra
  • Self-signed cert — browser warning on first visit is expected
  • API key — stored in NetAlertX's app.conf as UNIFIAPI_api_key
  • 3 wired LAN networks, see the VLAN map below
  • ISP — Wingo (since Sep 2026), WAN 192.168.9.1, ISP router 192.168.9.254, double-NAT, zero port forwards
Synology NAS DS218+
on lan192.168.4.36
dsm direct192.168.4.36:5000
smb\\192.168.4.36
  • IP is pinned — DHCP reservation on the UniFi side, won't drift
  • HTTPS via nginx on the Pi — real cert, so no DSM certificate warning
  • Also runs Jellyfin (own card below) and Synology Drive (:6690)
  • Reachable over Tailscale — it's inside the advertised 192.168.4.0/24
  • qBittorrent is installed here; DHT/PeX/LSD turned off Sep 2026 after it tripped UniFi's P2P IDS rules
Jellyfin docker on NAS
direct192.168.4.36:8096
containerjellyfin-jellyfin-1
  • Runs in Docker Compose on the NAS, not on the Pi
  • HTTPS via nginx on the Pi — Jellyfin itself is HTTP-only (:8920 closed), so without this, logins crossed the network in clear text
  • Proxy is tuned for streaming — WebSockets on, buffering off, long timeouts
  • Set Networking → Known proxies to 192.168.4.157 in Jellyfin, or every client logs as coming from the Pi
Tailscale subnet router
pi4100.78.9.108
tailnettail907ff3
  • Advertises 192.168.4.0/24 to the tailnet
  • Remote reach limited to 3 devices — phone, work laptop, stream-yt-setic
  • Everyone else on the tailnet can reach pi4 itself, not the LAN behind it

Network map

NetworkRangeVLANPurpose
Default 192.168.0.0/24 General household devices, DNS via WAN chain
restricted network 192.168.3.0/24 2 App-category blocking + scheduled cutoffs, kid-safe devices
all unblocked 192.168.4.0/24 3 Pi4 lives here, along with IoT and main devices
MCP config
~/Downloads/.mcp.json
NetAlertX config
/opt/netalertx/config/app.conf
DNS fallback chain
eth → wifi → 1.1.1.1
Health check
Ask Claude for "the usual check"

Runbooks

Adding a Docker container that survives updates written after Jellyfin lost everything, Sep 2026
If losing it would annoy you, bind-mount it. An anonymous volume is what you get by default when nothing overrides the image — and it's the one mount type that silently evaporates when a container is recreated.
The three mount types
TypeLooks likeSurvives recreate?
Anonymous ab9b549818bb…:/config No — tied to the container instance
Named myapp_config:/config Yes, but — opaque, awkward to back up
Bind /volume1/docker/myapp/config:/config Yes — a real folder you can rsync
Layout — definition and data together, one dir per service
/volume1/docker/<service>/
├── docker-compose.yml   # the definition
├── .env                 # secrets only, chmod 600
├── config/              # bind-mounted
└── data/                # bind-mounted
Template
services:
  myapp:
    image: vendor/myapp:latest
    container_name: myapp
    restart: always

    volumes:
      - /volume1/docker/myapp/config:/config   # anything stateful
      - /volume1/docker/myapp/data:/data
      - /volume1/media:/media:ro                # read-only where possible

    environment:
      - TZ=Europe/Zurich
      - APP_PASSWORD=${APP_PASSWORD}            # from .env, never inline
Traps worth knowing
  • Don't mix Container Manager and CLI compose on the same container. DSM keeps its own definition and silently reasserts it — a compose up can look like it worked while only restarting the old container. Pick one owner.
  • If a container already exists in Container Manager, delete it there first, then docker rm it too — CM releases its record but leaves the container behind, and compose then fails on the name conflict.
  • Never docker compose down -v — the -v deletes volumes. The other common way to lose everything.
  • Secrets belong in .env at chmod 600, never inline in the compose file, and never reused across services.
Updating
cd /volume1/docker/<service>
sudo docker compose pull && sudo docker compose up -d
Verifying — don't trust, check
A matching server/instance ID is not proof. Once config has been copied into a bind mount, both locations hold the same ID — so it tells you nothing. Check for new writes instead.
# 1. is it actually a bind mount?
sudo docker inspect <name> --format '{{json .Mounts}}' | grep -o '"Destination":"/config"[^}]*'
#    want "Type":"bind"

# 2. does the bind-mounted dir receive fresh writes after a restart?
ls -la /volume1/docker/<service>/config/log/

# 3. the real test — recreate, then confirm nothing reset
sudo docker compose pull && sudo docker compose up -d