Skip to content
Cloud Native

cert-manager and Let’s Encrypt: TLS automation and the renewal that silently failed

cert-manager — several cargo containers

At 03:14 UTC, our automated trading backend started dropping connections from three major institutional liquidity providers. In our execution logs, the errors looked like this:

2024-10-12T03:14:02.104Z [ERROR] execution-gateway: TLS handshake failed: remote error: tls: bad certificate
2024-10-12T03:14:05.891Z [ERROR] execution-gateway: Connection closed by peer: TLS alert: certificate expired

This shouldn’t have happened. We were using cert-manager inside our Kubernetes clusters to automate TLS certificates via Let’s Encrypt. The certificates were supposed to rotate automatically 30 days before expiration. The deployment was configured with a standard DNS-01 challenge solver using Cloudflare.

Yet, a critical production certificate had expired.

This is the post-mortem of how our automated TLS pipeline silently failed, why our alerting missed it, and the precise configuration, Prometheus rules, and validation scripts we implemented to guarantee it never happens again.


The Failure Mode: Behind the Status “True”

We had a Certificate resource configured to secure our ingress gateway. When we ran our first diagnostic command during the outage, the output was deeply misleading:

$ kubectl get certificates -n ingress-nginx

NAME READY SECRET AGE
api-gateway-tls True api-gateway-tls-certs 241d

The status was True. According to Kubernetes, the certificate was “Ready”.

However, running openssl directly against the gateway endpoint painted a completely different picture:

$ echo | openssl s_client -connect api.production.internal:443 -servername api.production.internal 2>/dev/null | openssl x509 -noout -dates

notBefore=Jul 14 03:13:00 2024 GMT
notAfter=Oct 12 03:13:00 2024 GMT

The live certificate had expired exactly 14 minutes prior.

The Diagnostic Trail

We started tracing the certificate generation hierarchy: Certificate -> CertificateRequest -> Order -> Challenge.

$ kubectl get certificaterequests -n ingress-nginx

NAME APPROVED DENIED READY ISSUER AGE
api-gateway-tls-1a2b3 True False cloudflare-issuer 29d

Here was the mismatch. The Certificate resource reported Ready: True because it was evaluating the existing, loaded Kubernetes Secret (which was valid when evaluated weeks ago but was now expired). Meanwhile, the pending renewal request (CertificateRequest) had been stuck in a state of failure for 29 days.

We checked the Order resources:

$ kubectl get orders -n ingress-nginx

NAME STATE AGE
api-gateway-tls-1a2b3-987654321 errored 29d

And finally, we inspected the failed Challenge:

$ kubectl describe challenge api-gateway-tls-1a2b3-987654321-11223344 -n ingress-nginx
text
Status:
Presented: false
Processing: false
Reason: Cloudflare API error: Zone not found or token does not have permissions to edit DNS records.
State: errored

Four weeks prior to the expiration, our infrastructure team had tightened RBAC policies in our Cloudflare account. They scoped down the API token used by our staging and production clusters. During this cleanup, they accidentally omitted the zone ID of our primary trading domain from the allowed resources in the production token policy.

Because cert-manager could not present the DNS-01 TXT record to Cloudflare, the Let’s Encrypt ACME challenge failed. But instead of bubbling this failure up to trigger a hard status change on the parent Certificate resource, the system kept serving the old, valid-but-aging certificate inside the target Secret.

The renewal failed silently in the background. The Certificate status remained True until the actual day of expiration, at which point the ingress controller was left serving a dead certificate.


The Corrected Architecture

To prevent this silent failure, we refactored our TLS lifecycle. We transitioned to explicit token management, implemented deep status propagation checks, and introduced an independent external validator.

flowchart TD
 cert["Certificate CRD"] -->|Triggers| certReq["CertificateRequest"]
 certReq -->|Spawns| order["Order CRD"]
 order -->|Executes| challenge["Challenge CRD"]
 challenge -->|Updates TXT| dnsAPI["Cloudflare DNS API"]
 dnsAPI -->|Validates| ca["Let's Encrypt CA"]
 ca -->|Issues Cert| certReq
 certReq -->|Rotates Secret| cert
 prometheus["Prometheus / Alertmanager"] -->|Scrapes Metrics| cert
 extCheck["Python Canary Watchdog"] -->|Direct TLS Handshake| dnsAPI

Code and Configuration

