LabHub

Blog

SSL Certificate Operations Playbook: Zero-Downtime Renewal and Expiry Prevention

한국어English日本語

SSL Certificate Operations Playbook: Zero-Downtime Renewal and Expiry Prevention

Introduction

The basics of SSL/TLS certificates -- concepts, issuance methods, and Nginx configuration -- are covered in the SSL/TLS Certificate Complete Guide. This post extends that foundation by focusing exclusively on operations. Issuing a certificate once is easy. The real challenge is managing dozens of domains without a single expiry incident.

Certificate expiry incidents happen even to major services. In 2020, Microsoft Teams went down for hours due to an expired certificate. Spotify and LinkedIn have experienced the same. The common thread was not the absence of automation, but the absence of operational processes.

This playbook answers the following questions:


1. Certificate Lifecycle Management

Certificate operations is not simply "issue and renew." It requires systematic lifecycle management.

┌──────────┐    ┌──────────┐    ┌──────────┐    ┌──────────┐    ┌──────────┐
Issuance │ → │  Deploy  │ → │ Monitor  │ → │ Renewal  │ → │  Revoke└──────────┘    └──────────┘    └──────────┘    └──────────┘    └──────────┘
      ↑                                              │
      └──────────────────────────────────────────────┘
                    (auto-renewal cycle)

1.1 Issuance

Key decisions at the issuance stage:

DecisionOptionsRecommended
CA selectionLet's Encrypt / DigiCert / ACMDepends on environment (see below)
Key algorithmRSA 2048 / RSA 4096 / ECDSA P-256ECDSA P-256 (performance + security)
Certificate scopeSingle domain / Wildcard / SANWildcard + apex SAN
Validation methodHTTP-01 / DNS-01DNS-01 (required for wildcards)

ECDSA is recommended because it has a smaller key size compared to RSA 2048 (256-bit vs. 2048-bit), TLS handshake performance is approximately 2-5x faster, and CPU load is lower at equivalent security strength.

# Issue Let's Encrypt certificate with ECDSA key
sudo certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  --key-type ecdsa \
  --elliptic-curve secp256r1 \
  -d "*.example.com" \
  -d "example.com"

1.2 Deployment

After issuance, the certificate must be applied to the actual service. This is trivial for a single server but requires a deployment strategy when spanning multiple servers.

#!/bin/bash
# /usr/local/bin/deploy-cert.sh
# Certificate deployment script (multi-server)

CERT_DIR="/etc/letsencrypt/live/example.com"
SERVERS=("web01" "web02" "web03")
REMOTE_CERT_DIR="/etc/nginx/ssl"
DEPLOY_LOG="/var/log/cert-deploy.log"

deploy_cert() {
    local server=$1
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploying to $server" >> "$DEPLOY_LOG"

    # Transfer certificate files
    scp -q "$CERT_DIR/fullchain.pem" "$server:$REMOTE_CERT_DIR/fullchain.pem.new"
    scp -q "$CERT_DIR/privkey.pem" "$server:$REMOTE_CERT_DIR/privkey.pem.new"

    # Atomic replacement (mv is atomic on the same filesystem)
    ssh "$server" "
        mv $REMOTE_CERT_DIR/fullchain.pem.new $REMOTE_CERT_DIR/fullchain.pem
        mv $REMOTE_CERT_DIR/privkey.pem.new $REMOTE_CERT_DIR/privkey.pem
        nginx -t && systemctl reload nginx
    "

    if [ $? -eq 0 ]; then
        echo "[$(date '+%Y-%m-%d %H:%M:%S')] $server: OK" >> "$DEPLOY_LOG"
    else
        echo "[$(date '+%Y-%m-%d %H:%M:%S')] $server: FAILED" >> "$DEPLOY_LOG"
        return 1
    fi
}

for server in "${SERVERS[@]}"; do
    deploy_cert "$server"
done

1.3 Monitoring

Covered in detail in Section 4. The core principles are:

1.4 Renewal

Zero-downtime renewal strategies are covered in detail in Section 2.

1.5 Revocation

Situations requiring certificate revocation:

# Revoke a Let's Encrypt certificate
sudo certbot revoke --cert-path /etc/letsencrypt/live/example.com/cert.pem \
  --reason keycompromise

# Delete certificate files after revocation
sudo certbot delete --cert-name example.com

