The three options that didn’t work

When I started the homelab I looked at the standard ways to make self-hosted services accessible and found the usual compromises:

Open ports on the router. Point a DNS record at your public IP, forward 443 to your server. Simple. Also: your home IP is now publicly associated with your services, your ISP can see your traffic, and a misconfigured app means an open door. Hard pass.

VPN for everything. WireGuard on the router, every device gets a tunnel. Secure, but every phone and laptop needs to be configured, split tunnelling adds complexity, and “let me pull up the recipe” becomes a two-tap operation that my family won’t do. And I still want public access for some services.

Cloudflare Tunnel, but broken HTTPS locally. This is the one I started with. The cloudflared pod dials out to Cloudflare, no open ports, external access works great. But locally, *.hippotion.com resolves to Cloudflare’s anycast IP, traffic leaves the house, Cloudflare terminates TLS, traffic comes back in through the tunnel. Every local request makes a round trip to a Cloudflare edge node. Worse: browsers cache HSTS for hippotion.com, so http:// URLs on the local network silently upgrade to https://, which fails because there’s no local certificate. Intermittent, confusing, and hard to explain to anyone else on the network.

What I wanted: the tunnel for external access, direct-to-server for local access, real TLS in both cases, and one configuration per application.


The insight: Pi-hole already controls local DNS

My network already runs Pi-hole for ad blocking. Pi-hole uses dnsmasq under the hood and can resolve any hostname to any IP you want. One config line in Pi-hole’s values:

dnsmasq:
  customDnsEntries:
    - address=/hippotion.com/192.168.0.109

The address= directive is a wildcard. Every device on the LAN that uses Pi-hole for DNS — which is all of them, because the router hands out Pi-hole’s IP via DHCP — will now resolve anything.hippotion.com to 192.168.0.109, the server’s LAN address. External traffic still goes to Cloudflare’s IP because it uses the public authoritative DNS. The split is automatic; no per-device configuration.

Local browser → Pi-hole → server’s LAN IP directly.

That solves routing. Now TLS.


Why HTTP-01 won’t work here, and why DNS-01 will

The standard way to get a Let’s Encrypt certificate is the HTTP-01 challenge: Let’s Encrypt sends a request to http://yourdomain.com/.well-known/acme-challenge/<token> and your server responds. This requires Let’s Encrypt’s servers to reach your server over the public internet.

That doesn’t work here. There are no open ports. Let’s Encrypt can’t reach the server. HTTP-01 is out.

DNS-01 is different. Instead of proving you control a server, you prove you control the DNS zone by creating a temporary TXT record at _acme-challenge.yourdomain.com. Let’s Encrypt checks DNS, finds the record, issues the cert. No inbound connection required — just API access to your DNS provider.

hippotion.com is on Cloudflare. cert-manager has a Cloudflare DNS solver that calls the Cloudflare API to create and delete the TXT record automatically. The certificate request flow:

  1. cert-manager creates an ACME Order with Let’s Encrypt
  2. cert-manager calls the Cloudflare API: add _acme-challenge.hippotion.com TXT <token>
  3. Let’s Encrypt queries DNS, finds the record, issues the cert
  4. cert-manager deletes the TXT record, writes the cert to a Kubernetes Secret
  5. cert-manager renews automatically ~30 days before expiry

The Cloudflare API token needs Zone:DNS:Edit permission for hippotion.com. It lives in Vault and syncs to the sys-cert-manager namespace via External Secrets Operator — same pattern as every other secret in the cluster, nothing in Git.

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: hippotion-wildcard
  namespace: sys-traefik
spec:
  secretName: hippotion-wildcard-tls
  issuerRef:
    name: letsencrypt-cloudflare
    kind: ClusterIssuer
  dnsNames:
    - "hippotion.com"
    - "*.hippotion.com"

One certificate. Every subdomain. cert-manager stores it as hippotion-wildcard-tls in the sys-traefik namespace, where Traefik can read it.