Below are the exact Kubernetes manifests, monitoring configurations, and automation scripts we deployed to resolve the failure and build absolute observability into our TLS layers.

1. The Secure and Scoped ClusterIssuer

We dropped the over-privileged legacy Cloudflare tokens and created a strictly scoped Cloudflare API token with only the following permissions:
Zone - DNS - Edit
Zone - Zone - Read

Here is the corrected and hardened production manifest. We explicitly separated staging and production ACME directory URLs to avoid Let’s Encrypt rate limits during testing.

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: cloudflare-prod-issuer
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: [email protected]
privateKeySecretRef:
name: letsencrypt-prod-private-key
solvers:
dns01:
cloudflare:
email: [email protected]
apiTokenSecretRef:
name: cloudflare-api-token-secret
key: api-token

Here is the corresponding Secret containing the highly restricted Cloudflare API token:

apiVersion: v1
kind: Secret
metadata:
name: cloudflare-api-token-secret
namespace: ingress-nginx
type: Opaque
stringData:
api-token: hg82JKDSa910_ds9aJkdS91KDS90aJkL_d8aJK23

And the production Certificate resource, which now explicitly mandates renewal 30 days before expiration (renewBefore: 720h):

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: api-gateway-tls
namespace: ingress-nginx
spec:
secretName: api-gateway-tls-certs
duration: 2160h # 90 days
renewBefore: 720h # 30 days
subject:
organizations:
Trading Corp LLC
commonName: api.production.internal
dnsNames:
api.production.internal
ws.production.internal
issuerRef:
name: cloudflare-prod-issuer
kind: ClusterIssuer

2. Monitoring and Alerting: Prometheus Rules

Relying on the Certificate status field was our main mistake. We had to alert directly on metrics derived from the actual certificates stored in Secret keys, and we needed to watch the failing CertificateRequest objects.

We deployed the following PrometheusRule resource to alert our team 15 days before expiration, and to flag any failed certificate request immediately.

apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: cert-manager-alerts
namespace: ingress-nginx
labels:
role: alert-rules
spec:
groups:
name: cert-manager.rules
rules:
alert: CertManagerCertExpiryVanishing
expr: |
(certmanager_certificate_expiration_timestamp_seconds – time()) < (15 * 24 * 3600)
for: 15m
labels:
severity: critical
tier: platform
annotations:
summary: "TLS Certificate expiring soon: {{ $labels.name }}"
description: "The TLS Certificate {{ $labels.name }} in namespace {{ $labels.namespace }} is expiring in less than 15 days. Current time remaining: {{ $value | humanizeDuration }}."

alert: CertManagerCertificateRequestFailed
expr: |
certmanager_certificate_request_status{status="False"} > 0
for: 10m
labels:
severity: warning
tier: platform
annotations:
summary: "Certificate Request is failing: {{ $labels.name }}"
description: "The CertificateRequest {{ $labels.name }} in namespace {{ $labels.namespace }} has failed. This indicates that renewal or initial issuance is blocked."

alert: CertManagerValidationError
expr: |
rate(certmanager_controller_issuer_sync_errors_total[10m]) > 0
for: 5m
labels:
severity: critical
tier: platform
annotations:
summary: "cert-manager Controller Sync Errors"
description: "The cert-manager controller is failing to sync issuers. This points to API errors, RBAC failures, or network issues with CA providers."


3. External Network Canary: Independent Verification Script

To completely bypass internal Kubernetes metric misrepresentations, we implemented an external canary monitor. This Python script runs as an isolated daemon inside a secure management zone. It performs a live TLS handshake against our edge nodes, extracting the certificate dates directly from the wire.

#!/usr/bin/env python3
import socket
import ssl
import sys
import datetime
import logging
from typing import Tuple

logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[logging.StreamHandler(sys.stdout)]
)
logger = logging.getLogger("tls_canary")

TARGET_HOSTS = [
("api.production.internal", 443),
("ws.production.internal", 443)
]
CRITICAL_THRESHOLD_DAYS = 14

def get_ssl_expiry_date(hostname: str, port: int) -> Tuple[datetime.datetime, int]:
"""
Establishes a raw SSL connection to extract the 'notAfter' certificate attribute.
Bypasses local cluster state to read the actual wire state.
"""
context = ssl.create_default_context()
# Force loading of custom internal root certificates if necessary, e.g.:
# context.load_verify_locations(cafile="/etc/ssl/certs/ca-certificates.crt")