# Immediately issue a new certificate
sudo certbot certonly --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  -d "*.example.com" -d "example.com"

2. Zero-Downtime Renewal Strategies

The main causes of service disruption during certificate renewal are:

  1. Restarting (not reloading) the web server during renewal
  2. Time gap between deploying the new certificate and load balancer propagation
  3. Clients holding TLS session caches referencing the old certificate

2.1 Nginx Reload (Single Server)

The most basic zero-downtime approach. When Nginx performs a reload, existing worker processes finish handling their current requests before shutting down, while new worker processes start with the new configuration (and new certificate).

# restart vs reload difference
# restart: stops and restarts the process → potential request loss
# reload:  spawns new workers → graceful shutdown of old workers → zero downtime

# Automate reload with certbot deploy hook
sudo certbot renew --deploy-hook "systemctl reload nginx"

Warning: Never use systemctl restart nginx. It immediately terminates existing connections.

2.2 Rolling Renewal (Multi-Server)

When multiple servers sit behind a load balancer, renew one server at a time sequentially.

#!/bin/bash
# /usr/local/bin/rolling-cert-renewal.sh

SERVERS=("web01" "web02" "web03")
LB_API="http://lb-admin.internal:8080/api"
HEALTH_CHECK_URL="https://example.com/healthz"
WAIT_SECONDS=30

for server in "${SERVERS[@]}"; do
    echo "=== Processing $server ==="

    # 1. Remove server from load balancer
    curl -s -X POST "$LB_API/drain" -d "server=$server"
    echo "Draining $server from load balancer..."
    sleep $WAIT_SECONDS  # Wait for existing connections to complete

    # 2. Deploy certificate and apply
    scp /etc/letsencrypt/live/example.com/fullchain.pem "$server:/etc/nginx/ssl/"
    scp /etc/letsencrypt/live/example.com/privkey.pem "$server:/etc/nginx/ssl/"
    ssh "$server" "nginx -t && systemctl reload nginx"

    # 3. Health check
    for i in $(seq 1 10); do
        status=$(curl -s -o /dev/null -w "%{http_code}" "https://$server/healthz" --resolve "example.com:443:$(dig +short $server)")
        if [ "$status" = "200" ]; then
            echo "$server health check passed"
            break
        fi
        sleep 2
    done

    # 4. Re-add server to load balancer
    curl -s -X POST "$LB_API/enable" -d "server=$server"
    echo "$server re-enabled in load balancer"
    sleep 5
done

echo "=== Rolling renewal complete ==="

2.3 Blue-Green Certificate Swap

Operate two sets of certificates and instantly switch at the transition point. Primarily used in large-scale infrastructure.

# /etc/nginx/conf.d/ssl-blue-green.conf
# Blue-Green certificate switching via symlinks

# Active certificate (symlink)
# /etc/nginx/ssl/active/ -> /etc/nginx/ssl/blue/ or /etc/nginx/ssl/green/

server {
    listen 443 ssl http2;
    server_name example.com;

    ssl_certificate     /etc/nginx/ssl/active/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/active/privkey.pem;

    # ... other settings
}
#!/bin/bash
# /usr/local/bin/blue-green-cert-switch.sh

ACTIVE_LINK="/etc/nginx/ssl/active"
BLUE_DIR="/etc/nginx/ssl/blue"
GREEN_DIR="/etc/nginx/ssl/green"

# Determine current active slot
current=$(readlink "$ACTIVE_LINK")
if [ "$current" = "$BLUE_DIR" ]; then
    target="$GREEN_DIR"
    target_name="green"
else
    target="$BLUE_DIR"
    target_name="blue"
fi

echo "Current: $current"
echo "Deploying new cert to: $target ($target_name)"

# Deploy new certificate to inactive slot
cp /etc/letsencrypt/live/example.com/fullchain.pem "$target/fullchain.pem"
cp /etc/letsencrypt/live/example.com/privkey.pem "$target/privkey.pem"

# Validate certificate
openssl x509 -in "$target/fullchain.pem" -noout -checkend 86400
if [ $? -ne 0 ]; then
    echo "ERROR: New certificate expires within 24 hours. Aborting."
    exit 1
fi

