cloud

Replace Caddy with cloudflared: expose Kubernetes without opening ports 80 and 443

Move the public entry point and identity checks to Cloudflare, then reach internal services through an outbound-only Tunnel.

My k8s_infra setup originally used Caddy as a local web reverse proxy. External requests reached Cloudflare first, entered my home network through forwarded HTTP or HTTPS ports, arrived at Caddy, and were finally sent to Kubernetes.

That design worked, but it still required a public entry point into my home network. Even with Cloudflare proxying, TLS, and firewall rules, the origin had to listen on an inbound port. I also had to maintain another layer for Caddy routing and TLS termination.

I have now replaced that path with cloudflared running inside Kubernetes. The connector initiates a Tunnel to Cloudflare, Cloudflare Access authenticates the user, and approved requests travel through the existing Tunnel connection into the cluster. My router no longer forwards ports 80 or 443 to the local server, and Caddy is no longer required for public web traffic.

The new request path is:

browser
  -> Cloudflare Access
  -> Cloudflare edge
  -> outbound Cloudflare Tunnel
  -> cloudflared Pod
  -> Kubernetes ClusterIP Service
  -> application Pod

Cloudflare Tunnel uses connections initiated by the origin. The origin does not need a public IP or an open inbound port. That change in connection direction is the most important security boundary in this migration. Cloudflare’s Tunnel overview and firewall guide also describe blocking ingress while allowing the outbound connections needed by cloudflared.

Series

This post is part of my home Kubernetes GitOps series:

  1. Bootstrap a new RKE cluster for GitOps
  2. Use Argo CD to manage my home Kubernetes cluster
  3. Use Vault and External Secrets in Kubernetes
  4. Run Istio ambient mode with waypoint proxies
  5. Expose Kubernetes services with Istio Gateway API
  6. Build an OpenTelemetry stack for Kubernetes apps
  7. Run Airflow on Kubernetes with GitOps-managed values
  8. Use Mozilla SOPS with GitOps for encrypted Kubernetes Secrets
  9. Replace Caddy with cloudflared: expose Kubernetes without opening ports 80 and 443

The original Caddy path

The old architecture looked roughly like this:

browser -> Cloudflare -> router 80/443 -> Caddy -> NodePort/Service -> Pod

Caddy handled TLS termination, hostname routing, and reverse proxying. This is a straightforward design and works well for services on a single host. In my home Kubernetes environment, however, it meant maintaining all of these pieces:

  1. Port-forwarding rules on the router.
  2. Inbound rules on the host firewall.
  3. Hostnames and upstreams in the Caddy configuration.
  4. A NodePort, Ingress, or another Kubernetes entry point.
  5. Cloudflare DNS records and Access policies.

A mistake in any of those layers could leave the origin directly reachable and allow a request to bypass the authentication path I expected Cloudflare to enforce.

With Tunnel, the public entry point exists only at Cloudflare. Because cloudflared runs inside the cluster, it can resolve Kubernetes Service names directly. The request no longer needs to leave the cluster and return through a NodePort.

The new security boundary

This change is not simply replacing Caddy with another reverse proxy. It reverses the direction in which the network connection is established.

The old design accepted a connection from outside my network:

Internet --inbound--> public 80/443 --proxy--> Kubernetes

The new design starts the connection from inside the cluster:

Kubernetes cloudflared --outbound--> Cloudflare
Cloudflare --existing tunnel--> Kubernetes Service

My local server can therefore reject inbound HTTP and HTTPS traffic from the Internet. An external user must go through Cloudflare and cannot send a request directly to ports 80 or 443 on the origin.

The phrase “no open ports” needs one qualification: it means no open inbound HTTP or HTTPS ports. cloudflared still needs outbound network access. The Cloudflare firewall documentation specifies TCP/UDP port 7844 for Tunnel egress, using HTTP/2 or QUIC respectively.

Run cloudflared inside Kubernetes

I run the connector in a dedicated cloudflare namespace and start two replicas for the same Tunnel:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: cloudflared
  namespace: cloudflare
spec:
  replicas: 2
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: cloudflared
  template:
    metadata:
      labels:
        app.kubernetes.io/name: cloudflared
    spec:
      containers:
        - name: cloudflared
          image: cloudflare/cloudflared:<pinned-version>
          args:
            - tunnel
            - --no-autoupdate
            - --loglevel
            - info
            - --metrics
            - 0.0.0.0:2000
            - run
          env:
            - name: TUNNEL_TOKEN
              valueFrom:
                secretKeyRef:
                  name: cloudflared-token
                  key: token
          readinessProbe:
            httpGet:
              path: /ready
              port: 2000
          securityContext:
            allowPrivilegeEscalation: false
            capabilities:
              drop: ["ALL"]
            readOnlyRootFilesystem: true
            runAsNonRoot: true
            seccompProfile:
              type: RuntimeDefault