with socket.create_connection((hostname, port), timeout=5) as sock:
with context.wrap_socket(sock, server_hostname=hostname) as ssock:
cert = ssock.getpeercert()
if not cert:
raise ValueError(f"No certificate returned by {hostname}")

# Extract the expiration date string
not_after_str = cert['notAfter']
# e.g., 'Oct 12 03:13:00 2024 GMT'
expiry_date = datetime.datetime.strptime(not_after_str, '%b %d %H:%M:%S %Y %Z')
days_remaining = (expiry_date datetime.datetime.utcnow()).days
return expiry_date, days_remaining

def main():
failures = 0
for host, port in TARGET_HOSTS:
try:
expiry, days_left = get_ssl_expiry_date(host, port)
if days_left <= CRITICAL_THRESHOLD_DAYS:
logger.error(
f"CRITICAL: Certificate for {host}:{port} expires in {days_left} days! "
f"Expiry Date: {expiry.isoformat()}"
)
failures += 1
else:
logger.info(
f"PASS: {host}:{port} is healthy. Expiry: {expiry.isoformat()} "
f"({days_left} days remaining)"
)
except Exception as e:
logger.error(f"FAILURE: Could not evaluate TLS status for {host}:{port} – Error: {str(e)}")
failures += 1

if failures > 0:
logger.error(f"Validation failed on {failures} target endpoint(s). Exiting with error.")
sys.exit(1)

logger.info("All endpoints passed TLS validation checks.")
sys.exit(0)

if __name__ == "__main__":
main()


Results and Verification

Once we updated our RBAC permissions, configured the alerts, and deployed the new ClusterIssuer, we ran a verification test.

First, we cleared the old failed resources and forced cert-manager to execute a renewal run:

$ kubectl cert-manager renew api-gateway-tls -n ingress-nginx

Success: Certificate api-gateway-tls has been requested for renewal.

We observed the controller logs to watch the execution flow:

$ kubectl logs -n cert-manager -l app.kubernetes.io/name=cert-manager –tail=100 -f
text
I1012 03:52:11.101 controller.go:129] "syncing item" key="ingress-nginx/api-gateway-tls"
I1012 03:52:11.450 order.go:212] "created Challenge path" resource="api-gateway-tls-1a2b3-987654321-11223344"
I1012 03:52:12.802 dns.go:104] "successfully presented DNS-01 challenge to Cloudflare API" domain="api.production.internal"
I1012 03:52:45.312 acme.go:340] "received validated status for challenge" domain="api.production.internal"
I1012 03:52:46.012 certificate_request.go:189] "CertificateRequest issued successfully" name="api-gateway-tls-1a2b3"

The live verification confirmed the dynamic secret rotation worked seamlessly. We ran our Python validation script immediately after:

$ python3 tls_canary.py
text
2024-10-12 03:53:01,002 [INFO] tls_canary: PASS: api.production.internal:443 is healthy. Expiry: 2025-01-10T03:52:46 (90 days remaining)
2024-10-12 03:53:01,291 [INFO] tls_canary: PASS: ws.production.internal:443 is healthy. Expiry: 2025-01-10T03:52:46 (90 days remaining)
2024-10-12 03:53:01,292 [INFO] tls_canary: All endpoints passed TLS validation checks.

Our Prometheus environment picked up the new metrics. Querying certmanager_certificate_expiration_timestamp_seconds for our certificate returned the expected epoch timestamp, reflecting the full 90-day validity window.


Lessons Learned

Automated systems can create a false sense of security. To ensure zero-downtime TLS operations, we now follow these rules:

  1. Never rely on high-level CRD status fields alone: The Certificate CRD reporting Ready: True is a lagging indicator that only tells you whether a Secret exists and was once successfully written. It does not mean the Secret is still valid, nor does it guarantee the background renewal process is succeeding.
  2. Alert on the lower-level CRD states: Always monitor CertificateRequest and Order objects. A failing CertificateRequest is the earliest warning indicator of an impending renewal failure, triggering up to 30 days before the active certificate actually expires.
  3. Verify from the outside: Keep an independent, external monitoring agent in place that performs actual TLS handshakes against your production ingress nodes. If a certificate is expired, missing, or misconfigured, this external canary will catch it regardless of what Kubernetes API objects report inside your cluster.

Join the conversation

Your email address will not be published. Required fields are marked *