BlogCybersecurity
Cybersecurity

SSL/TLS Certificate Automation: Let's Encrypt, ACME, and Zero-Touch Certificate Lifecycle Management

Manual SSL certificate management causes outages. Automate the entire certificate lifecycle with Let's Encrypt, cert-manager, Caddy, and Vault PKI. Covers ACME protocol internals, wildcard certificates via DNS-01 challenge, internal PKI for private services, and monitoring expiry.

S

Sarah Chen

Senior Cybersecurity Engineer with 12+ years of experience in penetration testing and security architecture.

March 21, 2026
21 min read

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.

S

Sarah Chen

Senior Cybersecurity Engineer with 12+ years of experience in penetration testing and security architecture.

Ready to Transform Your Infrastructure?

Let's discuss how we can help you achieve similar results.