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 that only accepts pushes from the sofa is half a Git server, and a dashboard you cannot open from elsewhere is barely monitoring. This post covers how Forge’s services became reachable from anywhere, with no open router port, no published home IP address, and no naked login page on the internet. It ends with the outage that taught me what tunnel DNS records are.

The Options, and Why Two of Them Lost

Port forwarding with dynamic DNS is the traditional homelab answer, and it fails the brief three ways. Your home IP address becomes public knowledge, tied to your domain in DNS history permanently. Your router — the least-updated device you own — becomes security-critical. And every forwarded service meets the internet’s background scanning directly, with only its own login page for protection. Some of those login pages are excellent. The exposure depends on all of them being excellent, including the ones you installed and forgot.

VPN-only access is the opposite extreme. With Tailscale, nothing is exposed and everything is reachable, but only from enrolled devices. For admin surfaces that is exactly correct, and Tailscale is how Proxmox and Portainer are reached. It cannot give a service a real URL that works from a borrowed browser, and it cannot share anything with another person.

Cloudflare Tunnel inverts the model. A small daemon inside the network makes an outbound connection to Cloudflare’s edge, and public hostnames route down that tunnel. Nothing listens on the home connection, so there is no port to scan, and DNS resolves to Cloudflare instead of to my house. The whole ingress is one 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 any person who holds it can route your hostnames.

One Wildcard, One Router: Caddy

The tunnel can route each hostname individually from Cloudflare’s dashboard, but that splits the routing truth between a web UI and the repository. Instead, one wildcard sends all of *.example.com down the tunnel to one place: Caddy, 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
}

A new public service is a two-line diff. Container names rather than IP addresses, which is the shared-network decision from Part 4 paying off again.

Two details in those blocks are not obvious. The http:// prefix carries load. TLS terminates at Cloudflare’s edge, and the tunnel delivers plain HTTP to the origin. Without the explicit scheme, Caddy does the “correct” thing: it provisions certificates and redirects to HTTPS, which makes a redirect loop against a tunnel that speaks only HTTP to it. The explicit scheme tells Caddy that this origin only ever speaks HTTP.

The second detail is what happens to hostnames that do not match. A wildcard DNS record makes every possible subdomain resolve and reach Caddy, including subdomains you never configured. Caddy’s default for an unmatched HTTP host can be an empty 200 response, which quietly tells a scanner that something lives here. The live configuration closes that door:

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

An explicit 404 catch-all, and the same hygiene for retired services. When a service goes away — including Part 6’s departures — its hostname gets an explicit 404 block instead of deletion into ambiguity. With a wildcard in place, publication becomes the default state, so the configuration has to name what is public rather than what is not.

HTTPS Is Not Authorization

Here is the trap in every “expose your homelab with a tunnel” tutorial. At this point the services are encrypted and the origin is hidden, and they are still open to the public internet. HTTPS protects traffic in transit. It has no opinion about who can enter. Every public hostname is still a login page that accepts guesses from anyone.

The platform’s answer is Cloudflare Access: an identity check at Cloudflare’s edge, before a request reaches the tunnel. Each public hostname gets an Access application, with an allow-list of my email address, a one-time PIN or an identity provider login, and a session that lasts days. The request never touches my network until identity is proven. Services get enterprise SSO in front of them, including services that do not know it happens.

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

  • Never public. Proxmox, Portainer, PostgreSQL, and Prometheus have no hostname at all, and answer only over Tailscale. A control plane behind a login page is still one vulnerability away from full access to the platform.
  • Public hostname, Access in front. The dashboard, Grafana, n8n, and the Forgejo web UI. Useful away from home, and nobody else’s business.
  • Public by explicit decision. Nothing sits here today, but the tier exists, and a rule guards it. No new Caddy route until the matching Access application exists and is tested. The order matters. Reversed, it produces an open service with authentication still on the to-do list.

One trade-off is honest and worth stating. The Access browser login breaks non-browser clients. Git over HTTPS through an Access-protected hostname fails, because a machine cannot complete an interactive PIN flow. That forces the question of how git push reaches the server, and that question produced this post’s disaster.

What Broke: The Grey-Cloud Outage

Git SSH looked simple. Forgejo listens on port 2222, so ssh git.example.com:2222 times out. From everywhere. A firewall? The tunnel configuration? No. The cause was more fundamental, and I understood it only after I made it much worse.

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

The error was in my mental model. A tunnel hostname is a CNAME to cfargotunnel.com. It is not an address with optional proxying, because the proxy performs the routing. Cloudflare’s edge accepts the request and forwards it down the tunnel. A grey-clouded record publishes a CNAME to infrastructure that does not answer unproxied requests. There is no “direct” to fall back to, because no public IP address exists behind these hostnames. That absence was the entire point of the tunnel choice in Part 1. And the edge carries only what the tunnel routes expose, which for HTTP hostnames does not include port 2222. That explains the first timeout.

Two symptoms, one lesson, now a standing rule in the decisions file. Tunnel-type DNS records stay Proxied, always. With tunnel-only origins, you solve connectivity problems in the tunnel’s route configuration and the client’s tools, never with DNS toggles.

The real SSH solution is a dedicated tunnel route, git-ssh.example.com, that carries SSH, with cloudflared wrapped around the client connection:

# ~/.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, with no port, because the tunnel route knows where Forgejo’s SSH listens. Access fronts this route too. When the session expires, cloudflared opens a browser login, and then SSH flows through, authenticated. Ordinary git push from any configured machine, and still no open port at home.

What This Stage Delivered

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

Next in the Series

Part 9 closes the first arc with the discipline that the whole build rests on: the repository. The /opt/platform layout that separates Compose, configuration, data, and knowledge. The drift that arrived anyway, and the gaps file that confessed it. CI that validates the repository, Renovate that proposes updates, and the allowlisted pilot that lets a merged pull request deploy a stack.

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