This draft comes from a real cluster note where I needed a repeatable way to issue TLS certificates through cert-manager using Cloudflare DNS-01 validation. I cleaned up the environment-specific details and replaced all real domains, e-mail addresses, and API tokens with placeholders.

The end goal was simple: let Kubernetes manage certificates automatically while keeping DNS-based validation in place for public-facing services.

Why DNS-01 Was the Right Fit

For multi-service or ingress-heavy clusters, DNS-01 is often the cleaner option:

  • it does not depend on HTTP challenge paths being reachable first
  • it works well for wildcard and externally routed cases
  • it keeps certificate issuance independent from the application container itself

In this setup, Cloudflare provided the DNS API that cert-manager used to complete the challenge.

1. Install cert-manager

If cert-manager is not installed yet:

1
2
3
4
5
6
7
8
helm repo add jetstack https://charts.jetstack.io
helm repo update

helm install cert-manager jetstack/cert-manager \
  --namespace cert-manager \
  --create-namespace \
  --version v1.13.3 \
  --set installCRDs=true

One of the first possible failure modes is simply forgetting that the cert-manager namespace does not exist yet. The --create-namespace flag prevents that.

2. Install or Validate NGINX Ingress

Before requesting a certificate for an ingress-backed service, I made sure the ingress controller was in place:

1
2
3
4
5
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update

helm install ingress-nginx ingress-nginx/ingress-nginx \
  --set controller.service.externalTrafficPolicy=Local

In the original note, one reason for keeping externalTrafficPolicy=Local was avoiding SSL lookup confusion during ingress handling.

If the release already exists, upgrade instead:

1
2
3
helm upgrade ingress-nginx ingress-nginx/ingress-nginx \
  --reuse-values \
  --set controller.service.externalTrafficPolicy=Local

3. Handle a Chart Values Gotcha

One of the installation issues I recorded later was a Helm template failure around controller.extraArgs.update-status.

The fix was to move that setting into values.yml and make it explicitly string-based:

1
2
3
4
5
6
controller:
  extraArgs:
    update-status: "true"
  hostNetwork: true
  ingressClass: nginx
  externalTrafficPolicy: "Local"

That is a good reminder that not all values behave the same when passed inline through --set.

4. Verify the Cloudflare Token

Before wiring the issuer, I like to verify the API token separately:

1
2
curl "https://api.cloudflare.com/client/v4/user/tokens/verify" \
  -H "Authorization: Bearer <cloudflare-api-token>"

The real token should never live in the post itself. In a live environment, store it securely and only reference a placeholder in documentation.

5. Store the Token as a Kubernetes Secret

Create a secret in the cert-manager namespace:

1
2
3
4
5
6
7
8
apiVersion: v1
kind: Secret
metadata:
  name: cloudflare-token
  namespace: cert-manager
type: Opaque
stringData:
  token: "<cloudflare-api-token>"

Apply it:

1
kubectl apply -f cloudflare-token.yaml

6. Create a ClusterIssuer

Because the goal was to reuse the issuer across services, I used a ClusterIssuer instead of a namespace-scoped Issuer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-cloudflare
spec:
  acme:
    email: [email protected]
    server: https://acme-v02.api.letsencrypt.org/directory
    privateKeySecretRef:
      name: letsencrypt-private-key
    solvers:
      - dns01:
          cloudflare:
            apiTokenSecretRef:
              name: cloudflare-token
              key: token

7. Request a Certificate

With the issuer in place, create a certificate in the service namespace:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: app-example-com-ssl
  namespace: app-prod
spec:
  secretName: app-example-com-chain
  issuerRef:
    name: letsencrypt-cloudflare
    kind: ClusterIssuer
  dnsNames:
    - app.example.com

8. Wire the Certificate into Ingress

A representative ingress looked like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: app-ingress
  namespace: app-prod
  annotations:
    kubernetes.io/ingress.class: nginx
    nginx.ingress.kubernetes.io/configuration-snippet: |
      proxy_set_header X-Forwarded-Proto https;
      proxy_set_header X-Forwarded-Port 443;
    nginx.ingress.kubernetes.io/use-forwarded-headers: "true"
spec:
  tls:
    - hosts:
        - app.example.com
      secretName: app-example-com-chain
  rules:
    - host: app.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: app-service
                port:
                  number: 9011

9. Enable Snippet Annotations If Needed

One of the errors in the original note came from ingress annotation restrictions:

1
2
nginx.ingress.kubernetes.io/configuration-snippet annotation cannot be used.
Snippet directives are disabled by the Ingress administrator.

The fix was updating the ingress controller ConfigMap:

1
2
3
4
5
6
7
8
9
apiVersion: v1
kind: ConfigMap
metadata:
  name: ingress-nginx-controller
data:
  allow-snippet-annotations: "true"
  enable-real-ip: "true"
  compute-full-forwarded-for: "true"
  use-forwarded-headers: "true"

10. Verify and Troubleshoot

Useful checks:

1
2
3
kubectl describe certificate app-example-com-ssl -n app-prod
kubectl get secret app-example-com-chain -n app-prod
kubectl get ingress -n app-prod

If NGINX still does not pick up the certificate immediately, a reload can help confirm whether the secret is already valid:

1
kubectl exec -it deployment/ingress-nginx-controller -- nginx -s reload

What This Note Shows

This was not just a certificate issuance note. It was an operational sequence:

  • install the controller components
  • validate token access
  • store the secret safely
  • define the issuer
  • request the certificate
  • make sure ingress actually consumes it

That sequencing is usually where real-world TLS automation either becomes smooth or turns into a string of confusing partial failures.

Closing Thought

I like this kind of note because it starts as infrastructure glue work but ends up becoming a reusable pattern. Once cleaned up and sanitized, it is exactly the sort of thing that helps other admins move faster with fewer surprises.