Part 8 of the series: Building a Self-Hosted AI Development Platform
5 min read

Building a Self-Hosted AI Development Platform — Part 8: Publishing Services Safely with Cloudflare Tunnel and Caddy

Part 8 of the Forge series: exposing a homelab without opening a single router port — the tunnel-plus-Caddy pattern, Cloudflare Access as the front door, and the DNS toggle that took everything offline

Part 8: Publishing Services Safely with Cloudflare Tunnel and Caddy

Everything in this series so far happens safely inside a home network. But a Git server you can only push to from the sofa is half a Git server, and a dashboard you can’t glance at from elsewhere barely counts as monitoring. This post covers how Forge’s services became reachable from anywhere — without opening a single router port, publishing a home IP address, or leaving a login page naked on the internet. It’s the layer previous posts kept promising, and it ends with the outage that taught me what tunnel DNS records actually are.

The Options, and Why Two of Them Lost

Port forwarding plus dynamic DNS is the traditional homelab answer, and it fails the brief three ways. Your home IP becomes public knowledge, permanently linked to your domain in DNS history. Your router — the least-updated device you own — becomes security-critical. And every forwarded service faces the internet’s background radiation of scanners directly, protected only by its own login page. Some of those login pages are excellent. Betting the network on all of them being excellent is not a bet.

VPN-only access (Tailscale, in this platform’s case) is the opposite extreme: nothing exposed, everything reachable — but only from enrolled devices. For admin surfaces that’s exactly right, and Tailscale is precisely how Proxmox and Portainer are reached. But it can’t give a service a real URL that works from a borrowed browser, and it can’t ever share anything with another human.

Cloudflare Tunnel inverts the whole model. A small daemon inside the network makes an outbound connection to Cloudflare’s edge; public hostnames route down that tunnel. Nothing listens on the home connection at all — there’s no port to scan, and DNS resolves to Cloudflare, not to my house. The entire ingress, as a Compose file:

services:
  cloudflared:
    image: cloudflare/cloudflared:latest
    container_name: cloudflared
    restart: unless-stopped
    command: tunnel --no-autoupdate run --token ${CLOUDFLARED_TUNNEL_TOKEN}
    networks:
      - platform

One container, one secret, outbound-only. The token lives in an untracked .env file — it is the tunnel’s identity, and anyone holding it can route your hostnames.

One Wildcard, One Router: Caddy

The tunnel could route each hostname individually from Cloudflare’s dashboard, but that splits routing truth across a web UI and the repo. Instead, a single wildcard sends all of *.example.com down the tunnel to one place: Caddy, listening on the platform network. From there, routing is plain text in Git:

http://example.com {
	reverse_proxy homarr:7575
}

http://git.example.com {
	reverse_proxy forgejo:3000
}

http://grafana.example.com {
	reverse_proxy grafana:3000
}

Adding a public service is a two-line diff. Container names, not IPs — the payoff of Part 4’s shared-network decision, one more time.

Two non-obvious details in those blocks. The http:// prefix is load-bearing: TLS terminates at Cloudflare’s edge, and the tunnel delivers plain HTTP to the origin. Without the explicit scheme, Caddy does the “right” thing — provisioning certificates and redirecting to HTTPS — and produces a redirect loop against a tunnel that only speaks HTTP to it. Declaring the sites HTTP-only tells Caddy the truth about its job.

The second detail is what happens to hostnames that don’t match. A wildcard DNS record means every conceivable subdomain resolves and reaches Caddy — including ones you never configured. Caddy’s default for an unmatched HTTP host can be a contentless 200, which quietly tells scanners “something lives here.” The live config closes that door explicitly:

http://*.example.com {
	handle {
		respond "Not found" 404
	}
}

An explicit 404 catch-all, and the same hygiene for retired services — when a service is removed (Part 6’s departures included), its hostname gets an explicit respond 404 block rather than being deleted into ambiguity. A wildcard makes publishing the default; the config’s job is to make it a decision.

HTTPS Is Not Authorization

