How‑To: cert‑manager + Let’s Encrypt (DNS‑01 via Cloudflare) with Envoy TLS Termination


Overview

Issue real TLS certs with cert‑manager using the DNS‑01 challenge (Cloudflare) and terminate TLS directly in Envoy. No Ingress controller required.

Why DNS‑01 (no Ingress):

- Works with any L7 proxy (Envoy) without exposing port 80
- No /.well-known routing hassles
- Great for home/edge networks where HTTP‑01 is flaky

Prerequisites

- Kubernetes cluster with Envoy running as a Deployment/DaemonSet
- A domain in Cloudflare and an API Token with Zone.DNS:Edit for the zone
- cert‑manager installed (CRDs installed)

Install cert‑manager (Helm):

helm repo add jetstack https://charts.jetstack.io
helm repo update
kubectl create namespace cert-manager
helm install cert-manager jetstack/cert-manager   --namespace cert-manager   --set installCRDs=true

Create Cloudflare API token Secret (in cert‑manager ns):

kubectl -n cert-manager create secret generic cloudflare-api-token-secret   --from-literal=api-token='<CF_API_TOKEN>'

Create a ClusterIssuer for Let’s Encrypt (staging + prod) using DNS‑01:

cat <<'YAML' | kubectl apply -f -
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-staging-dns01
spec:
  acme:
    email: you@example.com
    server: https://acme-staging-v02.api.letsencrypt.org/directory
    privateKeySecretRef:
      name: le-staging-dns01-key
    solvers:
    - dns01:
        cloudflare:
          apiTokenSecretRef:
            name: cloudflare-api-token-secret
            key: api-token
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod-dns01
spec:
  acme:
    email: you@example.com
    server: https://acme-v02.api.letsencrypt.org/directory
    privateKeySecretRef:
      name: le-prod-dns01-key
    solvers:
    - dns01:
        cloudflare:
          apiTokenSecretRef:
            name: cloudflare-api-token-secret
            key: api-token
YAML

Request a Certificate (in your app namespace):

cat <<'YAML' | kubectl apply -f -
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: app-cert
  namespace: apps
spec:
  secretName: envoy-tls-app    # cert-manager will keep this secret updated
  issuerRef:
    kind: ClusterIssuer
    name: letsencrypt-prod-dns01
  dnsNames:
    - app.example.com
YAML

Mount the TLS Secret into Envoy and configure TLS:

# Deployment snippet (Envoy)
spec:
  template:
    spec:
      volumes:
      - name: tls
        secret:
          secretName: envoy-tls-app
      containers:
      - name: envoy
        image: envoyproxy/envoy:v1.30.2
        volumeMounts:
        - name: tls
          mountPath: /etc/envoy/tls
          readOnly: true
        args: ["-c","/etc/envoy/envoy.yaml"]

Envoy listener (TLS) snippet (envoy.yaml):

static_resources:
  listeners:
  - name: https_listener
    address:
      socket_address: { address: 0.0.0.0, port_value: 443 }
    filter_chains:
    - filter_chain_match:
        server_names: ["app.example.com"]
      transport_socket:
        name: envoy.transport_sockets.tls
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
          common_tls_context:
            tls_certificates:
            - certificate_chain: { filename: "/etc/envoy/tls/tls.crt" }
              private_key:       { filename: "/etc/envoy/tls/tls.key" }
      filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          stat_prefix: https_app
          route_config:
            name: local_route
            virtual_hosts:
            - name: app
              domains: ["app.example.com"]
              routes:
              - match: { prefix: "/" }
                route: { cluster: app_cluster }
          http_filters:
          - name: envoy.filters.http.router

  clusters:
  - name: app_cluster
    connect_timeout: 0.3s
    type: STRICT_DNS
    load_assignment:
      cluster_name: app_cluster
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: myapp.apps.svc.cluster.local
                port_value: 8080

Renewal Strategy (choose one)

A) **Rolling restart on Secret change (simple):** watch the Secret and `kubectl rollout restart deploy/envoy` when it updates. Zero‑config but brief connection churn.

B) **SDS (advanced, zero‑downtime):** serve TLS secrets to Envoy via xDS/Secret Discovery so certs rotate without restarts.

Verify

kubectl -n apps get certificate,secret | grep app-cert
openssl s_client -connect app.example.com:443 -servername app.example.com -showcerts </dev/null | openssl x509 -noout -issuer -enddate

Security

- Scope the Cloudflare token to the specific zone only.
- Prefer DNS‑01 for private/edge sites; keep 80/443 locked down appropriately.
- Treat TLS secrets as sensitive; restrict RBAC.

Meme idea

“HTTP‑01: find me on port 80.” Me with Envoy only: “Bestie, I’m doing DNS.”

Taylor Swift

“And the monsters turned out to be just trees.” — Out of the Woods

Comments

Popular posts from this blog

Learning to Automate My Side Projects with SWE-agent + GitLab

Ship-Ready Web Essentials: Search, Sitemap, Metadata & Icons (SvelteKit)

Kubernetes Secrets Management — SOPS + age (GitOps‑friendly)