Automating TLS Certificates in Kubernetes with cert-manager and Cloudflare

If you’re running Kubernetes and want automatic HTTPS for your services, cert-manager is one of the best tools available. It integrates directly with Let’s Encrypt to handle certificate requests, renewals, and management.

In my setup, I wanted to:

  • Use Cloudflare DNS for ACME DNS-01 challenges.
  • Automatically issue wildcard certificates (*.example.com).
  • Use the EmberStack Reflector plugin to share those certificates across namespaces.

To make the process easier to repeat, I split the configuration into four Ansible playbooks:

  1. cert-manager-helm-values.yml – Helm configuration for cert-manager
  2. cert-manager.yml – Installs cert-manager and sets up the Let’s Encrypt issuer
  3. cert-manager-reflector-plugin.yml – Installs the reflector plugin
  4. cert-manager-wildcard-cert.yml – Creates a wildcard certificate

cert-manager Helm Values (cert-manager-helm-values.yml)

# Custom cert-manager Helm values file for Cloudflare DNS01 challenge
installCRDs: true
extraArgs:
  - --dns01-recursive-nameservers=1.1.1.1:53,9.9.9.9:53
  - --dns01-recursive-nameservers-only=true
podDnsPolicy: None
podDnsConfig:
  nameservers:
    - "1.1.1.1"
    - "9.9.9.9"

This configuration ensures cert-manager installs its CRDs and uses Cloudflare’s and Quad9’s public resolvers for DNS lookups.
It’s especially helpful if you run a custom internal DNS setup or need predictable resolution for ACME challenges.


Installing cert-manager and Creating a ClusterIssuer (cert-manager.yml)

- name: Install cert-manager
  hosts: master
  gather_facts: false
  vars:
    desired_state: "present"

  tasks:
    - name: Add jetstack Helm repo
      kubernetes.core.helm_repository:
        kubeconfig: /home/{{ user }}/.kube/config
        context: {{ kube_context }}  # I have multiple clusters, so this line is not needed if you only manage one cluster with your kubeconfig.
        repo_name: jetstack
        repo_url: "https://charts.jetstack.io"
        state: "{{ desired_state }}"
      delegate_to: localhost

    - name: Deploy cert-manager
      kubernetes.core.helm:
        kubeconfig: /home/{{ user }}/.kube/config
        context: {{ kube_context }}  # see note above
        state: "{{ desired_state }}"
        name: cert-manager
        chart_ref: jetstack/cert-manager
        release_namespace: cert-manager
        create_namespace: true
        release_state: present
        purge: true
        force: true
        wait: true
        values_files:
          - /path/to/cert-manager-helm-values.yml
      delegate_to: localhost

    - name: Create Cloudflare API token secret
      kubernetes.core.k8s:
        kubeconfig: /home/{{ user }}/.kube/config
        context: {{ kube_context }}  # see note above
        state: "{{ desired_state }}"
        definition:
          apiVersion: v1
          kind: Secret
          metadata:
            name: cloudflare-api-token
            namespace: cert-manager
            annotations:
              reflector.v1.k8s.emberstack.com/reflection-allowed: "true"
              reflector.v1.k8s.emberstack.com/reflection-auto-enabled: "true"
          type: Opaque
          stringData:
            api-token: "{{ cloudflare_api_token }}"
      delegate_to: localhost

    - name: Create ClusterIssuer for Let’s Encrypt
      kubernetes.core.k8s:
        kubeconfig: /home/{{ user }}/.kube/config
        context: {{ kube_context }}  # see note above
        state: "{{ desired_state }}"
        definition:
          apiVersion: cert-manager.io/v1
          kind: ClusterIssuer
          metadata:
            name: letsencrypt-staging  # change to letsencrypt-prod once tested
            namespace: cert-manager
          spec:
            acme:
              email: "{{ contact_email }}"
              server: https://acme-staging-v02.api.letsencrypt.org/directory
              privateKeySecretRef:
                name: letsencrypt-staging
              solvers:
                - dns01:
                    cloudflare:
                      email: "{{ contact_email }}"
                      apiTokenSecretRef:
                        name: cloudflare-api-token
                        key: api-token
                  selector:
                    dnsZones:
                      - "{{ domain1 }}"
                      - "{{ domain2 }}"

