bash — christopher@homelab

christopher@homelab:~$ whoami

christopher. I build and run a homelab at home

christopher@homelab:~$ cat mission.txt

christopher@homelab:~$ _

bare metal, always on 20+ services in Docker DNS · VPN · monitoring
services … cpu … ram … disk …

# architecture

How traffic reaches the portfolio, the live terminal, and the Fabric SMP game server.

Visitors browser Cloudflare Pages static portfolio christopher-lab.pages.dev afraid.org URL forward → Pages CF Quick Tunnel WebSocket proxy terminal-gateway Homelab Caddy reverse proxy · TLS Docker services 20+ containers Tailscale mesh VPN AdGuard + Unbound recursive DNS terminal-gateway read-only shell Monitoring stack Kuma · Beszel · Dozzle · Speedtest · ntfy Apps · Nextcloud · Paperless · Wazuh files · SIEM · CrowdSec · Restic Java players Minecraft client Bedrock players phone · console TCPShield DDoS proxy apex hides home IP playit.gg Java TCP relay SRV → tunnel playit.gg Bedrock UDP relay no TCPShield Fabric SMP Fabric server Java · systemd · :25565 Geyser Bedrock bridge · :19132 Cloudflare DNS dobasmp.net apex → TCPShield · SRV → playit Live status on portfolio Uptime Kuma GameDig · player count · no public join info portfolio game server Java UDP HTTPS WSS TCP status.json LAN poll
static site path live terminal path TCPShield · Java Fabric SMP · playit homelab services

# how this site works

A quick case study on shipping a public portfolio when your ISP won't let inbound ports through.

Problem

I wanted a public portfolio that shows off the homelab (live status, a read-only terminal, the whole thing), but my ISP blocks inbound ports. I can't just point a domain at my house and call it a day.

Approach

I put the static site on Cloudflare Pages (free, fast, no server to babysit). My afraid.org subdomain forwards to Pages so I get a clean URL. For the live terminal, a Cloudflare quick tunnel on the homelab exposes a WebSocket to a read-only gateway with sensitive paths blocked and output redacted. Status syncs from Uptime Kuma on a cron job.

Outcome

Public site with zero open inbound ports. Visitors get HTTPS, live monitoring, and a sandboxed shell peek at the stack. Deploys are a script on the homelab that pushes to Pages. When the box reboots, Docker brings everything back in about two minutes.

# the stack

Docker on the OptiPlex 5040, with Fabric SMP on the same host. Caddy terminates TLS for LAN services under dobasmp.net.

AdGuard Home + Unbound

I use AdGuard Home with Unbound for network-wide blocking and recursive DNS. Queries go to the root servers, not some upstream resolver.

DNSPrivacyUnbound

Encrypted DNS

DoH, DoT, and DoQ endpoints so devices can resolve DNS privately. Works off-network too when I'm on the VPN.

DoHDoTDoQ

Tailscale

Tailscale mesh VPN with an exit node and MagicDNS. I can reach everything at home from anywhere without opening ports.

WireGuardExit nodeMagicDNS

SearXNG

Self-hosted SearXNG for search. No tracking, no profile building, just aggregated results.

SearchPrivacy

Caddy

Caddy as reverse proxy and TLS. Let's Encrypt certs via DNS-01, so every service gets a clean HTTPS URL.

Reverse proxyLet's Encrypt

Monitoring

Uptime Kuma for probes, Beszel for host metrics, Dozzle for container logs, CrowdSec for automated IP bans from Caddy and SSH logs, Speedtest Tracker for ISP history, and ntfy for push alerts.

Uptime KumaBeszelDozzlentfy

Homepage

LAN startpage for every stack — AdGuard, monitoring, Nextcloud, Wazuh, Paperless, CrowdSec logs, Minecraft map, and the rest — in one dense grid.

DashboardLAN

Paperless-ngx

Document OCR and archive. Cloudflare Email Routing drops mail into Gmail, then Paperless IMAP consumes and deletes after ingest.

OCRIMAPArchive

Wazuh

Single-node SIEM (indexer + manager + dashboard) with host agent and ntfy alerts for high-severity events. Capped so it shares the box with Minecraft.

SIEMAgentntfy

Nextcloud

Personal file sync and sharing — photos, documents, calendars — under cloud.dobasmp.net. Data lives on the homelab (not a public cloud). Desktop and phone apps talk to your box over HTTPS.

FilesSyncPrivate

CrowdSec

Collaborative IDS: reads Caddy access logs and SSH auth, detects scanners and brute-force, and the host firewall bouncer drops bad IPs automatically. No web UI — check decisions in Dozzle or with cscli on the box.

IDSFirewallSSHCaddy