Both replicas use the same Tunnel token. Their purpose is connector availability; they do not automatically give the Kubernetes Services or applications a complete load-balancing and disaster-recovery design. The Cloudflare Kubernetes deployment guide also describes additional cloudflared replicas as a high-availability mechanism.

I pin the container image and use --no-autoupdate. This keeps version changes inside the normal GitOps review and Argo CD rollout process instead of allowing the container to update itself.

Keep the Tunnel token out of Git

The Tunnel token can start a connector, so I treat it as a sensitive credential. I do not place the token directly in the Deployment or commit it as plaintext. Terraform obtains the token, I store it in Vault, and External Secrets creates the Kubernetes Secret consumed by the Pod:

Terraform output
  -> Vault KV
  -> ExternalSecret
  -> Secret/cloudflared-token
  -> cloudflared Pod

A simplified ExternalSecret looks like this:

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: cloudflared-token
  namespace: cloudflare
spec:
  refreshInterval: 2m
  secretStoreRef:
    name: vault-cloudflare
    kind: ClusterSecretStore
  target:
    name: cloudflared-token
    creationPolicy: Owner
  data:
    - secretKey: token
      remoteRef:
        key: cloudflare/cloudflared
        property: token

Git contains only the secret mapping, not the token itself. If the token is exposed, I rotate it at Cloudflare first and then update Vault. Deleting the string from Git does not invalidate a credential that has already leaked.

Manage the Tunnel and hostnames with Terraform

I use a remotely managed Tunnel. Kubernetes is responsible for running the connector, while Terraform manages the Tunnel, DNS records, hostname routes, and Cloudflare Access applications.

Each route maps a public hostname to a Service that the connector can reach:

locals {
  tunnel_routes = {
    argocd = {
      hostname         = "argocd.example.com"
      service          = "http://argocd-server.argocd.svc.cluster.local:80"
      access_aud_tags  = local.access_aud_tags.argocd
      http_host_header = "argocd.example.com"
      no_tls_verify    = false
      manage_dns       = true
    }

    kiali = {
      hostname         = "kiali.example.com"
      service          = "http://kiali.istio-system.svc.cluster.local:20001"
      access_aud_tags  = local.access_aud_tags.kiali
      http_host_header = "kiali.example.com"
      no_tls_verify    = false
      manage_dns       = true
    }
  }
}

The service field uses a full Kubernetes Service DNS name. Internal UIs such as Argo CD, Kiali, Airflow, and Longhorn can remain ClusterIP Services without a public LoadBalancer or NodePort.

The Tunnel ingress configuration is generated from this route map and always ends with a catch-all 404 rule:

resource "cloudflare_zero_trust_tunnel_cloudflared_config" "home_k8s" {
  account_id = var.cloudflare_account_id
  tunnel_id  = cloudflare_zero_trust_tunnel_cloudflared.home_k8s.id
  source     = "cloudflare"

  config = {
    ingress = concat(
      [
        for route_name in sort(keys(local.tunnel_routes)) : {
          hostname = local.tunnel_routes[route_name].hostname
          service  = local.tunnel_routes[route_name].service

          origin_request = {
            http_host_header = local.tunnel_routes[route_name].http_host_header
            no_tls_verify    = local.tunnel_routes[route_name].no_tls_verify
            access = {
              required  = true
              team_name = var.cloudflare_access_team_name
              aud_tag   = local.tunnel_routes[route_name].access_aud_tags
            }
          }
        }
      ],
      [
        {
          hostname       = null
          service        = "http_status:404"
          origin_request = null
        }
      ]
    )
  }
}

The catch-all matters. A hostname without an explicit route should not fall through to an accidental default backend.

The DNS record for each published hostname points to:

<tunnel-uuid>.cfargotunnel.com

It does not point to my home public IP, so DNS does not reveal an HTTP or HTTPS origin endpoint that can be contacted directly.

Authenticate at Cloudflare Access

I want management interfaces such as Argo CD, Kiali, Grafana, and Longhorn to authenticate users before a request reaches the local service.

Each hostname therefore has a corresponding Cloudflare Access application and allow policy. When a user opens one of these URLs, the flow is:

  1. Cloudflare Access verifies the user’s identity and evaluates the policy.
  2. After approval, Cloudflare attaches an Access JWT to the request.
  3. cloudflared validates the JWT against the AUD configured for the route.
  4. Only a successfully validated request is forwarded to the origin Service.