# Verify key match
CERT_MD5=$(openssl x509 -noout -modulus -in "$target/fullchain.pem" | openssl md5)
KEY_MD5=$(openssl rsa -noout -modulus -in "$target/privkey.pem" 2>/dev/null | openssl md5)
if [ "$CERT_MD5" != "$KEY_MD5" ]; then
    echo "ERROR: Certificate and key do not match. Aborting."
    exit 1
fi

# Atomic symlink switch
ln -sfn "$target" "${ACTIVE_LINK}.new"
mv -T "${ACTIVE_LINK}.new" "$ACTIVE_LINK"

# Nginx reload
nginx -t && systemctl reload nginx
echo "Switched to $target_name slot. Reload complete."

2.4 Dual-Certificate

Nginx 1.11.0+ supports loading both RSA and ECDSA certificates simultaneously. This allows one certificate to maintain service while the other is being renewed.

server {
    listen 443 ssl http2;
    server_name example.com;

    # RSA certificate
    ssl_certificate     /etc/nginx/ssl/rsa/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/rsa/privkey.pem;

    # ECDSA certificate
    ssl_certificate     /etc/nginx/ssl/ecdsa/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/ecdsa/privkey.pem;

    # Nginx automatically selects based on client support
    # ECDSA preferred, RSA fallback for unsupported clients
}

3. Let's Encrypt Auto-Renewal Operations

Reasons to prefer systemd timer over cron:

# /etc/systemd/system/certbot-renewal.service
[Unit]
Description=Certbot SSL Certificate Renewal
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/bin/certbot renew --quiet \
  --pre-hook "/usr/local/bin/cert-pre-hook.sh" \
  --deploy-hook "/usr/local/bin/cert-deploy-hook.sh"
ExecStartPost=/usr/local/bin/cert-renewal-notify.sh
TimeoutStartSec=300
# /etc/systemd/system/certbot-renewal.timer
[Unit]
Description=Run certbot renewal twice daily

[Timer]
OnCalendar=*-*-* 02,14:00:00
RandomizedDelaySec=3600
Persistent=true
AccuracySec=1s

[Install]
WantedBy=timers.target
# Enable timer and check status
sudo systemctl daemon-reload
sudo systemctl enable --now certbot-renewal.timer
sudo systemctl list-timers certbot-renewal.timer

# Manual test (dry-run)
sudo certbot renew --dry-run

# Manual trigger
sudo systemctl start certbot-renewal.service

3.2 Using pre-hook / deploy-hook

Hooks automate tasks before and after renewal. Certbot only executes hooks when a certificate is actually renewed.

#!/bin/bash
# /usr/local/bin/cert-pre-hook.sh
# Executed before renewal

LOG="/var/log/cert-hooks.log"

echo "[$(date '+%Y-%m-%d %H:%M:%S')] PRE-HOOK: Starting renewal process" >> "$LOG"