This playbook installs cert-manager, creates the Cloudflare API token Secret, and defines a ClusterIssuer using Let’s Encrypt’s staging endpoint.
The staging environment issues untrusted certificates but avoids production rate limits — ideal for testing.


About the {{ }} variables

Anything inside double braces (like {{ kube_context }} or {{ cloudflare_api_token }}) belongs in your Ansible vars file, not the playbook itself.
Keeping credentials and domains separate makes your playbooks reusable and secure.

Example vars.yml:

# vars.yml
user: douglas
kube_context: cluster1
contact_email: do**@*****le.com
cloudflare_api_token: YOUR_API_TOKEN
domain1: example.com
domain2: anotherdomain.com

Because this file contains secrets, it should be encrypted with Ansible Vault:

ansible-vault encrypt vars.yml
ansible-playbook cert-manager.yml --ask-vault-pass -e @vars.yml

Installing the Reflector Plugin (cert-manager-reflector-plugin.yml)

- name: Install cert-manager reflector plugin
  hosts: master
  gather_facts: false
  vars:
    desired_state: "present"

  tasks:
    - name: Add emberstack Helm repo
      kubernetes.core.helm_repository:
        kubeconfig: /etc/rancher/k3s/k3s.yaml
        repo_name: emberstack
        repo_url: "https://emberstack.github.io/helm-charts"
        state: "{{ desired_state }}"

    - name: Update Helm repo cache
      kubernetes.core.helm:
        kubeconfig: /etc/rancher/k3s/k3s.yaml
        state: absent
        release_name: dummy
        release_namespace: kube-system
        update_repo_cache: true

    - name: Deploy reflector
      kubernetes.core.helm:
        kubeconfig: /etc/rancher/k3s/k3s.yaml
        state: "{{ desired_state }}"
        name: reflector
        chart_ref: emberstack/reflector
        release_namespace: reflector
        create_namespace: true
        release_state: present
        purge: true
        force: true
        wait: true

The reflector plugin replicates Secrets across namespaces, allowing a single wildcard certificate to be shared throughout the cluster.


Creating the Wildcard Certificate (cert-manager-wildcard-cert.yml)

- name: Create wildcard certificate via cert-manager
  hosts: master
  become: true
  gather_facts: false
  vars:
    desired_state: "present"

  tasks:
    - name: Create wildcard Certificate resource
      kubernetes.core.k8s:
        kubeconfig: /etc/rancher/k3s/k3s.yaml
        state: "{{ desired_state }}"
        definition:
          apiVersion: cert-manager.io/v1
          kind: Certificate
          metadata:
            name: wildcard-cert-prod-example
            namespace: cert-manager
          spec:
            secretName: wildcard-cert-prod-example-tls
            secretTemplate:
              annotations:
                reflector.v1.k8s.emberstack.com/reflection-allowed: "true"
                reflector.v1.k8s.emberstack.com/reflection-auto-enabled: "true"
            issuerRef:
              name: letsencrypt-staging  # switch to prod once validated
              kind: ClusterIssuer
            commonName: "*.example.com"
            dnsNames:
              - "*.example.com"

This defines a wildcard certificate using the Let’s Encrypt ClusterIssuer. Once complete, cert-manager will store it as a Kubernetes Secret, and the reflector plugin will propagate it to other namespaces.


Verifying cert-manager Installation

Before diving into issuers and certificates, confirm cert-manager itself is running properly.

# Check namespace and pods
kubectl get ns
kubectl get pods -n cert-manager