Setting access.required = true and supplying the correct aud_tag makes the connector verify that the token belongs to the expected Access application. In my Terraform model, an empty access_aud_tags list means that a route is intentionally public. Internal management UIs must never be left with an empty list by accident.

Cloudflare Access is still an identity-aware outer gate, not a replacement for application authorization. Argo CD, Grafana, and other applications should keep appropriate accounts, RBAC, and session controls. Edge authentication and application authorization are separate layers.

Migration order

I did not turn off Caddy at the start. I first built and verified the new path:

  1. Create the Tunnel and Access applications with Terraform.
  2. Store the Tunnel token in Vault.
  3. Let External Secrets create cloudflared-token.
  4. Deploy two cloudflared Pods through Argo CD.
  5. Add one low-risk hostname and verify Access, JWT validation, and Service routing.
  6. Move the existing hostnames to the Tunnel one at a time.
  7. Test each service from an external network and confirm that unauthenticated requests cannot reach the origin.
  8. Confirm that every production hostname has stopped using Caddy.
  9. Remove router port forwarding and inbound firewall rules for 80 and 443.
  10. After confirming that it has no other role, disable Caddy as the public reverse proxy.

This sequence preserves a rollback path. A broken Tunnel configuration does not affect the old entry point before DNS is switched. The old route is closed only after the replacement has been verified, avoiding a migration that loses both paths at once.

What I verify after the migration

I validate the result from inside the cluster, from the LAN, and from an external network.

First, I check the connector and secret state:

kubectl -n cloudflare get deployment,pod
kubectl -n cloudflare get externalsecret,secretstore
kubectl -n cloudflare logs deployment/cloudflared --tail=100

Then I confirm that origin Services remain internal:

kubectl get service --all-namespaces
kubectl get gateway,httproute --all-namespaces

Finally, I test from a network outside my home LAN:

  • An unauthenticated request reaches Cloudflare Access, not the application.
  • An account that does not match the allow policy is rejected.
  • Hostnames, redirects, WebSockets, and callback URLs work after login.
  • Ports 80 and 443 on my home public IP are unreachable from the Internet.
  • New requests continue through the second replica when one cloudflared Pod is stopped.
  • An unconfigured hostname returns 404 instead of reaching another service.

A Running status on the cloudflared Pods is not enough. The migration is complete only when the old inbound path is closed and every management UI is actually protected by its Access policy.

Risks that still remain

Cloudflare Tunnel reduces the public attack surface, but it does not solve every security problem automatically.

The Tunnel token is still a privileged credential

Someone with a valid token may be able to start an unauthorized connector. The token should not appear in Git, shell history, CI logs, or Terraform plan artifacts, and it needs a tested rotation procedure.

Cluster networking determines what cloudflared can reach

Because the cloudflared Pods run inside the cluster, they can usually resolve and contact many Services. NetworkPolicy should restrict them to the namespaces and ports that the Tunnel is intended to publish. This limits lateral movement if the connector is compromised.

Internal HTTP is not safe in every environment

The Tunnel protects the path between the Cloudflare edge and the connector, but the connector-to-origin hop may still use HTTP. In a less trusted environment, across unencrypted node networks, or under compliance requirements, I would add Istio mTLS or use an HTTPS origin rather than assume that all in-cluster traffic is safe.

The original client IP needs explicit handling

The origin normally sees the connector as its direct network peer, not the user’s original IP address. If an HTTP application uses client IPs for audit logs, rate limits, or security decisions, it must consume the correct Cloudflare header through a trusted proxy chain. The application must not trust the same header from arbitrary, untrusted sources.

Cloudflare becomes an entry-point dependency

Access, DNS, Tunnel, and the Cloudflare edge are all part of the remote access path. I keep a restricted LAN or VPN maintenance path so I am not completely locked out when Cloudflare or the Internet is unavailable. That recovery path does not reopen public ports 80 or 443.

The result

The Kubernetes web entry point changed from:

Cloudflare -> public 80/443 -> Caddy -> Kubernetes

to:

Cloudflare Access -> outbound Tunnel -> cloudflared -> ClusterIP Service

The biggest improvement is not merely removing a Caddy process. My local network no longer exposes a public web listener. Authentication happens at Cloudflare, the connector validates the Access JWT before proxying, and only hostnames explicitly declared in Terraform can travel through the Tunnel to a specific Service.

For a home Kubernetes cluster, this puts the public entry point, access policy, and DNS in one control plane while keeping the internal routing model simple. Removing one public inbound hop also removes one endpoint that could otherwise be scanned, misconfigured, or used to bypass the intended authentication path.