Restic backups

Nightly encrypted snapshots to the USB stick: stack configs, AdGuard, cron, plus PostgreSQL dumps for Nextcloud. Retention: 7 daily, 4 weekly, 2 monthly. This is disaster recovery for configs and DBs — not a full mirror of every file you upload.

ResticUSBCron

ntfy

Self-hosted push notifications for Uptime Kuma and Wazuh so phone alerts stay on my stack, not a third-party SaaS.

AlertsPush

Fabric SMP

A modded Fabric server with Geyser for Bedrock cross-play. Java players hit TCPShield first so the home IP stays out of DNS, then playit tunnels TCP to the box. Bedrock goes through playit only. Uptime Kuma and this site show online status and player count — no join info posted here.

FabricGeyserTCPShieldplayit

# explore the stack

Read-only shell into /opt/stacks over a Cloudflare Tunnel. You can't edit anything. Sensitive paths are blocked and output gets redacted.

ssh — christopher@homelab (read-only) waiting…

# live status

Pulled live from Uptime Kuma and the homelab host. Updates every minute.

checking…
Connecting to monitoring…

# reliability

What happens when the box reboots, how status stays fresh, and what I actually monitor.

live metrics

  • Host uptime
  • Last deploy
  • Last incident
  • Status synced

runbook

  • On reboot: systemd starts Docker; autostart brings up DNS, proxy, CrowdSec, Nextcloud, and the rest. USB mounts via fstab for Restic. Caddy picks up certs; tunnels reconnect.
  • Status sync: fetch_status.py runs every minute via cron. It pulls Uptime Kuma heartbeats, host CPU/RAM/disk, deploy timestamp, and incident log into status.json, then deploys if the hash changed.
  • Monitoring: Uptime Kuma watches HTTP endpoints and TCP ports. Beszel tracks CPU/RAM/disk. Dozzle for container logs. I get alerts when something stays down.

backups

  • Restic (nightly): encrypted repo on the USB stick at /mnt/backup-usb/restic — compose files, Caddyfile, AdGuard config, cron, and PostgreSQL dumps for Nextcloud. Password in /opt/stacks/.restic.pass (root only).
  • Configs: same paths are in the Restic job; I also keep timestamped .bak files before edits.
  • AdGuard: periodic export of DNS rewrites and settings so I can rebuild filtering after a wipe.
  • Recovery test: full stack has come back from reboot in ~2 minutes. I treat that as the baseline DR drill.

# security model

How I limit blast radius on a box that is partly public-facing.

No inbound ports

ISP blocks inbound anyway. Public site on Cloudflare Pages. Homelab services reach the internet only through outbound tunnels and Tailscale.

Edge accessZero open ports

Read-only terminal

Path jail to /opt/stacks only. Blocked dotfiles, keys, env files, and databases. Output redacted. Commands audit-logged.

Least privilegeRedaction

Container hardening

Terminal gateway runs read-only rootfs, non-root user, cap_drop: ALL, and no-new-privileges. Secrets stay outside deploy paths.

Dockercap_drop

TLS everywhere

Caddy terminates HTTPS with Let's Encrypt via DNS-01. HSTS on proxied services. Encrypted DNS (DoH/DoT/DoQ) for clients that support it.

DNS-01DoH

# the hardware

One recycled desktop today. Three phases to a fully local, VLAN-segmented homelab with zero cloud AI bills.

running now

  • HostDell OptiPlex 5040
  • CPUi7-6700 @ 3.4 GHz
  • Memory32 GB DDR3
  • Storage476 GB SATA SSD
  • OSUbuntu · Docker
  • NetworkTailscale · playit.gg

phase 1 · consolidate

  • Compute5040 · NVMe upgrade next
  • OSProxmox or Ubuntu + Docker
  • StackAdGuard · Caddy · Tailscale
  • AIQwen 2.5 Coder 7B (Ollama)

phase 2 · cameras

  • GPURTX 3050/4060 LP
  • CamerasReolink PoE + Frigate
  • NetworkTP-Link PoE+ · VLANs
  • AIQwen 2.5 Coder 14B (VRAM)

phase 3 · powerhouse

  • ComputeATX · 64 GB DDR5
  • Rack19" + 1500VA UPS
  • GPU2× RTX 3090 · 48 GB
  • AIQwen 32B / DeepSeek-R1

Goal: VLAN segmentation + zero-utility-cost local AI coding (Aider / Continue.dev in VS Code)

# about

I run a homelab at home: DNS, VPN, reverse proxy, monitoring, and Docker. I built this portfolio site and the automation around it myself.

Building toward systems administration. The lab is where I practice what I do at work: monitoring, DNS, Linux, documentation, and recovery.