# Back up current certificate information
for domain_dir in /etc/letsencrypt/live/*/; do
    domain=$(basename "$domain_dir")
    expiry=$(openssl x509 -in "${domain_dir}fullchain.pem" -noout -enddate 2>/dev/null | cut -d= -f2)
    echo "[PRE] $domain expires: $expiry" >> "$LOG"
done
#!/bin/bash
# /usr/local/bin/cert-deploy-hook.sh
# Executed after successful renewal
# Environment variables $RENEWED_DOMAINS and $RENEWED_LINEAGE are available

LOG="/var/log/cert-hooks.log"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] DEPLOY-HOOK: Certificate renewed" >> "$LOG"
echo "  Domains: $RENEWED_DOMAINS" >> "$LOG"
echo "  Lineage: $RENEWED_LINEAGE" >> "$LOG"

# 1. Validate Nginx config then reload
if nginx -t 2>/dev/null; then
    systemctl reload nginx
    echo "  Nginx reloaded successfully" >> "$LOG"
else
    echo "  ERROR: Nginx config test failed!" >> "$LOG"
    curl -s -X POST "$SLACK_WEBHOOK" \
      -H 'Content-type: application/json' \
      -d '{"text":"CRITICAL: Nginx config test failed after cert renewal!"}'
    exit 1
fi

# 2. If HAProxy is running, combine cert and reload
if systemctl is-active haproxy > /dev/null 2>&1; then
    cat "$RENEWED_LINEAGE/fullchain.pem" "$RENEWED_LINEAGE/privkey.pem" \
      > /etc/haproxy/certs/$(basename "$RENEWED_LINEAGE").pem
    systemctl reload haproxy
    echo "  HAProxy reloaded" >> "$LOG"
fi

# 3. Sync certificates to other servers (if needed)
# /usr/local/bin/sync-certs-to-peers.sh "$RENEWED_LINEAGE"
#!/bin/bash
# /usr/local/bin/cert-renewal-notify.sh
# Renewal result notification

SLACK_WEBHOOK="https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
LOG="/var/log/cert-hooks.log"

# Check last renewal result
last_renewal=$(journalctl -u certbot-renewal.service --since "5 minutes ago" --no-pager 2>/dev/null)

if echo "$last_renewal" | grep -q "Congratulations"; then
    # Renewal succeeded
    renewed_domains=$(echo "$last_renewal" | grep "renewed" | head -5)
    curl -s -X POST "$SLACK_WEBHOOK" \
      -H 'Content-type: application/json' \
      -d "{\"text\":\"SSL certificate renewal succeeded\\n${renewed_domains}\"}"
elif echo "$last_renewal" | grep -q "No renewals were attempted"; then
    # No certificates due for renewal (normal)
    echo "[$(date)] No certificates due for renewal" >> "$LOG"
else
    # Renewal failed
    curl -s -X POST "$SLACK_WEBHOOK" \
      -H 'Content-type: application/json' \
      -d '{"text":"WARNING: Please check certbot renewal execution results!"}'
fi

3.3 Automatic Retry on Renewal Failure

Certbot does not have built-in retry logic. Use systemd features to implement it.

# Add to /etc/systemd/system/certbot-renewal.service
[Service]
# Retry after 5 minutes on failure, up to 3 times
Restart=on-failure
RestartSec=300
StartLimitBurst=3
StartLimitIntervalSec=3600

4. Certificate Expiry Monitoring

4.1 Prometheus + ssl_exporter

ssl_exporter exposes TLS certificate expiry times as Prometheus metrics.

# Install ssl_exporter
wget https://github.com/ribbybibby/ssl_exporter/releases/download/v2.4.3/ssl_exporter-2.4.3.linux-amd64.tar.gz
tar xzf ssl_exporter-2.4.3.linux-amd64.tar.gz
sudo mv ssl_exporter-2.4.3.linux-amd64/ssl_exporter /usr/local/bin/

# systemd service
sudo tee /etc/systemd/system/ssl-exporter.service << 'EOF'
[Unit]
Description=SSL Certificate Exporter
After=network-online.target

[Service]
ExecStart=/usr/local/bin/ssl_exporter
Restart=on-failure
User=ssl-exporter

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl enable --now ssl-exporter
# Prometheus scrape config
# /etc/prometheus/prometheus.yml

scrape_configs:
  - job_name: 'ssl'
    metrics_path: /probe
    static_configs:
      - targets:
          - example.com:443
          - api.example.com:443
          - admin.example.com:443
          - staging.example.com:443
    relabel_configs:
      - source_labels: [__address__]
        target_label: __param_target
      - source_labels: [__param_target]
        target_label: instance
      - target_label: __address__
        replacement: ssl-exporter:9219 # ssl_exporter address

Key metrics:

# Calculate days until certificate expiry
(ssl_cert_not_after - time()) / 86400

# Find certificates expiring within 30 days
(ssl_cert_not_after - time()) / 86400 < 30

# Find certificates expiring within 7 days
(ssl_cert_not_after - time()) / 86400 < 7

4.2 Alertmanager Alert Rules

# /etc/prometheus/rules/ssl-alerts.yml
groups:
  - name: ssl_certificate_alerts
    rules:
      # Expiring within 30 days - Warning
      - alert: SSLCertExpiringSoon
        expr: (ssl_cert_not_after - time()) / 86400 < 30
        for: 1h
        labels:
          severity: warning
        annotations:
          summary: 'SSL certificate expiring soon ({{ $labels.instance }})'
          description: '{{ $labels.instance }} certificate expires in {{ $value | printf "%.0f" }} days.'
          runbook_url: 'https://wiki.internal/runbooks/ssl-renewal'

      # Expiring within 7 days - Critical
      - alert: SSLCertExpiryCritical
        expr: (ssl_cert_not_after - time()) / 86400 < 7
        for: 10m
        labels:
          severity: critical
          team: platform
        annotations:
          summary: 'SSL certificate expiry urgent ({{ $labels.instance }})'
          description: '{{ $labels.instance }} certificate expires in {{ $value | printf "%.0f" }} days. Immediate action required.'
          runbook_url: 'https://wiki.internal/runbooks/ssl-emergency-renewal'

      # Already expired certificate
      - alert: SSLCertExpired
        expr: (ssl_cert_not_after - time()) < 0
        for: 0m
        labels:
          severity: critical
          escalation: pagerduty
        annotations:
          summary: 'SSL certificate expired ({{ $labels.instance }})'
          description: '{{ $labels.instance }} certificate has expired! Service outage may occur.'

      # Probe failure (connection failed)
      - alert: SSLProbeFailure
        expr: ssl_probe_success == 0
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: 'SSL probe failed ({{ $labels.instance }})'
          description: 'Cannot establish TLS connection to {{ $labels.instance }}.'
# Alertmanager routing config
# /etc/alertmanager/alertmanager.yml

route:
  group_by: ['alertname', 'instance']
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  receiver: 'slack-warning'
  routes:
    - match:
        severity: critical
        escalation: pagerduty
      receiver: 'pagerduty-critical'
      repeat_interval: 30m
    - match:
        severity: critical
      receiver: 'slack-critical'
      repeat_interval: 1h

receivers:
  - name: 'slack-warning'
    slack_configs:
      - api_url: 'https://hooks.slack.com/services/YOUR/WEBHOOK/URL'
        channel: '#ssl-alerts'
        title: '{{ .CommonAnnotations.summary }}'
        text: '{{ .CommonAnnotations.description }}'

  - name: 'slack-critical'
    slack_configs:
      - api_url: 'https://hooks.slack.com/services/YOUR/WEBHOOK/URL'
        channel: '#incident'
        title: '{{ .CommonAnnotations.summary }}'
        text: '{{ .CommonAnnotations.description }}'

  - name: 'pagerduty-critical'
    pagerduty_configs:
      - service_key: 'YOUR_PAGERDUTY_SERVICE_KEY'
        severity: critical

4.3 Grafana Dashboard

{
  "title": "SSL Certificate Dashboard",
  "panels": [
    {
      "title": "Days Until Certificate Expiry",
      "type": "table",
      "targets": [
        {
          "expr": "sort_desc((ssl_cert_not_after - time()) / 86400)",
          "legendFormat": "{{ instance }}"
        }
      ]
    },
    {
      "title": "Certificates Expiring Soon (within 30 days)",
      "type": "stat",
      "targets": [
        {
          "expr": "count((ssl_cert_not_after - time()) / 86400 < 30)"
        }
      ],
      "thresholds": [
        { "value": 0, "color": "green" },
        { "value": 1, "color": "orange" },
        { "value": 3, "color": "red" }
      ]
    }
  ]
}

4.4 Standalone Monitoring Script (Without Prometheus)

For environments without Prometheus infrastructure, a shell script can serve as an alternative.

#!/bin/bash
# /usr/local/bin/ssl-expiry-check.sh
# Certificate expiry monitoring + Slack/Email alerts

set -euo pipefail

DOMAINS=(
    "example.com"
    "api.example.com"
    "admin.example.com"
    "staging.example.com"
)

WARNING_DAYS=30
CRITICAL_DAYS=7
SLACK_WEBHOOK="${SLACK_WEBHOOK:-}"
ALERT_EMAIL="ops-team@example.com"

check_cert() {
    local domain=$1
    local port=${2:-443}

    # Get certificate expiry date
    local expiry_date
    expiry_date=$(echo | timeout 10 openssl s_client \
        -servername "$domain" \
        -connect "${domain}:${port}" 2>/dev/null \
        | openssl x509 -noout -enddate 2>/dev/null \
        | cut -d= -f2)

    if [ -z "$expiry_date" ]; then
        echo "UNKNOWN|${domain}|Connection failed"
        return
    fi

    # Calculate remaining days (Linux/macOS compatible)
    local expiry_epoch days_left
    if date --version >/dev/null 2>&1; then
        # GNU date (Linux)
        expiry_epoch=$(date -d "$expiry_date" +%s)
    else
        # BSD date (macOS)
        expiry_epoch=$(date -j -f "%b %d %T %Y %Z" "$expiry_date" +%s)
    fi
    days_left=$(( (expiry_epoch - $(date +%s)) / 86400 ))

    if [ "$days_left" -lt 0 ]; then
        echo "EXPIRED|${domain}|${days_left}|${expiry_date}"
    elif [ "$days_left" -lt "$CRITICAL_DAYS" ]; then
        echo "CRITICAL|${domain}|${days_left}|${expiry_date}"
    elif [ "$days_left" -lt "$WARNING_DAYS" ]; then
        echo "WARNING|${domain}|${days_left}|${expiry_date}"
    else
        echo "OK|${domain}|${days_left}|${expiry_date}"
    fi
}

# Check all domains
alerts=""
for domain in "${DOMAINS[@]}"; do
    result=$(check_cert "$domain")
    status=$(echo "$result" | cut -d'|' -f1)
    days=$(echo "$result" | cut -d'|' -f3)

    case $status in
        OK)
            printf "%-30s %-10s %s days\n" "$domain" "[OK]" "$days"
            ;;
        WARNING)
            printf "%-30s %-10s %s days\n" "$domain" "[WARNING]" "$days"
            alerts="${alerts}WARNING: ${domain} - ${days} days remaining\n"
            ;;
        CRITICAL|EXPIRED)
            printf "%-30s %-10s %s days\n" "$domain" "[$status]" "$days"
            alerts="${alerts}${status}: ${domain} - ${days} days remaining\n"
            ;;
        UNKNOWN)
            printf "%-30s %-10s\n" "$domain" "[UNKNOWN]"
            alerts="${alerts}UNKNOWN: ${domain} - Connection failed\n"
            ;;
    esac
done

# Send alerts
if [ -n "$alerts" ]; then
    if [ -n "$SLACK_WEBHOOK" ]; then
        curl -s -X POST "$SLACK_WEBHOOK" \
          -H 'Content-type: application/json' \
          -d "{\"text\":\"SSL Certificate Check Results:\\n${alerts}\"}"
    fi

    # Email alert (requires mailutils)
    echo -e "$alerts" | mail -s "[SSL Alert] Certificate Expiry Warning" "$ALERT_EMAIL" 2>/dev/null || true
fi
# Register in cron (daily at 9 AM)
echo "0 9 * * * /usr/local/bin/ssl-expiry-check.sh >> /var/log/ssl-check.log 2>&1" | sudo crontab -

5. Incident Response Playbook

5.1 Response Flow for Certificate Expiry Incidents

┌─────────────────────────────────────────────────────────────┐
Certificate Expiry Incident Response Flow├─────────────────────────────────────────────────────────────┤
│                                                             │
1. Detection & Confirmation (0-5 min)│     └→ Assess blast radius, list affected domains           │
│                                                             │
2. Immediate Mitigation (5-15 min)│     └→ Apply temporary certificate or reroute traffic       │
│                                                             │
3. Formal Certificate Renewal (15-30 min)│     └→ certbot renewal or emergency issuance                │
│                                                             │
4. Service Verification (30-45 min)│     └→ Confirm TLS connectivity on all endpoints            │
│                                                             │
5. Postmortem (within 48 hours)│     └→ Root cause analysis, preventive measures             │
│                                                             │
└─────────────────────────────────────────────────────────────┘

5.2 Detailed Step-by-Step Response

Step 1: Detection and Confirmation (0-5 min)

# 1-1. Immediately check expiry status
echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null \
  | openssl x509 -noout -dates

# 1-2. Check multiple domains at once
for domain in example.com api.example.com admin.example.com; do
    echo -n "$domain: "
    echo | openssl s_client -servername "$domain" -connect "${domain}:443" 2>/dev/null \
      | openssl x509 -noout -enddate 2>/dev/null || echo "CONNECTION FAILED"
done

# 1-3. Check local certificate files
for cert in /etc/letsencrypt/live/*/fullchain.pem; do
    domain=$(basename $(dirname "$cert"))
    expiry=$(openssl x509 -in "$cert" -noout -enddate | cut -d= -f2)
    echo "$domain: $expiry"