Here’s the trap in every “expose your homelab with a tunnel” tutorial: at this point the services are encrypted, hidden-origin — and still open to the public internet. HTTPS protects traffic in transit. It has no opinion about who’s allowed in. Every public hostname is still a login page taking guesses from anyone.

The platform’s answer is Cloudflare Access: an identity check at Cloudflare’s edge, before a request ever reaches the tunnel. Each public hostname gets an Access application — allow-list of my email, one-time PIN or identity provider login, session lasting days. The request never touches my network until identity is proven. Services effectively get enterprise SSO bolted on in front, including services that have no idea it’s happening.

The resulting access model has three tiers, worked out service by service across this series:

  • Never public: Proxmox, Portainer, PostgreSQL, Prometheus. No hostname exists; reachable over Tailscale only. A control plane behind any login page is still a control plane one vulnerability from disaster.
  • Public hostname, Access in front: the dashboard, Grafana, n8n, Forgejo’s web UI. Useful away from home, nobody else’s business.
  • Public, by explicit decision: nothing, currently — but the tier exists, and a rule guards it: no new Caddy route until the matching Access application exists and has been tested. Ordering matters; the reverse order is an open service with authentication planned.

One genuine trade-off, honestly noted: Access’s browser-login flow breaks non-browser clients. Git-over-HTTPS through an Access-protected hostname fails — a machine can’t complete an interactive PIN flow. Which forces the question of how git push actually reaches the server, and thereby this post’s disaster.

What Broke: The Grey-Cloud Outage

Git SSH seemed simple: Forgejo listens on port 2222, so ssh git.example.com:2222… times out. From everywhere. Firewall? Tunnel config? No — something more fundamental, which I understood only after making it much worse.

The reasonable-sounding fix: in Cloudflare DNS, flip the record from Proxied (orange cloud) to DNS-only (grey cloud), so SSH bypasses Cloudflare’s proxy and connects “directly.” This took every public service offline at once.

The mental model error: a tunnel hostname is a CNAME to cfargotunnel.com. It isn’t an address plus optional proxying — the proxy is the route. Cloudflare’s edge accepts the request and forwards it down the tunnel; grey-clouding the record just publishes a CNAME to infrastructure that won’t answer unproxied requests. There is no “direct” to fall back to — no public IP exists behind these hostnames, which was the entire point of choosing tunnels in Part 1. And the edge only carries what tunnel routes expose, which for HTTP hostnames doesn’t include port 2222 — hence the original timeout.

Both symptoms, one lesson, now written in the decisions file as a standing rule: tunnel-type DNS records stay Proxied, always. With tunnel-only origins, connectivity problems are solved in the tunnel’s route configuration and the client’s tooling — never with DNS toggles.

The actual SSH solution is a dedicated tunnel route (git-ssh.example.com) carrying SSH, with cloudflared wrapping the connection client-side:

# ~/.ssh/config
Host git-ssh.example.com
  User git
  ProxyCommand cloudflared access ssh --hostname %h
  IdentitiesOnly yes
  IdentityFile ~/.ssh/id_ed25519

Remote URLs become git@git-ssh.example.com:andrew/<repo>.git — no port, because the tunnel route already knows where Forgejo’s SSH listens. Access fronts this route too: cloudflared pops a browser login when the session expires, then SSH flows through authenticated. Ordinary git push from any configured machine, and still not one open port at home.

What This Stage Delivered

The finished ingress is one outbound daemon, one wildcard, one Git-tracked Caddyfile, and identity checks on everything a browser can reach. Adding a service to the internet is a Caddy block plus an Access application; removing one is an explicit 404. The home IP appears nowhere, and the router forwards nothing.

Next in the Series

Part 9 closes the series with the discipline the whole build rests on: the repository. The /opt/platform layout that separates Compose, config, data, and knowledge; the drift that crept in anyway and the gaps file that confessed it; CI validating the repo, Renovate proposing updates, and the allowlisted pilot that lets a merged pull request deploy a stack — turning a pile of containers into a platform that can explain itself.

Next: Part 9 — Turning the Platform into a Repeatable Build