The homelab dashboard: CPU, memory, GPU, NVMe and SATA storage, Wi-Fi and Ethernet panels above the services — Glances, Dockge, Immich with 1,088 photos and 126 videos, Open WebUI with two models, Sunshine and Moonlight Web
The dashboard, served by Homepage. An i5-8300H with 24 GB of RAM and a 2 GB GTX 1050, a 1 TB data drive, and the services that run on it.

WhyWhy a laptop

It was a 2018 gaming laptop that would otherwise have gone in a drawer. It has a GPU, a battery that doubles as a UPS, and idles at almost nothing. The constraint I set was that it should behave like infrastructure, not a hobby box: the same names and certificates everywhere, recovery that's a checklist rather than a weekend, and a log of what broke and why.

DesignHow traffic gets to a service

The domain's DNS points at a private address on my LAN. That is the whole exposure model: off the network, the names resolve to nothing reachable. There is no port forwarding.

Caddy terminates TLS for every service with one wildcard certificate from Let's Encrypt, obtained through the DNS-01 challenge. DNS-01 proves control of the domain by writing a TXT record, so no inbound HTTP is needed and the host never has to be reachable from the internet to get or renew a certificate. Each service gets its own hostname and Caddy proxies it to the container, so nothing has a port number in its URL.

For remote access, Tailscale advertises a /32 subnet route: exactly this host, not the whole home network. A device on the tailnet resolves the same hostnames to the same private address and gets the same certificate, so the experience is identical at home and away. I looked at Headscale and NetBird and passed: both replace the control server rather than the client, which means the same battery cost plus a new public service to maintain.

RunsWhat runs

StackWhatWhy it's here
caddyCaddy, custom buildReverse proxy and wildcard TLS. Built with xcaddy because the prebuilt image drops privileges and can't bind 443.
immichImmich, Postgres, Redis, MLPhoto library, replacing a cloud subscription. Phone backs up over the HTTPS name.
aiOllama, Open WebUILocal LLM inference on the GPU. See the incident below.
homepageHomepageThe dashboard above, with live service health.
dockgeDockgeCompose stack management from a browser.
glancesGlancesHost metrics.
moonlight-webMoonlight Web, with Sunshine on the hostA roommate opens a URL and is driving the living-room TV from a browser. No app, no account.

The OS is immutable: the base system is a read-only image, and a bad update is one rpm-ostree rollback away. All state lives in Compose volumes on the data drive, which is what makes the recovery runbook short.

ScreensWhat it looks like day to day

Glances terminal view during a stream: CPU 24%, two GPUs, eleven containers with moonlight-web at 152% CPU and the rest near zero
Glances during a live stream: moonlight-web at 150% CPU encoding the desktop, the other ten containers idling. Two GPUs listed; the NVIDIA card is reserved for Ollama.
A desktop streamed to a browser through Moonlight Web, with the on-screen controls overlay open
The living-room TV path: the desktop streamed into a browser tab, controls overlay open. No app, no account.
Open WebUI chat interface served from the homelab
Open WebUI, served from the box over the HTTPS name. Local models through Ollama on the GPU, plus a bring-your-own-key cloud model for heavier questions.
A photo of a bowl of ramen open in the Immich photo viewer
Immich, the photo library: 1,100 photos and 126 videos backed up from the phone over the HTTPS name, browsed here from the desktop. Yes, that's lunch.

IncidentsTwo from the log

The full log is in the repo. These two are the ones I'd tell in an interview.

The streaming host that exited cleanly and never came back

symptom
After every reboot the streaming service showed as running, but its port never bound. systemctl status showed zero tasks.
cause
The screen-capture portal wasn't ready when the service started. The process exited cleanly, so Restart=on-failure never fired. Not an encoder problem, which is what I chased first.
fix
Order the unit after the portal and audio stack, then the real fix: a systemd timer that checks whether the port is listening, and restarts the unit if it isn't.
rule
Check outcomes, not process state. A clean exit defeats every restart policy you have.

Port 443 that worked on 8443

symptom
Caddy: listen tcp :443: bind: permission denied. Same config on 8443 was fine. Then, once it bound, browsers timed out on 443 while 8443 still worked.
cause
Two separate causes in a row. The third-party image dropped to an unprivileged user, and the bind capability didn't survive the drop. Then the desktop firewall zone, by default, doesn't open ports below 1024, and 80 and 443 live there.
fix
Build the Caddy image myself from the official builder with the DNS plugin, so it runs as intended and binds 443 natively. Open the http and https services in the firewall.
rule
Timeout means the firewall is dropping packets. Connection refused means nothing is listening. Different symptoms, different causes; read which one you got.

Not builtWhat I deliberately didn't build

  • An AI agent on the host. I built one, measured it for a day, and removed it. It needed constant hand-holding and once reported success on work it never did. A scheduled report doesn't need a model. Written up in Notes.
  • Public exposure. Anything that ever needs to be shared with someone off the network goes through a tunnel with its own authentication design first. The guest streaming setup is not allowed through as-is.
  • VLANs. The router isn't mine. A firewall port inventory is the interim control; segmentation waits for hardware I control.