done

# 1-4. Share situation in incident channel
# "SSL certificate expiry confirmed. Blast radius: example.com, api.example.com. Response initiated."

Step 2: Immediate Mitigation (5-15 min)

# 2-1. Force renew Let's Encrypt certificate
sudo certbot renew --force-renewal --cert-name example.com
sudo systemctl reload nginx

# 2-2. If renewal fails - emergency issuance via standalone
sudo systemctl stop nginx
sudo certbot certonly --standalone -d example.com -d "*.example.com"
sudo systemctl start nginx

# 2-3. If hitting Let's Encrypt rate limit - temporary self-signed cert
openssl req -x509 -nodes -days 1 -newkey rsa:2048 \
  -keyout /tmp/emergency.key \
  -out /tmp/emergency.crt \
  -subj "/CN=example.com"

# Note: Self-signed certs show browser warnings but can serve
# as a temporary measure for API servers or internal communication

# 2-4. For AWS environments using ACM certificates
# ACM auto-renews, so the issue is usually on the ALB/CloudFront side
aws elbv2 describe-listeners --load-balancer-arn $ALB_ARN \
  --query 'Listeners[].Certificates[].CertificateArn'

# Check ACM certificate status
aws acm describe-certificate --certificate-arn $CERT_ARN \
  --query 'Certificate.{Status:Status,NotAfter:NotAfter}'

