Why Certificate Automation Is Non-Negotiable
In 2026, certificate-related outages still regularly make headlines. The cause is almost always the same: a team relying on manual certificate renewal missed a 90-day Let's Encrypt expiry, or a 1-year commercial CA certificate quietly expired on a monitoring endpoint. Google has announced plans to reduce maximum TLS certificate validity to 47 days β when this happens, manual renewal becomes completely untenable.
The good news: certificate automation is well-solved. This guide covers every scenario: HTTP-01 challenges for public web servers, DNS-01 challenges for wildcard and internal certificates, cert-manager for Kubernetes, Caddy for automatic TLS without configuration, and HashiCorp Vault for internal PKI.
ACME Protocol: How Let's Encrypt Works
ACME (Automatic Certificate Management Environment) is the protocol Let's Encrypt uses. Understanding it helps you debug failures and choose the right challenge type.
ACME Certificate Issuance Flow:
1. Client requests certificate for example.com from ACME server
2. ACME server provides challenges (prove you control the domain):
a. HTTP-01: Create a file at http://example.com/.well-known/acme-challenge/TOKEN
b. DNS-01: Create TXT record _acme-challenge.example.com = TOKEN_HASH
c. TLS-ALPN-01: Provision a special TLS certificate on port 443
3. Client completes challenge (places file/DNS record)
4. ACME server validates challenge
5. ACME server signs and returns certificate
6. Certificate is valid for 90 days (Let's Encrypt)
or 47 days (proposed Google limit)
Challenge Type Comparison:
HTTP-01:
β Simple, no DNS API needed
β Works from any server with port 80 open
β Doesn't work for wildcard certificates
β Doesn't work for internal/non-public domains
DNS-01:
β Works for wildcard certificates (*.example.com)
β Works for internal domains (server.internal.company.com)
β Works even when port 80/443 are blocked
β Requires DNS API access
β Slightly more complex setup
Certbot: CLI Certificate Management
HTTP-01 Challenge (Public Web Servers)
# Install certbot
sudo apt-get install certbot python3-certbot-nginx
# Issue certificate with Nginx plugin (automatic config update)
sudo certbot --nginx -d example.com -d www.example.com
# For Apache
sudo certbot --apache -d example.com
# Standalone (no web server running on port 80)
sudo certbot certonly --standalone -d example.com
# Webroot (web server keeps running, certbot writes challenge file)
sudo certbot certonly --webroot -w /var/www/html -d example.com -d www.example.com
# Test renewal (dry run - no actual certificate issued)
sudo certbot renew --dry-run
# Force renewal (even if not near expiry)
sudo certbot renew --force-renewal --cert-name example.com
# Set up automatic renewal (certbot installs this automatically)
# Check systemd timer:
systemctl status certbot.timer
# Or cron:
cat /etc/cron.d/certbot
DNS-01 Challenge for Wildcard Certificates
# Wildcard cert requires DNS-01 challenge
# Install DNS plugin for your provider
# AWS Route 53
pip install certbot-dns-route53
sudo certbot certonly --dns-route53 -d "*.example.com" -d "example.com"
# Cloudflare
pip install certbot-dns-cloudflare
cat > /etc/certbot/cloudflare.ini << 'EOF'
dns_cloudflare_api_token = your-cloudflare-api-token
EOF
chmod 600 /etc/certbot/cloudflare.ini
sudo certbot certonly --dns-cloudflare --dns-cloudflare-credentials /etc/certbot/cloudflare.ini -d "*.example.com" -d "example.com"
# Result: certificate covers example.com AND *.example.com
# (api.example.com, app.example.com, etc. β one cert level deep)
# Verify certificate
openssl x509 -in /etc/letsencrypt/live/example.com/cert.pem -text -noout | grep -A2 "Subject Alternative Name"
# Should show: DNS:*.example.com, DNS:example.com
Caddy: Zero-Configuration Automatic TLS
Caddy is the simplest path to automatic TLS. It handles certificate issuance, renewal, and HTTPS redirects automatically β you don't configure any of it.
# Caddyfile β that's it. Caddy gets and renews certs automatically.
example.com {
reverse_proxy localhost:3000
}
api.example.com {
reverse_proxy localhost:8080
# Rate limiting
rate_limit {
zone dynamic {
key {remote_host}
events 100
window 1m
}
}
}
# Internal domain (self-signed, Caddy manages its own internal CA)
internal.company.local {
tls internal # Uses Caddy's internal CA
reverse_proxy localhost:8090
}
# Install Caddy
sudo apt-get install caddy
# Run Caddy
sudo systemctl start caddy
# Caddy automatically:
# 1. Gets Let's Encrypt certificate for example.com
# 2. Configures HTTPS on port 443
# 3. Redirects HTTP β HTTPS
# 4. Renews certificate automatically (when 30 days from expiry)
# Check Caddy's certificate storage
ls /var/lib/caddy/.local/share/caddy/certificates/
# Verify auto-renewal
journalctl -u caddy | grep -i "renew|certif"
# Caddy with Docker
docker run -p 80:80 -p 443:443 -p 443:443/udp -v ./Caddyfile:/etc/caddy/Caddyfile -v caddy_data:/data -v caddy_config:/config caddy:latest
cert-manager: Kubernetes Certificate Automation
cert-manager is the standard certificate controller for Kubernetes. It watches Certificate resources and automatically issues and renews certificates using configured Issuers.
Installation
# Install cert-manager with Helm
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.14.0 --set installCRDs=true --set prometheus.enabled=true --set webhook.timeoutSeconds=30
# Verify
kubectl get pods -n cert-manager
kubectl get crds | grep cert-manager
ClusterIssuer Configuration
# Let's Encrypt ClusterIssuer (HTTP-01)
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: ssl-admin@company.com
privateKeySecretRef:
name: letsencrypt-prod-key
solvers:
- http01:
ingress:
class: nginx # or istio, traefik, etc.
podTemplate:
spec:
nodeSelector:
"kubernetes.io/os": linux
---
# Let's Encrypt ClusterIssuer with DNS-01 (Cloudflare, for wildcards)
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-dns
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: ssl-admin@company.com
privateKeySecretRef:
name: letsencrypt-dns-key
solvers:
- dns01:
cloudflare:
email: admin@company.com
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token
selector:
dnsZones:
- company.com
---
# Cloudflare API token secret
apiVersion: v1
kind: Secret
metadata:
name: cloudflare-api-token
namespace: cert-manager
type: Opaque
stringData:
api-token: "your-cloudflare-api-token-with-dns-edit-permission"
Issuing Certificates
# Standard certificate for a service
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: api-tls
namespace: production
spec:
secretName: api-tls-secret # Kubernetes Secret where cert is stored
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
commonName: api.company.com
dnsNames:
- api.company.com
duration: 2160h # 90 days
renewBefore: 720h # Renew when 30 days from expiry
---
# Wildcard certificate via DNS-01
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: wildcard-tls
namespace: production
spec:
secretName: wildcard-tls-secret
issuerRef:
name: letsencrypt-dns
kind: ClusterIssuer
dnsNames:
- "*.company.com"
- "company.com"
duration: 2160h
renewBefore: 720h
---
# Ingress with automatic certificate (annotation-based)
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: api-ingress
namespace: production
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
cert-manager.io/duration: "2160h"
cert-manager.io/renew-before: "720h"
spec:
tls:
- hosts:
- api.company.com
secretName: api-tls-secret # cert-manager creates this
rules:
- host: api.company.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api-service
port:
number: 80
Monitoring Certificate Expiry
# Check certificate status
kubectl get certificates -A
kubectl describe certificate api-tls -n production
# cert-manager exposes Prometheus metrics
# Alert rule for certificates expiring soon:
cat << 'EOF' >> /etc/prometheus/rules/certificates.yml
groups:
- name: certificate-alerts
rules:
- alert: CertificateExpiringSoon
expr: |
certmanager_certificate_expiration_timestamp_seconds
- time() < 7 * 24 * 3600 # 7 days
for: 1h
labels:
severity: warning
annotations:
summary: "Certificate expiring in less than 7 days"
description: "Certificate {{ $labels.name }} in namespace {{ $labels.namespace }} expires in {{ $value | humanizeDuration }}"
- alert: CertificateExpired
expr: |
certmanager_certificate_expiration_timestamp_seconds - time() < 0
labels:
severity: critical
annotations:
summary: "Certificate has EXPIRED"
EOF
HashiCorp Vault PKI: Internal Certificate Authority
For internal services that can't use public CAs (private namespaces, internal APIs, microservice mTLS), Vault PKI provides a full internal CA with automatic rotation.
# Enable Vault PKI secrets engine
vault secrets enable pki
vault secrets tune -max-lease-ttl=87600h pki # 10 years
# Generate root CA
vault write pki/root/generate/internal common_name="Company Internal Root CA" ttl=87600h key_type=rsa key_bits=4096
# Create intermediate CA (better security practice)
vault secrets enable -path=pki_int pki
vault secrets tune -max-lease-ttl=43800h pki_int
vault write -format=json pki_int/intermediate/generate/internal common_name="Company Intermediate CA" ttl=43800h | jq -r '.data.csr' > pki_int.csr
vault write -format=json pki/root/sign-intermediate csr=@pki_int.csr format=pem_bundle ttl=43800h | jq -r '.data.certificate' > intermediate.cert.pem
vault write pki_int/intermediate/set-signed certificate=@intermediate.cert.pem
# Create a role for issuing service certificates
vault write pki_int/roles/internal-services allowed_domains="svc.cluster.local,internal.company.com" allow_subdomains=true max_ttl=720h ttl=24h # 24-hour certs, auto-rotated
key_type=rsa key_bits=2048 require_cn=false allowed_uri_sans="spiffe://cluster.local/*"
# Issue a certificate for a service
vault write pki_int/issue/internal-services common_name="api.svc.cluster.local" ttl=24h alt_names="api.svc.cluster.local" uri_sans="spiffe://cluster.local/ns/production/sa/api"
cert-manager + Vault Integration
# VaultIssuer for Kubernetes
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: vault-internal-ca
spec:
vault:
server: https://vault.company.com:8200
path: pki_int/sign/internal-services
caBundle: |
-----BEGIN CERTIFICATE-----
... (Vault CA bundle) ...
-----END CERTIFICATE-----
auth:
kubernetes:
mountPath: /v1/auth/kubernetes
role: cert-manager
secretRef:
name: vault-cert-manager-token
key: token
Nginx Configuration for TLS Hardening
server {
listen 443 ssl http2;
server_name example.com;
# Certificate files
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# Mozilla Modern configuration
ssl_protocols TLSv1.3 TLSv1.2;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# Session handling
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off; # Disable for forward secrecy
# OCSP stapling (reduces client latency)
ssl_stapling on;
ssl_stapling_verify on;
resolver 8.8.8.8 8.8.4.4 valid=300s;
resolver_timeout 5s;
# HSTS (max-age=1year, includeSubdomains, preload)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# Additional security headers
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Content Security Policy
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://api.example.com;" always;
}
Certificate Expiry Monitoring Script
#!/bin/bash
# check-cert-expiry.sh β Run weekly via cron, alert to Slack
DOMAINS=(
"example.com"
"api.company.com"
"app.company.com"
)
WARN_DAYS=30
CRITICAL_DAYS=7
SLACK_WEBHOOK="https://hooks.slack.com/services/..."
check_cert() {
local domain="$1"
local expiry
expiry=$(echo | openssl s_client -servername "$domain" -connect "$domain:443" 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
if [ -z "$expiry" ]; then
echo "ERROR: Could not check certificate for $domain"
return
fi
local expiry_epoch
expiry_epoch=$(date -d "$expiry" +%s)
local now
now=$(date +%s)
local days_remaining
days_remaining=$(( (expiry_epoch - now) / 86400 ))
if [ "$days_remaining" -lt "$CRITICAL_DAYS" ]; then
curl -s -X POST "$SLACK_WEBHOOK" -H 'Content-type: application/json' --data "{"text":"π¨ CRITICAL: Certificate for $domain expires in $days_remaining days!"}"
elif [ "$days_remaining" -lt "$WARN_DAYS" ]; then
curl -s -X POST "$SLACK_WEBHOOK" -H 'Content-type: application/json' --data "{"text":"β οΈ WARNING: Certificate for $domain expires in $days_remaining days"}"
else
echo "β $domain: $days_remaining days remaining"
fi
}
for domain in "DOMAINS[@]"; do
check_cert "$domain"
done
Conclusion
Certificate automation is a solved problem β the tools are mature, free, and well-documented. The only reason to have a certificate outage in 2026 is failing to implement automation in the first place. Start with Let's Encrypt + certbot for existing servers (15 minutes), add cert-manager to your Kubernetes cluster (30 minutes), and implement Vault PKI for internal services if you have a microservices architecture.
With Google's push toward 47-day certificate validity, the organizations that haven't automated by 2026 will face manual renewal work at a cadence that's simply unmanageable. Automate now.
Sarah Chen
Senior Cybersecurity Engineer with 12+ years of experience in penetration testing and security architecture.