Two Traefik entrypoints

Traefik has three entrypoints configured:

EntrypointPortPurpose
web80Redirects all traffic to websecure
websecure443Local HTTPS, serves the wildcard cert
cloudflare7080Receives plain HTTP from the cloudflared pod

The cloudflare entrypoint is the key piece. Cloudflare Tunnel terminates TLS at Cloudflare’s edge and forwards plain HTTP to the cluster. If that plain HTTP landed on web (port 80), it would get redirected to websecure (port 443), which would fail because cloudflared isn’t sending HTTPS. A separate entrypoint on a separate port handles tunnel traffic without redirection.

Traefik is configured to use the wildcard cert as its default:

tlsStore:
  default:
    defaultCertificate:
      secretName: hippotion-wildcard-tls

Any websecure request that doesn’t match a more specific TLS configuration gets the wildcard cert. No per-app certificate configuration.


One IngressRoute handles both paths

Every application gets a single IngressRoute with both entrypoints:

apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: myapp
  namespace: myapp
spec:
  entryPoints:
    - cloudflare   # plain HTTP from cloudflared
    - websecure    # local HTTPS with wildcard cert
  routes:
    - match: Host(`myapp.hippotion.com`)
      kind: Rule
      middlewares:
        - name: oauth-auth
          namespace: sys-oauth2-gitlab
      services:
        - name: myapp
          port: 8080

That’s it. The same hostname, the same routing rule, the same middleware — served correctly on both paths. No conditional logic, no separate ingress for local vs external.

The OAuth middleware (oauth-auth) works on both paths too. Local browsers get redirected to GitLab for authentication the same way external browsers do. The SSO cookie is set on hippotion.com, so it works across all subdomains regardless of which path the traffic came through.


What the two traffic paths look like end to end

External browser (anywhere):
  Browser
    → Cloudflare DNS (hippotion.com → Cloudflare anycast IP)
    → Cloudflare edge (TLS terminated, certificate managed by Cloudflare)
    → cloudflared pod in cluster (plain HTTP)
    → Traefik :7080 (cloudflare entrypoint)
    → app pod

Local browser (home WiFi):
  Browser
    → Pi-hole DNS (*.hippotion.com → 192.168.0.109)
    → Traefik :443 (websecure entrypoint)
    → Traefik serves hippotion-wildcard-tls (Let's Encrypt cert, trusted by browser)
    → app pod

Both paths hit the same Traefik IngressRoute rule. The app sees an HTTP request either way. TLS is handled at the edge — Cloudflare for external traffic, Traefik for local traffic.


The HSTS detail

Cloudflare likely has HSTS enabled for your domain. Browsers cache this: once they see an HSTS header for hippotion.com, they’ll refuse to load any http:// URL under that domain for the duration of the max-age. They silently upgrade to https:// and fail if there’s no cert.

This is why the original setup — tunnel only, no local cert — felt unreliable locally. The browser was doing the right thing (enforcing HTTPS) but the cert didn’t exist. The wildcard cert fixes this because HTTPS now actually works locally. The HSTS enforcement is fine once TLS is real.


What it’s like to operate

Adding a new service means writing one IngressRoute with two entrypoints and pushing to Git. No DNS records to create (cloudflared picks up hostnames from a config list), no certificates to request (the wildcard covers everything), no VPN profiles to distribute. The platform handles it.

Local access works when the internet is down. The Pi-hole DNS and the wildcard cert are entirely on-premises — as long as the server is up, the services are reachable, Cloudflare outage or not. I noticed this during a brief Cloudflare incident a few months ago: external access went down, everything inside the house kept working without interruption.

I’m not a networking expert. I just followed the constraint — no open ports, no VPN — and the DNS-01 + split DNS solution fell out naturally. It turned out to be simpler to configure than the alternatives, and cleaner to operate.