Adding TLS to Gateway API with Cloudflare Origin Certificates

by Afanasy Barbarov

Adding TLS to Gateway API with Cloudflare Origin Certificates

I had the Gateway API working on port 80 with Cilium in hostNetwork mode. Next step: HTTPS. The goal is Cloudflare Full (Strict) mode — encrypted all the way from visitor to origin.

The two types of Cloudflare certificates

This is where it gets confusing if you haven't done it before. Cloudflare has two separate certificate systems, and they live in different places in the dashboard.

Edge Certificates (SSL/TLS → Edge Certificates) handle the visitor-to-Cloudflare leg. Cloudflare manages these automatically — universal SSL, free, auto-renewing. You don't touch these.

Origin Server certificates (SSL/TLS → Origin Server) handle the Cloudflare-to-your-server leg. These are what you need. Also free, but you generate them yourself and install on your origin. They're valid for up to 15 years but trusted only by Cloudflare — browsers will reject them, which is fine since browsers never talk to your origin directly.

If you find yourself on a page trying to sell you "Advanced Certificate Manager" for $10/month, you're in the wrong section.

Generating the origin certificate

In the Cloudflare dashboard: SSL/TLS → Origin Server → Create Certificate.

Settings:

  • Let Cloudflare generate a private key and CSR
  • Hostnames: *.yourdomain.com, yourdomain.com
  • Validity: 15 years
  • Key format: PEM

Cloudflare shows you the certificate and private key exactly once. Copy both. If you lose the private key, you'll need to generate a new cert.

Storing the cert in Kubernetes

The cert and key go into a standard TLS secret in the gateway namespace (same namespace as the Gateway resource):

apiVersion: v1
kind: Secret
metadata:
  name: cloudflare-origin-cert
  namespace: gateway
type: kubernetes.io/tls
stringData:
  tls.crt: |
    -----BEGIN CERTIFICATE-----
    ...your certificate...
    -----END CERTIFICATE-----
  tls.key: |
    -----BEGIN PRIVATE KEY-----
    ...your private key...
    -----END PRIVATE KEY-----

Store this in k8s/cloudflare-origin-cert.yaml and add it to .gitignore immediately. You do not want private keys in git.

Apply:

kubectl apply -f k8s/cloudflare-origin-cert.yaml

Adding the HTTPS listener to Gateway

The Gateway resource gets a new listener. I went with HTTPS-only — no port 80 listener at all. Cloudflare handles the HTTP-to-HTTPS redirect on the edge, so there's no reason for the origin to accept unencrypted traffic. One less thing to worry about.

k8s/gateway.yaml:

apiVersion: v1
kind: Namespace
metadata:
  name: gateway
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: main-gateway
  namespace: gateway
spec:
  gatewayClassName: cilium
  listeners:
    - name: https
      port: 443
      protocol: HTTPS
      tls:
        mode: Terminate
        certificateRefs:
          - kind: Secret
            name: cloudflare-origin-cert
      allowedRoutes:
        namespaces:
          from: All

Key details:

  • mode: Terminate means the Gateway terminates TLS and forwards plain HTTP to backend services. Your apps don't need to know about TLS.
  • certificateRefs points to the secret in the same namespace. No ReferenceGrant needed since both live in gateway.
  • allowedRoutes: from: All lets HTTPRoutes from any namespace attach to this listener.
  • No hostname filter on the listener — the origin cert covers *.yourdomain.com and yourdomain.com, and Cloudflare only sends traffic for your domains anyway. Individual HTTPRoutes handle hostname matching.

Apply:

kubectl apply -f k8s/gateway.yaml

No changes needed for existing HTTPRoutes

HTTPRoutes that reference main-gateway without a sectionName automatically attach to all compatible listeners. Since I changed from HTTP to HTTPS, the routes just work — Gateway API handles the listener matching.

# This route works on the HTTPS listener without changes
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: dashboard-route
  namespace: apps
