cert-manager and Let’s Encrypt: TLS automation and the renewal that silently failed
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: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:
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:
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.
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:
NAME STATE AGE
api-gateway-tls-1a2b3-987654321 errored 29d
And finally, we inspected the failed Challenge:
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.
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:
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):
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.
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.
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:
Success: Certificate api-gateway-tls has been requested for renewal.
We observed the controller logs to watch the execution flow:
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:
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:
- Never rely on high-level CRD status fields alone: The
CertificateCRD reportingReady: Trueis 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. - Alert on the lower-level CRD states: Always monitor
CertificateRequestandOrderobjects. A failingCertificateRequestis the earliest warning indicator of an impending renewal failure, triggering up to 30 days before the active certificate actually expires. - 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.