# KHE Homelab Personal family homelab — self-hosted photos, passwords, family apps and home automation on a single machine. Infrastructure as Code with Docker Compose on Proxmox VE. ## Architecture ```mermaid graph TB Internet((Internet)) VPN((Tailscale
VPN)) LAN((LAN
devices)) Internet --> CF[Cloudflare Tunnel] VPN -->|subnet route
192.168.0.0/24| LAN AG[AdGuard Home
split-horizon DNS] -.->|per-host rewrites
→ 192.168.0.11| LAN LAN --> NPM[Nginx Proxy Manager
wildcard *.khe.ee · LAN-only] CF -->|public hostnames
CF Access OTP on
the private apps| DVM NPM --> DVM subgraph DVM[Docker VM · 192.168.0.11] direction LR Core["Core
Homepage · Vaultwarden
Uptime Kuma"] Media["Media
Immich"] Apps["Apps
Landing Page · games hub
pages · trips"] Obs["Observability
Loki · Grafana
Alloy · Alertmanager"] Home["Home
Home Assistant
Mosquitto · PAI"] end DVM --> HDD[(ZFS Mirror · 2× 12TB
NFS /srv)] DVM -.-> NVMe[(NVMe 2TB
OS + DB volumes)] ``` > NPM, AdGuard, and Cloudflare Tunnel also run on the same Docker VM (shown above in the ingress tier, not listed again inside Core). Two independent paths to the same containers: - **External** — Cloudflare Tunnel goes directly to each container. CF Access OTP gates the private apps. Subject to Cloudflare's 100MB upload limit. - **LAN / Tailscale** — AdGuard rewrites the LAN-served hostnames to `192.168.0.11`, so devices hit NPM with the wildcard cert and no upload limit. `draft`, games and pages have no LAN shortcut and always go through Cloudflare; `dash` and `trips` are Access-gated only from outside. Which hostname goes where, and which ones Access gates, is in [infrastructure/cloudflare.md](infrastructure/cloudflare.md). Proxmox VE (192.168.0.10) is the hypervisor; the Docker VM (192.168.0.11) runs every service. Fast storage (NVMe) holds the VM root + DB volumes; bulk storage (ZFS mirror, NFS-mounted at `/srv`) holds photos and backups. ## Hardware | Component | Spec | |-----------|------| | CPU | Intel i7-12700K (12C/20T, Quick Sync iGPU) | | RAM | 32GB DDR5 | | Boot | 2TB Kingston KC3000 NVMe | | Storage | 2x 12TB WD Ultrastar (ZFS Mirror) | | PSU | Seasonic 850W Gold | | Network | Intel 2.5G LAN → UniFi Cloud Gateway Ultra, U6-LR access point | Intel iGPU is passed through to the Docker VM via `vfio-pci` for hardware transcoding — immich-server and Immich machine-learning both use `/dev/dri` for Quick Sync acceleration. ## Services | | Service | Domain | What it does | |---|---------|--------|-------------| | 🌐 | **Landing Page** | `khe.ee` | Public family landing page; shared trips under `/r/`, the trip atlas demo under `/trips/` | | 🏠 | **Homepage** | `dash.khe.ee` | Service dashboard with live widgets (CF Access protected) | | | **Immich** | `photos.khe.ee` | Photo library with ML tagging (Google Photos replacement) | | | **Vaultwarden** | `vault.khe.ee` | Password manager with Passkey support | | | **Uptime Kuma** | `status.khe.ee` | Service monitoring and alerts | | 🎮 | **games hub** | `games.khe.ee` | Launcher, khe-study (`/study/`) and khe-ai-adventure (`/adventure/`), auto-deployed from GitHub | | 🗺️ | **trips** | `trips.khe.ee` | Private family trip atlas, CF Access protected, own GitHub runner | | 📝 | **pages** | `pages.khe.ee` | Quick-publish HTML pages; edited at `draft.khe.ee` (CF Access protected) | | | Home Assistant | `home.khe.ee` (LAN + Tailscale) | House automation: HVAC, grid metering and cameras over local protocols. Deliberately not on the tunnel; the house detail is in the private khe-meta repo | | | Mosquitto | internal only | MQTT broker for Home Assistant and PAI; publishes no host port | | | PAI | internal only | Bridges the Paradox alarm panel to MQTT | | | AdGuard Home | LAN + Tailscale | DNS ad-blocking on the LAN + split-horizon DNS; over Tailscale it answers only the `khe.ee` zone | | | Nginx Proxy Manager | LAN only | Reverse proxy + wildcard SSL for LAN traffic | | | Cloudflare Tunnel | — | Secure external access (no open ports) | | | Grafana + Loki + Alloy + Alertmanager | LAN only | Log aggregation for every container, Telegram alerting via Loki ruler | | | autoheal | — | Restarts any container whose healthcheck turns unhealthy | ## Security & Access Multiple independent layers — nothing on the router is exposed to the internet. **External access — Cloudflare Tunnel** Zero inbound ports. Cloudflare terminates TLS and forwards to containers over an outbound-only tunnel. Private apps (the dashboard, trips, the pages editor) sit behind **Cloudflare Access** with email OTP. **Remote admin — Tailscale VPN** Docker VM runs Tailscale as a **subnet router** (`192.168.0.0/24`), so any Tailscale-connected device gets full LAN access: Proxmox UI, AdGuard, NPM admin, and SSH to the VM. Lets mobile devices bypass the Cloudflare 100MB upload limit — large Immich uploads go directly to NPM over VPN. **LAN access — Wildcard SSL** A `*.khe.ee` Let's Encrypt certificate (Cloudflare DNS-01 challenge, auto-renewing) on NPM means every LAN service gets HTTPS without per-service certs. AdGuard does split-horizon DNS, so `photos.khe.ee` at home resolves to `192.168.0.11` — fast, unlimited uploads, never touches the internet. **Host hardening** - SSH key-only auth on Docker VM (password login disabled) - UFW firewall + fail2ban on the VM (UFW does not filter Docker-published ports; see [SECURITY.md](SECURITY.md#network-exposure)) - Homepage, autoheal and Alloy route Docker access through `docker-socket-proxy` instead of mounting `docker.sock` directly - All secrets in `.env` files on the VM, never committed ## Resilience Six layers, each catching what the others miss: 1. **Kernel hang — hardware watchdog.** The Docker VM runs the `watchdog` daemon pinging `/dev/watchdog` (Proxmox-emulated iTCO, 30s hardware timeout). If the kernel wedges — OOM, I/O lock, driver bug — the hypervisor force-resets the VM and Proxmox boots it back up. Conservative config: only pings the device, no load/memory/network checks (those cause false-positive reboots on blips). 2. **Unhealthy container — autoheal sidecar.** `willfarrell/autoheal` talks to a narrow-scope `docker-socket-proxy` and restarts any container whose Docker healthcheck reports `unhealthy`. Fills the gap left by `restart: unless-stopped`, which only reacts to full crashes. This caught a stuck immich-ml worker the day it was deployed. 3. **Runaway service — Compose resource limits.** Every long-running container has conservative memory and CPU limits in its `docker-compose.yml`; media and database workloads get larger caps than static web services. This keeps one bad process from consuming the whole VM. 4. **Service down — Uptime Kuma + Telegram push.** Kuma watches the services with HTTP/DNS monitors and takes push heartbeats from the backups and the OS update reports; all notify the same Telegram bot (`@khe_homelab_bot`). Alert lands on the owner's phone within ~90s. Kuma DB is in `backup.sh`, so monitor + notification config survives a VM rebuild. 5. **Whole homelab down — external UptimeRobot.** Pings `khe.ee/healthz` every 5 min from outside the home network, pushes to the UptimeRobot iOS app. The only layer that can notify when the VM, Proxmox, or the home internet itself is unreachable (when layers 1–4 can't reach any network). 6. **Whole homelab down, static copy at the edge.** A Cloudflare Worker in front of `khe.ee` and `games.khe.ee` serves an hourly copy of the static sites when the homelab answers 5xx or not at all ([estate ADR-016](https://github.com/khelias/khe-architecture/blob/main/decisions/016-static-fallback-at-the-edge.md)). Visitors outside the home network still get the pages; the APIs and `/healthz` fail as they should, so layer 5 still alerts. ## Automation Operational work is kept to a minimum by pushing everything into code and cron. - **GitOps** — every `docker-compose.yml`, Homepage config and AdGuard config is version-controlled here. Rebuilding any service is `git pull && docker compose up -d`. - **Renovate** — watches every pinned image tag except the estate's own and opens PRs for updates (digests + changelogs). - **GitHub Actions self-hosted runners** — repo-specific runners on the Docker VM deploy `khe-sites` (`khe.ee` and the games launcher), `khe-study` and `khe-trips` into `/srv/data/...` directories served by nginx, and `khe-home-assistant` into Home Assistant's config volume. - **Estate images**: `khe-ai-adventure` is built in GitHub-hosted CI and published to GHCR; this repo pins the digests and the app's CI moves them in an automerged pin PR, so its deploy is a merge here, minutes after the push. - **OS update reminders** — `scripts/os-status.sh` pushes the Proxmox host's and the VM's update state to Uptime Kuma daily, which pages Telegram when a reboot is due or an update has waited 30 days. - **Certificate renewal** — NPM auto-renews the wildcard cert via Cloudflare DNS API. No manual steps. - **Backups** — `scripts/backup.sh` dumps every Postgres DB, the Home Assistant recorder and the service configs on a schedule; `scripts/offsite-backup.sh` then pushes the set to Cloudflare R2 with `restic` (client-side AES-256), which is what makes this 3-2-1. See [infrastructure/offsite-backup.md](infrastructure/offsite-backup.md). ## Network ``` 192.168.0.1 UniFi Cloud Gateway Ultra (gateway, DHCP .100-.254) 192.168.0.10 Proxmox host (pve.khe.ee) 192.168.0.11 Docker VM (+ Tailscale subnet router) 192.168.0.2-99 Static block: infrastructure and the cameras 192.168.0.100+ DHCP, including the house devices HA addresses by IP ``` The house devices HA addresses by IP have fixed IPs (DHCP reservations) in UniFi Network. Router DHCP hands out `192.168.0.11` (AdGuard) as the **only** DNS — no secondary. A fallback server gets raced in parallel and Cloudflare usually wins, so filtering would silently stop applying. Better to fail loudly if AdGuard is down. Tailscale carries only the `khe.ee` zone to remote devices (split-DNS route, no global override), so since 2026-09-12 it coexists with FortiClient and UniFi VPNs on the same laptop. The cost: AdGuard no longer filters general mobile traffic. All external traffic goes through Cloudflare Tunnel — zero ports open on the router. ## Setup ```bash # On Proxmox host ./scripts/proxmox-post-install.sh # 1. Disable enterprise repo, tools, security updates, IOMMU ./scripts/create-zfs-pool.sh # 2. Create ZFS mirror from 2x 12TB HDDs ./scripts/create-docker-vm.sh # 3. Create Debian 13 VM (cloud-init, fully automated) ./scripts/setup-igpu-passthrough.sh # 4. Bind Intel iGPU to vfio-pci, attach it to the VM (reboot after) ./scripts/setup-nfs-share.sh # 5. Export ZFS pool via NFS # Inside Docker VM (ssh khe@192.168.0.11) ./scripts/setup-docker-host.sh # 6. Install Docker + real kernel + firmware (reboot after) ./scripts/mount-nfs-in-vm.sh # 7. Mount NFS shares at /srv, Docker waits for them ./scripts/harden-docker-vm.sh # 8. UFW firewall, fail2ban, SSH hardening ./scripts/setup-tailscale.sh # 9. Install Tailscale as subnet router ./scripts/deploy.sh up # 10. Start the stacks sudo ./scripts/setup-status-timer.sh # 11. 5-min status snapshot into Loki ``` ## Day-to-day ```bash ./scripts/deploy.sh status # What's running? ./scripts/deploy.sh pull # Pull latest images ./scripts/deploy.sh up # (Re)start everything ./scripts/deploy.sh down # Stop everything ./scripts/backup.sh # Backup databases + configs ``` The Home Assistant configuration and its dashboard generator are in [khe-home-assistant](https://github.com/khelias/khe-home-assistant). Health snapshot without touching the VM: a timer writes it every 5 min into Loki (`{job="homelab-status"}` in Grafana Explore), and this refreshes it now, printing only the summary since Actions logs here are public: ```bash gh workflow run ops-status.yml --repo khelias/khe-homelab ``` When something is broken, follow [docs/runbook.md](docs/runbook.md). ## More - [docs/runbook.md](docs/runbook.md) - what to do when something is broken - [docs/operational-notes.md](docs/operational-notes.md) - per-service quirks - [docs/service-choices.md](docs/service-choices.md) - why each service - [infrastructure/](infrastructure/) - Proxmox, network, Cloudflare, Tailscale, offsite backup - [SECURITY.md](SECURITY.md), [ROADMAP.md](ROADMAP.md) - [AGENTS.md](AGENTS.md) - layout and conventions for working on the repo; the Home Assistant procedure is in [khe-home-assistant](https://github.com/khelias/khe-home-assistant/blob/main/AGENTS.md) --- *Built with [Claude Code](https://claude.ai/claude-code). Version tracking by [Renovate](https://github.com/renovatebot/renovate).*