# Check deployments and replica status
kubectl get deployments -n cert-manager
kubectl describe deployment cert-manager -n cert-manager

# Check services (webhook and CA injector)
kubectl get svc -n cert-manager

# Confirm CRDs were installed
kubectl get crds | grep cert-manager.io

# Check logs from the main controller
kubectl logs -n cert-manager deploy/cert-manager

# Verify all components are ready
kubectl get pods -n cert-manager -o wide

If everything is healthy, you should see three main deployments:
cert-manager, cert-manager-cainjector, and cert-manager-webhook.
All should be in a Running state with 1/1 READY.


Verifying cert-manager Functionality

After confirming installation, use these to check certificate-related resources.

# Check the ClusterIssuer
kubectl get clusterissuer
kubectl describe clusterissuer letsencrypt-staging

# Check certificates
kubectl get certificate -n cert-manager
kubectl describe certificate wildcard-cert-prod-example -n cert-manager

# Check expiry and readiness
kubectl -n cert-manager get certificate \
  -o custom-columns=NAME:metadata.name,NOT_AFTER:status.notAfter,READY:status.conditions[?(@.type=="Ready")].status

# Troubleshoot ACME stages
kubectl get order,challenge,certificaterequest -n cert-manager
kubectl describe order <order-name> -n cert-manager
kubectl describe challenge <challenge-name> -n cert-manager
kubectl describe certificaterequest <certrequest-name> -n cert-manager

# Inspect the final TLS secret
kubectl get secret wildcard-cert-prod-example-tls -n cert-manager
kubectl describe secret wildcard-cert-prod-example-tls -n cert-manager

Use Let’s Encrypt Staging First

Always start with the staging environment:

server: https://acme-staging-v02.api.letsencrypt.org/directory

Let’s Encrypt production enforces rate limits (about 50 certificates per domain per week). Staging has no such restriction and is ideal for verifying DNS automation before switching to production.


Summary

Breaking the cert-manager setup into separate playbooks makes it easier to test and maintain:

  • Helm configuration
  • cert-manager install and issuer setup
  • Reflector plugin deployment
  • Wildcard certificate definition

Keep your secrets encrypted with Ansible Vault.
Verify cert-manager installation first, confirm ClusterIssuer readiness, and only then test certificate issuance with staging.
Once everything checks out, point the issuer to the production API and you’ll have fully automated TLS across your Kubernetes cluster.


Appendix: Quick Reference – cert-manager Verification Commands

When troubleshooting or validating your deployment, this quick reference covers the main checks from installation to active certificates.

Verify Installation

kubectl get ns | grep cert-manager
kubectl get pods -n cert-manager
kubectl get deployments -n cert-manager
kubectl describe deployment cert-manager -n cert-manager
kubectl get svc -n cert-manager
kubectl get crds | grep cert-manager.io
kubectl logs -n cert-manager deploy/cert-manager

Verify ClusterIssuer or Issuer

kubectl get issuer,clusterissuer
kubectl describe clusterissuer letsencrypt-staging

Verify Certificates

kubectl get certificate -n cert-manager
kubectl describe certificate wildcard-cert-prod-example -n cert-manager

Check Expiry and Readiness

kubectl -n cert-manager get certificate \
  -o custom-columns=NAME:metadata.name,NOT_AFTER:status.notAfter,READY:status.conditions[?(@.type=="Ready")].status

Inspect ACME Orders, Challenges, and Requests

kubectl get order,challenge,certificaterequest -n cert-manager
kubectl describe order <order-name> -n cert-manager
kubectl describe challenge <challenge-name> -n cert-manager
kubectl describe certificaterequest <certrequest-name> -n cert-manager

Confirm the TLS Secret

kubectl get secret wildcard-cert-prod-example-tls -n cert-manager
kubectl describe secret wildcard-cert-prod-example-tls -n cert-manager

Leave a Comment