Step 3: Formal Certificate Renewal (15-30 min)

# 3-1. Validate renewed certificate chain
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt \
  /etc/letsencrypt/live/example.com/fullchain.pem

# 3-2. Verify certificate and key match
diff <(openssl x509 -noout -modulus -in /etc/letsencrypt/live/example.com/fullchain.pem | openssl md5) \
     <(openssl rsa -noout -modulus -in /etc/letsencrypt/live/example.com/privkey.pem | openssl md5)

# 3-3. Deploy to all servers
/usr/local/bin/deploy-cert.sh

Step 4: Service Verification (30-45 min)

# 4-1. TLS connection test
curl -vI https://example.com 2>&1 | grep -E "SSL|expire|subject"

# 4-2. Full endpoint check
for url in https://example.com https://api.example.com/healthz https://admin.example.com; do
    status=$(curl -s -o /dev/null -w "%{http_code}" "$url")
    echo "$url: HTTP $status"
done

# 4-3. External verification (SSL Labs)
echo "Check: https://www.ssllabs.com/ssltest/analyze.html?d=example.com"

Step 5: Postmortem (within 48 hours)

Items to include in the postmortem:

## Incident Postmortem: SSL Certificate Expiry

### Timeline

- HH:MM - Initial alert received (source: Prometheus/user report)
- HH:MM - Incident confirmed, response started
- HH:MM - Certificate renewal completed
- HH:MM - Service confirmed normal

