Most homelab guides tell you to forward ports 80 and 443 on your router and put your home IP in DNS. This one opens zero inbound ports. The whole fleet — photos, passwords, CI, dev environments, this site — sits behind a single outbound tunnel that terminates on the router itself. No port to forward, no public IP to leak.
What’s running#
Two Proxmox VE nodes, four VMs, and a MikroTik router at the edge.
Node A — AMD Ryzen 7 5700x, 48 GB RAM, 1 TB storage. The agentic stack — the one box that earns the bigger CPU and RAM.
- Work VM — Coder (browser-based dev environments), Multica (a self-hosted managed-agents platform — the layer above coding agents like Claude Code and Codex that turns them into assignable teammates with issues, progress streams, and reusable skills), Memory/Hindsight (persistent memory for those agents).
- Embedded LLM —
llama.cpprunning local inference on the same metal, feeding the agent stack.
Node B — Intel i3-1215U mini PC, 32 GB RAM, 1.5 TB storage.
- Personal VM — Immich (photos), Vaultwarden (passwords).
- Apps VM — Drone CI, this Hugo site.
- Gateway VM — Traefik v3 plus the observability stack: Prometheus, Loki, Grafana, Alertmanager.
Edge — a MikroTik L009UiGS router running cloudflared as a RouterOS container. That replaces what used to be a dedicated tunnel VM; the entire inbound path now ends on the router.
The topology#
flowchart TB
USER[Visitor browser] -->|HTTPS| CF[Cloudflare edge
TLS terminate · DDoS · CDN]
subgraph ROUTER["MikroTik L009UiGS router"]
CT[cloudflared container
armv7 · --protocol http2]
end
CF -.->|outbound-only tunnel| CT
subgraph NODEB["Proxmox Node B · i3-1215U · 32 GB"]
NBH((" ")):::ghost
GW[gateway VM · Traefik v3 :80
Prometheus · Loki · Grafana · Alertmanager]
PER[personal VM · Immich · Vaultwarden]
APPS[apps VM · Drone CI · Hugo site]
end
subgraph NODEA["Proxmox Node A · Ryzen 7 5700x · 48 GB"]
NAH((" ")):::ghost
BIZ[work VM · Coder · Multica · Memory]
LLM[embedded LLM · llama.cpp]
end
CT -->|HTTP · firewall-locked to :80| GW
GW --> BIZ
GW --> PER
GW --> APPS
NBH ~~~ GW
NBH ~~~ PER
NBH ~~~ APPS
NAH ~~~ BIZ
NAH ~~~ LLM
TS[Tailscale admin mesh] -.->|SSH · management| NODEA
TS -.-> NODEB
TS -.-> ROUTER
classDef ghost fill:none,stroke:none,color:transparent
Each hop earns its place. Cloudflare hides the origin IP and absorbs L7 noise. The tunnel is outbound-only, so nothing on the LAN listens to the internet. The router-local cloudflared keeps encrypted egress as close to the wire as possible. Traefik on the gateway VM is the single place routing and middleware policy live. The dotted Tailscale overlay is the management plane — separate from the request path; more on it below.
Why the tunnel lives on the router#
This used to sit on its own VM — a dedicated, minimal box that did nothing but run cloudflared. That worked, but it also meant a whole VM (disk, RAM, an OS to patch, an Alloy agent to keep alive) for a process that does almost nothing. The MikroTik L009UiGS runs RouterOS containers, so the tunnel moved there.
Two real problems had to be solved first. The official cloudflare/cloudflared image dropped armv7 builds, and the L009UiGS is armv7 — so I build a custom image from the official binary and push it to Docker Hub. Then, cloudflared defaults to QUIC on outbound, which needs UDP/7844 open through the router firewall — fiddly on RouterOS. Forcing --protocol http2 collapses it to a single outbound HTTPS TCP connection, so there’s no outbound firewall rule to maintain.
The container sits on the router’s own container network, firewalled to reach only the gateway VM’s port 80 — nothing else on the LAN. If it were ever compromised, the blast radius is one TCP port on one VM. The honest trade-off: the router is now a critical piece of infrastructure, not a box you forget about. If it reboots, the tunnel drops with it. I’ve accepted that to remove a whole VM.
Two ISPs, one tunnel#
The router has two WAN ports and two ISPs behind them. That’s the resilience layer under everything above. The Cloudflare Tunnel is an outbound connection, so it rides whichever WAN is alive — kill one ISP and the tunnel, and every service behind it, stays up on the other.
Splitting traffic across both is standard MikroTik dual-WAN. New connections are marked by a per-connection-classifier (PCC) and balanced across the two links; each ISP owns its own routing table with a recursive default route that pings a resolver to confirm the path actually carries traffic. If one ISP’s resolver stops answering, its routes withdraw and that table fails over to the other — symmetric both ways. A netwatch job pinging each link is the second opinion, catching a path that’s “up” at the interface but dead on the internet.
I’m not publishing the classifier ratios, the resolver targets, or the failover distances — they’re tuned to these two links. The shape is the point: load-balance on the happy path, automatic failover on the unhappy one, and a tunnel that doesn’t know or care which ISP it’s riding.
Why Traefik and not Nginx Proxy Manager#
Nginx Proxy Manager gives you a UI for one-off proxies; Traefik gives you a system. With Traefik, a Docker container with the right labels is routable the moment it starts — no UI to click, no file to regenerate, no reload to forget. It watches the Docker socket through the docker provider and a directory of YAML through the file provider; both reload dynamically. One language — routers, middlewares, services — describes everything. If you only ever run one service, Nginx Proxy Manager is fine. At five services, Traefik wins.
Why Traefik is HTTP-only#
Traefik listens on :80 and only :80. Cloudflare terminates TLS at the edge; Traefik never holds a certificate and never has to renew one. That’s one less moving part and one less way for a service to break at 3 a.m. because a cert expired.
# traefik/compose.yml (excerpt — sanitized)
command:
- --entrypoints.web.address=:80
- --entrypoints.web.forwardedHeaders.trustedIPs=<router-tunnel-ip>/32,<cloudflare-edge-ranges>
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=proxy
- --providers.file.directory=/dynamic
- --providers.file.watch=true
- --accesslog=true
- --accesslog.format=json
- --metrics.prometheus=true
The trade-off is real. By terminating TLS at Cloudflare you trust Cloudflare’s edge as your TLS boundary; the hop from edge to origin runs over the tunnel, itself an encrypted connection back to Cloudflare. Fine in practice — but a trust decision you’re making. If you don’t want that trust, run your own certs on Traefik and drop the tunnel.
Request flow#
sequenceDiagram
participant B as Browser
participant CF as Cloudflare edge
participant T as cloudflared (router container)
participant TR as Traefik (gateway VM)
participant BE as Backend VM
B->>CF: HTTPS request
CF->>CF: TLS terminate · DDoS filter
CF->>T: forward over tunnel (CF-Connecting-IP set)
T->>TR: HTTP :80 · Host header preserved
TR->>TR: match router · apply middleware tier
TR->>BE: forward to container port
BE-->>TR: response
TR-->>T: response
T-->>CF: response
CF-->>B: HTTPS response
The browser speaks HTTPS to Cloudflare’s edge. Cloudflare terminates TLS, applies DDoS and CDN logic, and forwards the request — with CF-Connecting-IP set — over the outbound tunnel. The router’s cloudflared hands it to Traefik on port 80, HTTP-only. Traefik reads the Host header, matches the router, applies the middleware tier, and forwards to the backend container on whichever VM it lives on.
Observability — and why Alloy#
Prometheus stores metrics, Loki stores logs, Grafana draws both, Alertmanager pages when something breaks. The interesting piece is the agent.
For a long time the standard answer was Promtail for logs and node_exporter for host metrics — two agents per machine, two configs, two failure modes. Promtail reached end-of-life in March 2026. Alloy is the Grafana-agent successor and does both jobs in one process: host metrics through prometheus.exporter.unix, Docker containers through discovery.docker, logs to Loki through its loki.source.* components. One agent, one config, one thing to debug. Every VM runs the same Alloy config; the only per-host variable is the hostname label.
flowchart LR
subgraph EACH["each VM in the fleet"]
AL[Alloy agent
prometheus.exporter.unix
discovery.docker
loki.source.*]
end
subgraph GW["gateway VM (also runs Alloy)"]
PROM[Prometheus]
LOKI[Loki]
GRAF[Grafana]
AM[Alertmanager]
end
AL -->|metrics · remote_write| PROM
AL -->|Docker stdout · journal| LOKI
AL -->|Traefik access log · gateway only| LOKI
PROM --> GRAF
LOKI --> GRAF
GRAF --> AM
Once logs from every VM land in one place with consistent labels, debugging changes shape. Three real LogQL queries I run weekly:
{container="<app-name>"} |~ "panic|fatal"
{job="traefik-access"} |~ "5.."
{job="docker", host="<vm>"} |= "error"
The first catches a crash in any named container. The second surfaces every 5xx in Traefik’s access log — with the real client IP, because we recover it below. The third pulls every error line from a given VM’s Docker containers. Try that with docker logs over SSH on five machines.
Rate-limit and security-header tiers#
Every route chains two middlewares: a security-headers tier and a rate-limit tier. I define a handful of named tiers and assign each route one, so the policy is declarative — a route says “auth” or “media” and inherits a coherent posture instead of per-route knob-twiddling. They range from a strict tier for anything that handles credentials, through balanced defaults, to a deliberately open one for ephemeral dev environments that shouldn’t be constrained.
The honest story behind why tiers exist at all: a service was inexplicably slow once, and for a week I assumed the backend was at fault. It wasn’t. The default tier was rate-limiting it, and a bursty client blew through the cap the moment the page loaded. Moving that one route to a higher tier fixed it in a single edit. Lesson, plainly: when a service is slow and the code has no culprit, check whether your reverse proxy is throttling it before you touch the app. I wasted a week learning that.
I’m not publishing the exact values or which route is on which tier. The tiers exist; that’s the architectural point.
Recovering the real visitor IP#
Behind a Cloudflare Tunnel, every request reaching Traefik has the router’s tunnel interface as its source. Cloudflare fixes this by adding CF-Connecting-IP, but Traefik has to be told to trust it. That’s --entrypoints.web.forwardedHeaders.trustedIPs: it whitelists the router’s tunnel interface and Cloudflare’s edge ranges, so Traefik reads CF-Connecting-IP as the client IP in access logs.
Gotcha: if you forget the tunnel interface in trustedIPs, every access log shows the router and per-client rate limits become useless. One-line mistake, and it’s silent. Verify after every Traefik restart — the IP in the access log should be the remote client, not the router.
The management plane — Tailscale#
Everything above is the public path: browser → Cloudflare → tunnel → Traefik → service. None of it gives me a shell on a box. For SSH and management I use a separate Tailscale mesh across the two nodes and the VMs — a parallel admin plane with nothing to do with the request path.
This is what keeps SSH off the public internet without me ever opening port 22. Nodes join the tailnet and reach each other directly over WireGuard. I SSH into any VM through its tailnet address, never through Cloudflare, never through port-forwarding. The two networks are deliberately separate: a compromise of the public path doesn’t expose SSH, and a compromise of the tailnet doesn’t expose the public services.
Tailscale also runs as a container on the router itself, advertising the LAN subnet into the tailnet — so the whole LAN is reachable from one router-side node, and the router’s own management interfaces are never exposed to the WAN. I manage the router over the tailnet too; SSH, Telnet, FTP, and the web UI are all WAN-disabled, reachable only from the LAN and the tailnet.
Adding a service#
Two paths. Add traefik.* labels to the service’s own docker-compose.yml (preferred when the service is Docker-based and the compose lives in its own repo), or add a 4-line route entry in traefik/dynamic/routes.yml (preferred when the backend is external to Docker or you want routing centralized). Deploy the service, allow the gateway VM’s IP through UFW on the host, and the per-VM Alloy agent picks up the new container automatically through Docker discovery — no Prometheus restart. Tunnel and DNS need no changes; the wildcard already covers the new subdomain.
Locking the LAN down#
Every app VM sits behind UFW, and only the gateway VM is allowed to reach app ports. The internet-facing surface is exactly the router, which only runs cloudflared outbound. Sanitized example:
sudo ufw allow from <gateway-ip> to any port <service-port>
That opens the listed ports to the gateway and nothing else. The gateway itself opens 80 only to the router’s tunnel interface. The monitoring ports are LAN-bound; for remote access I go through Tailscale. Nothing exposes Grafana to the public internet, ever.
What it actually costs#
No invented savings table. Hardware I already own (the two nodes plus the router), a domain per year, the Cloudflare free tier (tunnel and DNS cost nothing at my traffic), and electricity. The software stack — Traefik, Prometheus, Loki, Grafana, Alloy, cloudflared, Immich, Vaultwarden, Coder — is open source and costs zero. If you don’t already have hardware, that’s the one real upfront line item. Everything after that is your time.
FAQ#
Is Cloudflare Tunnel safe for a homelab?#
It’s outbound-only — your machine initiates a long-lived connection to Cloudflare’s edge; no inbound port has to be opened. Your home IP never appears in public DNS for the tunneled hostnames. The trust shift is real: instead of trusting your own edge to terminate TLS and resist direct attacks, you trust Cloudflare’s. For most homelabbers that’s a strict improvement.
Why no TLS on Traefik?#
Cloudflare terminates TLS at its edge, and the hop from edge to origin runs over the tunnel — itself an encrypted connection back to Cloudflare. Traefik sits LAN-side and only sees unencrypted HTTP from a source it already trusts. Adding TLS on Traefik would mean managing cert renewals for no security gain in my threat model. If I dropped the tunnel, I’d put TLS on Traefik the same day.
Alloy vs Promtail?#
Promtail reached end-of-life in March 2026. Alloy is the Grafana-agent successor: it does everything Promtail did for logs and replaces node_exporter for host metrics — one agent per VM instead of two. The migration is mostly mechanical; loki.source.* components in Alloy map cleanly onto Promtail’s scrape configs.
Can I run this on a single box?#
Yes. Collapse Traefik, monitoring, and apps onto one machine and you lose blast-radius isolation, but you also lose three VMs of overhead. For a personal setup with a handful of services, one box is a legitimate starting point. What you give up is the ability to break one thing without touching the others — and the option to put the tunnel on the router, which only makes sense once the router is separate from the workloads.
Want this behind your product?#
This is the same infrastructure that ships and monitors the apps I build for clients. The pipeline that builds and deploys your app, the reverse proxy that routes its traffic, and the observability stack that catches the regression before your users do — I bring all three. If you want that behind your product rather than a black box you can’t see into, look at what I offer and let’s talk.