spec:
  parentRefs:
  - name: main-gateway
    namespace: gateway
  rules:
  - matches:
    - path:
        type: PathPrefix
        value: /
    backendRefs:
    - name: dashboard
      port: 80

No changes needed for Cilium or network policies

The Cilium values already have NET_BIND_SERVICE capability for Envoy (added for port 80). Port 443 is also a privileged port, so the same capability covers it.

The fromEntities: ingress network policy works the same for HTTPS traffic — Cilium's special ingress identity applies to all Gateway API traffic regardless of the listener protocol.

Configuring Cloudflare

Once the Gateway is running with the origin cert:

  1. SSL/TLS → Overview → set encryption mode to Full (strict)
  2. DNS → verify A records point to your node IPs with the orange cloud (proxied) enabled

Full (strict) means Cloudflare validates the origin certificate before establishing the connection. If the cert is expired or missing, Cloudflare returns a 526 error to the visitor. This is what you want — it prevents MITM between Cloudflare and your origin.

Testing

Direct to node (bypasses Cloudflare):

curl -kv https://<node-ip>:443

The -k flag is required because origin certs aren't trusted by browsers/curl. You should see a TLS handshake with the Cloudflare origin cert and an Envoy response (404 if no matching route, 200 if a route matches).

Through Cloudflare:

curl -v https://yourdomain.com

This should show a valid Cloudflare edge certificate in the TLS handshake and your app's response.

IPv6 support

After getting HTTPS working on IPv4, I tried IPv6. It didn't work — curl -kv https://[<node-ipv6>]:443 returned "Network is unreachable". Turns out there were two problems.

Problem 1: Talos doesn't auto-configure IPv6

Unlike Debian, Talos Linux does not pick up IPv6 addresses from Router Advertisements by default. The nodes had IPv6 addresses assigned by the hosting provider, but Talos wasn't using them. The fix is explicit DHCPv6 in the machine config:

machine:
  network:
    interfaces:
      - interface: ens3
        dhcp: true
        dhcpOptions:
          ipv6: true

Apply to each node over IPv4 (safe — if IPv6 breaks, IPv4 still works):

talosctl apply-config --talosconfig talos/talosconfig --endpoints <node-ipv4> --nodes <node-ipv4> --file talos/controlplane.yaml

Problem 2: Cilium needs IPv6 enabled

Cilium doesn't enable IPv6 by default. Add to k8s/cilium-values.yaml:

ipv6:
  enabled: true
ipv4:
  enabled: true
enableIPv6Masquerade: true

Then upgrade and restart Envoy:

helm upgrade cilium cilium/cilium --namespace kube-system --values k8s/cilium-values.yaml
kubectl rollout restart daemonset cilium-envoy -n kube-system

Known issue: Envoy dual-stack binding

There are open Cilium issues (#34179, #37978) where Envoy in hostNetwork mode may only bind to 0.0.0.0 (IPv4) and not :: (IPv6). If after enabling both Talos IPv6 and Cilium IPv6 the gateway still doesn't respond on IPv6, the workaround is separate Gateway resources for IPv4 and IPv6.

Testing IPv6

# Basic connectivity
ping6 <node-ipv6>

# Gateway TLS
curl -kv https://[<node-ipv6>]:443

Files

FilePurpose
k8s/cloudflare-origin-cert.yamlTLS secret with origin cert/key (gitignored)
k8s/gateway.yamlGateway with HTTPS listener on port 443
k8s/cilium-values.yamlUpdated with IPv6 enabled
talos/controlplane.yamlUpdated with DHCPv6 on ens3
.gitignoreUpdated to exclude the cert secret

Written by Afanasy Barbarov — Tech Lead with 15+ years shipping production systems in Rust, Go, and TypeScript. Facing a similar challenge? Reach out on LinkedIn. Support my work.

More articles

Previous post

Gateway API and Ingress Setup.

Read more

Next post

Migrating Apps from Docker Swarm to Kubernetes.

Read more