### Blast Radius

- Affected domains: example.com, api.example.com
- Duration: XX minutes
- Affected users: approximately N

### Root Cause

- (e.g.) certbot timer was disabled, so auto-renewal was not running
- (e.g.) DNS validation kept failing but no failure alerts were configured

### Preventive Measures

- [ ] Add monitoring for auto-renewal timer status
- [ ] Set up immediate alerts on renewal failure
- [ ] Confirm 30-day warning alerts are configured

6. Multi-Environment Certificate Management

6.1 Strategy by Environment

EnvironmentCertificate TypeCARenewal CycleNotes
devSelf-signed or mkcertSelfN/ABrowser warnings acceptable
stagingLet's Encrypt (staging)LE Staging90 daysNo rate limits
prodLet's Encrypt or ACMPublic CA90 days / autoZero-downtime required

6.2 Development: Using mkcert

For local development environments, use mkcert to create locally-trusted certificates.

# Install mkcert (macOS)
brew install mkcert
mkcert -install  # Add local CA to system certificate store

# Create development certificates
mkcert "*.dev.example.com" localhost 127.0.0.1 ::1

# Output files
# _wildcard.dev.example.com+3.pem (certificate)
# _wildcard.dev.example.com+3-key.pem (key)

6.3 Staging: Using Let's Encrypt Staging

Use Let's Encrypt's staging server in staging to avoid rate limit issues.

# Issue certificate from staging server (no rate limits, not browser-trusted)
sudo certbot certonly --staging \
  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  -d "*.staging.example.com"

# Use -k (insecure) flag for API testing
curl -k https://staging.example.com/api/healthz

6.4 Wildcard Strategy

example.comSingle domain + wildcard
├── www.example.comCovered by *.example.com
├── api.example.comCovered by *.example.com
├── admin.example.comCovered by *.example.com
├── staging.example.comSeparate certificate (staging env)
│   ├── api.staging.example.comCovered by *.staging.example.com
│   └── admin.staging.example.comCovered by *.staging.example.com
└── internal.example.comInternal only (consider mTLS)
# Production wildcard certificate (apex + wildcard)
sudo certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  -d "example.com" \
  -d "*.example.com" \
  --cert-name prod-wildcard

