Omegion

Talos OS: Exposing a Homelab Service with Cloudflare Tunnel

Introduction

Between the cluster, the storage, and Postgres, I’ve got a real place to run apps now. All of that is still only reachable from inside my LAN, though. This post is getting a service out to the internet without opening a port on my home router or publishing my home IP anywhere - cloudflared running as an ingress controller inside the cluster.

Why a Tunnel Instead of Port Forwarding

cloudflared makes an outbound connection from inside the cluster out to Cloudflare’s edge and keeps it open. Traffic comes in over that connection, not through an inbound port on my router. There’s nothing to forward, no firewall rule pointing at my home IP, and no home IP exposed in DNS at all - every request goes through Cloudflare’s edge first.

Routing everything through that edge also means bot protection, WAF rules, and rate limiting are available on the same traffic, configured in Cloudflare rather than in the app or on the tunnel itself - nginx here doesn’t have to know any of that exists. I haven’t turned any of it on yet for this dummy app, but it’s sitting there for when something behind this tunnel actually needs it.

Prerequisites

  1. The Talos cluster from the first post and the ceph-rbd storage class from the second post.
  2. A domain onboarded to a Cloudflare account.
  3. Helm 3.
  4. A Cloudflare API token scoped to manage Tunnels.
  5. The cloudflare-tunnel-ingress-controller Helm chart.

Installing the Tunnel Ingress Controller

I run the cloudflare-tunnel-ingress-controller chart, which both manages the cloudflared pods and watches Kubernetes Ingress objects to create the matching tunnel routes and DNS records automatically:

yaml
cloudflare-tunnel-ingress-controller:
  cloudflare:
    accountId: "<account-id>"
    tunnelName: "homelab-ingress"
    apiToken: "<api-token>"

  cloudflared:
    replicaCount: 2
    podAntiAffinity: true
    resources:
      requests:
        cpu: 50m
        memory: 64Mi
      limits:
        cpu: 250m
        memory: 256Mi
    pdb:
      enabled: true
      minAvailable: 1
shell
❯ helm install cloudflare-ingress cloudflare/cloudflare-tunnel-ingress-controller \
  -n cloudflare-ingress --create-namespace -f values.yaml

I didn’t start with the replicaCount, podAntiAffinity, and resources block above - the chart defaults to a single replica with no resource limits at all, which meant every app behind the tunnel went down with that one pod. I also caught it stuck on a stale image build: cloudflared warns “outdated” on every startup, and left on latest with IfNotPresent it just never re-pulls once a pod already exists. Pinning the tag and running two replicas spread across nodes with a PodDisruptionBudget fixed both.

Pointing an App at It

Any Ingress with ingressClassName: cloudflare-tunnel gets picked up by the controller automatically - no manual cloudflared tunnel route dns step. To prove it works, a dummy nginx deployment:

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-demo
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx-demo
  template:
    metadata:
      labels:
        app: nginx-demo
    spec:
      containers:
        - name: nginx
          image: nginx:1.27
          ports:
            - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: nginx-demo
spec:
  selector:
    app: nginx-demo
  ports:
    - port: 80
      targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: nginx-demo
spec:
  ingressClassName: cloudflare-tunnel
  rules:
    - host: nginx.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: nginx-demo
                port:
                  number: 80
shell
❯ kubectl apply -f nginx-demo.yaml
❯ curl -sI https://nginx.example.com
HTTP/2 200
server: cloudflare
content-type: text/html

That request went from my laptop, out to Cloudflare, back down through the tunnel, into the cluster, and hit nginx - without touching my router at all:

Conclusion

The app is reachable from the internet now, which is the point, but also the problem: right now anyone with the URL can hit it, nginx welcome page and all. Next post: putting Cloudflare Zero Trust in front of this so it actually requires a login.