# Staging wildcard certificate (separate)
sudo certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  -d "staging.example.com" \
  -d "*.staging.example.com" \
  --cert-name staging-wildcard

6.5 Kubernetes: cert-manager

In Kubernetes environments, manage certificates declaratively with cert-manager.

# cert-manager ClusterIssuer (Let's Encrypt)
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: admin@example.com
    privateKeySecretRef:
      name: letsencrypt-prod-account-key
    solvers:
      - dns01:
          cloudflare:
            email: admin@example.com
            apiTokenSecretRef:
              name: cloudflare-api-token
              key: api-token
# Certificate resource
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: example-com-tls
  namespace: istio-system
spec:
  secretName: example-com-tls
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
  dnsNames:
    - example.com
    - '*.example.com'
  # Auto-renewal: 30 days before expiry
  renewBefore: 720h # 30 days
# Ingress with automatic certificate issuance
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: example-ingress
  annotations:
    cert-manager.io/cluster-issuer: 'letsencrypt-prod'
spec:
  tls:
    - hosts:
        - example.com
        - api.example.com
      secretName: example-com-tls
  rules:
    - host: example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: web
                port:
                  number: 80

Useful cert-manager monitoring commands:

# Check certificate status
kubectl get certificates -A
kubectl describe certificate example-com-tls -n istio-system

# Check certificate events
kubectl get events --field-selector reason=IssueError -A

# cert-manager logs
kubectl logs -n cert-manager deploy/cert-manager -f

7. Operational Checklists

Certificate Issuance Checklist

Auto-Renewal Checklist

Monitoring Checklist

Incident Preparedness Checklist

Multi-Environment Checklist


8. Conclusion

Here are the core principles of certificate operations:

1. Manual renewal will inevitably fail. The reason Let's Encrypt adopted a 90-day validity period is to force automation. Fully automate renewal with certbot + systemd timer, cert-manager, or equivalent.

2. Automation alone is not enough. Auto-renewal can fail. DNS API token expiry, disk space exhaustion, CA server outages -- failure causes are diverse. You must build monitoring alongside automation.

3. Incidents will happen. To avoid panic when an expiry incident occurs, write runbooks in advance and practice regularly. Recovery time is proportional to preparedness.

4. Manage certificates as an inventory. As domains grow, it becomes harder to track "what certificate is where and when does it expire." Document your certificate list and register every one in your monitoring targets without exception.

Build your own certificate operations system based on the checklists and scripts in this playbook. Once properly established, you can free yourself from certificate expiry incidents for good.

Quiz

Q1: What is the main topic covered in "SSL Certificate Operations Playbook: Zero-Downtime Renewal and Expiry Prevention"?

A comprehensive operations playbook covering the entire certificate lifecycle (issuance, deployment, monitoring, renewal, revocation).

Q2: What is Certificate Lifecycle Management? Certificate operations is not simply "issue and renew." It requires systematic lifecycle management. 1.1 Issuance Key decisions at the issuance stage: ECDSA is recommended because it has a smaller key size compared to RSA 2048 (256-bit vs.

Q3: Explain the core concept of Zero-Downtime Renewal Strategies. The main causes of service disruption during certificate renewal are: Restarting (not reloading) the web server during renewal Time gap between deploying the new certificate and load balancer propagation Clients holding TLS session caches referencing the old certificate 2.1 Nginx...

Q4: What are the key aspects of Let's Encrypt Auto-Renewal Operations? 3.1 systemd Timer-Based Renewal (Recommended) Reasons to prefer systemd timer over cron: RandomizedDelaySec distributes load on the CA server Persistent=true compensates for missed runs after boot systemctl list-timers shows next scheduled execution Logs are integrated via journa...

Q5: How does Certificate Expiry Monitoring work? 4.1 Prometheus + ssl_exporter ssl_exporter exposes TLS certificate expiry times as Prometheus metrics. Key metrics: ssl_cert_not_after: Certificate expiry time (Unix timestamp) ssl_cert_not_before: Certificate issuance time ssl_tls_version_info: TLS version information ssl_ocsp_r...

Comments

No comments yet.

Sign in to leave a comment