Skip to content

Certbot Commands & Certificate Management Guide (2026)

TL;DR: Certbot commands enable Let's Encrypt certificate automation through ACME protocol—this comprehensive reference covers production command patterns, renewal hooks, rate limit avoidance, enterprise fleet management, and preparation for Let's Encrypt's shift to short-lived certificates (6-day lifetimes by 2028).

Overview

Certbot command patterns determine certificate automation reliability in production environments. While official documentation explains command syntax, production operations require understanding failure modes, enterprise deployment patterns, monitoring integration, and the operational implications of Let's Encrypt's evolving certificate lifetime policies. This guide provides the command knowledge needed to build certificate infrastructure that operates reliably at scale.

Production certificate operations face challenges beyond basic command execution: rate limit management across hundreds of domains, automated renewal workflows that handle failures gracefully, certificate distribution to load-balanced fleets, integration with secret management systems, and monitoring that detects problems before outages occur. Understanding Certbot's complete command surface—including lesser-known flags, hook mechanisms, and diagnostic capabilities—enables operations teams to implement robust certificate automation.

The certificate automation landscape is shifting dramatically with Let's Encrypt's planned lifetime reductions: 47-day certificates in February 2027 and 6-day certificates in February 2028. These changes make manual certificate management impossible and require fully automated renewal workflows. This guide emphasizes command patterns that work in the short-lived certificate future, not just today's 90-day world.

What's Changed: The 2025–2028 Let's Encrypt Timeline

Before diving into commands, understand the ground shifting beneath certificate operations:

Date Change Impact
Now No TLS Client Auth EKU in classic profile Use --preferred-profile tlsclient if you need client auth certificates (deprecated May 13, 2026). If using client auth (e.g., mTLS for SMTP), migrate to dedicated client certs ASAP—public CAs like LE are phasing this out industry-wide by mid-2026.
Jan 15, 2026 6-day certs + IP addresses available (shortlived profile) Opt-in for ultra-short testing; automation mandatory
May 13, 2026 Opt-in 45-day certs (tlsserver profile) Early adopters/test automation
Feb 2027 Default to 64 days (classic profile Renewal window ~21 days; manual processes break
Feb 2028 Default to 45 days (classic profile) Renewal window ~15 days; daily automation norm

LE is shortening faster than industry max (47 days by Mar 2029). Prep for 45-day as the 2028 default.

The operational reality: If your renewal process involves a human touching anything, you have less than a year to fix it. This guide assumes you're building for the 6-day certificate world.

ACME Protocol: What's Actually Happening

Certbot implements the ACME protocol (RFC 8555). Understanding the protocol—not just the CLI—prevents debugging in the dark and enables effective troubleshooting.

The ACME Flow

1. Client → CA:    "I want a cert for example.com"         (newOrder)
2. CA → Client:    "Prove you control it. Here's a token."  (authorization + challenge)
3. Client:          Places proof (HTTP file or DNS record)
4. Client → CA:    "Check it now."                          (respond to challenge)
5. CA → Client:    "Verified. Here's your cert."            (finalize + download)

Challenge Types: When to Use What

Challenge Mechanism Ports Required Wildcard Support Best For
HTTP-01 Token file at /.well-known/acme-challenge/{token} 80 inbound No Single servers, simple setups
DNS-01 TXT record at _acme-challenge.{domain} None Yes Wildcards, non-web servers, firewalled environments
TLS-ALPN-01 TLS negotiation on port 443 443 inbound No When port 80 unavailable; niche use

Decision framework: - Need *.example.com? → DNS-01, no alternative - Port 80 open and single server? → HTTP-01, simplest path - Behind a CDN/load balancer that terminates TLS? → DNS-01 (HTTP-01 often fails due to redirect loops) - Internal-only server, no public DNS? → DNS-01 with split-horizon or TLS-ALPN-01

Installation

Snap Install (Required for Certbot 5.x)

# Install snapd if not present
sudo apt update && sudo apt install -y snapd
sudo snap install core && sudo snap refresh core

# Remove any apt-installed Certbot (critical — mixing causes plugin conflicts)
sudo apt remove -y certbot python3-certbot-* 2>/dev/null

# Install Certbot via snap
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/bin/certbot

# Verify installation
certbot --version
# Expected: certbot 5.x.x

Why snap, not apt? Ubuntu/Debian apt repositories freeze Certbot at old versions with outdated ACME libraries. Snap provides automatic updates, which matters when Let's Encrypt changes server-side behavior (and they do, regularly).

DNS Plugin Installation

Plugins must also come from snap to avoid Python environment conflicts:

# Cloudflare (most common)
sudo snap install certbot-dns-cloudflare

# AWS Route 53
sudo snap install certbot-dns-route53

# Google Cloud DNS
sudo snap install certbot-dns-google

# Other available plugins:
# certbot-dns-digitalocean, certbot-dns-linode, certbot-dns-ovh,
# certbot-dns-rfc2136 (for BIND/PowerDNS), certbot-dns-azure

Common trap: Installing a plugin via pip when Certbot is via snap (or vice versa) produces "Plugin not found" errors. Always match the installation method.

Core Commands

certbot certificates — Inventory

# List all managed certificates
sudo certbot certificates

# Output includes:
#   Certificate Name, Domains, Expiry Date, Certificate Path, Private Key Path
#   CRITICAL: Check "VALID: X days" — anything under 30 days needs attention

# Filter to a specific certificate
sudo certbot certificates --cert-name example.com

Production use: Parse this output in monitoring scripts to track certificate expiration across infrastructure.

certbot run — Obtain + Install (Web Server Integration)

# Nginx (auto-configures server blocks)
sudo certbot run --nginx \
  -d example.com \
  -d www.example.com \
  --agree-tos \
  --email [email protected] \
  --redirect \
  --hsts \
  --staple-ocsp

# Apache
sudo certbot run --apache \
  -d example.com \
  -d www.example.com \
  --agree-tos \
  --email [email protected]

Flag explanations:

  • --redirect: Adds permanent 301 redirect from HTTP to HTTPS in web server config
  • --hsts: Adds Strict-Transport-Security header (hard to undo once deployed)
  • --staple-ocsp: Enables OCSP stapling for faster validation and improved privacy

certbot certonly — Obtain Without Installing

Use when you manage web server configuration yourself, or for non-web services (mail servers, databases, MQTT brokers).

Webroot mode (web server stays running):

sudo certbot certonly --webroot \
  -w /var/www/html \
  -d example.com \
  -d www.example.com

Standalone mode (Certbot runs its own temporary server — port 80 must be free):

# Stop your web server first
sudo systemctl stop nginx
sudo certbot certonly --standalone -d example.com
sudo systemctl start nginx

DNS plugin mode (production wildcard pattern):

sudo certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  --dns-cloudflare-propagation-seconds 30 \
  -d "*.example.com" \
  -d example.com \
  --key-type ecdsa \
  --elliptic-curve secp384r1

Manual DNS mode (one-off/learning only — not automatable):

sudo certbot certonly --manual \
  --preferred-challenges dns \
  -d "*.example.com" \
  -d example.com

# Certbot will prompt you to create a TXT record
# Verify propagation before pressing Enter:
dig TXT _acme-challenge.example.com @8.8.8.8 +short

New in Certbot 5.3.0: IP Address Support

Add IP addresses as SANs (supported by Let's Encrypt since Jan 15, 2026). Use multiple --ip-address flags for multiple IPs:

sudo certbot certonly --standalone \
  -d example.com \
  --ip-address 192.0.2.1 \
  --ip-address 2001:db8::1

certbot renew — Renewal

# Dry run (always test first)
sudo certbot renew --dry-run -v

# Actual renewal (renews all certificates within renewal window)
sudo certbot renew

# Force a specific certificate to renew now (regardless of expiry)
sudo certbot renew --cert-name example.com --force-renewal

# Renew and allow partial success (if one domain fails, others proceed)
sudo certbot renew --allow-subset-of-names

Renewal window: Certbot renews when a certificate has less than 1/3 of its lifetime remaining. For 90-day certificates, that's ~30 days before expiry. For 47-day certificates (coming Feb 2027), that's ~15 days. For 6-day certificates (coming Feb 2028), that's ~2 days.

certbot revoke — Revocation

# Revoke by certificate path (most common)
sudo certbot revoke \
  --cert-path /etc/letsencrypt/live/example.com/fullchain.pem \
  --reason keycompromise

# Revoke by certificate name
sudo certbot revoke --cert-name example.com --reason keycompromise

# Valid reasons: unspecified, keycompromise, affiliationchanged,
#                superseded, cessationofoperation

After revocation: Always reissue immediately, then delete the old certificate:

sudo certbot delete --cert-name example.com
# Then re-obtain with certonly or run

certbot delete — Remove Managed Certificate

sudo certbot delete --cert-name old-domain.com
# Removes from /etc/letsencrypt/ entirely — renewal config, archive, live symlinks

File System Layout

Understanding where Certbot stores files prevents half of all debugging sessions:

/etc/letsencrypt/
├── accounts/          # ACME account keys (one per CA server)
│   └── acme-v02.api.letsencrypt.org/
├── archive/           # Actual cert files (numbered: cert1.pem, cert2.pem...)
│   └── example.com/
│       ├── cert1.pem       # Current certificate
│       ├── chain1.pem      # Intermediate CA chain
│       ├── fullchain1.pem  # cert + chain (what most servers want)
│       └── privkey1.pem    # Private key
├── live/              # Symlinks → latest files in archive/
│   └── example.com/
│       ├── cert.pem → ../../archive/example.com/cert1.pem
│       ├── chain.pem → ...
│       ├── fullchain.pem → ...
│       ├── privkey.pem → ...
│       └── README
├── renewal/           # Renewal configuration (one .conf per cert)
│   └── example.com.conf
├── renewal-hooks/     # Scripts executed during renewal
│   ├── pre/           # Before renewal attempt
│   ├── deploy/        # After successful renewal
│   └── post/          # After renewal attempt (success or failure)
└── options-ssl-*.conf # SSL/TLS configuration templates

Key rules:

  • Always reference live/ paths in your server configs (they auto-update via symlinks)
  • Never manually edit files in archive/ — Certbot manages numbering
  • Back up accounts/ and renewal/ — losing these means re-registering and reconfiguring

Renewal Hooks: Production Patterns

Hooks transform Certbot from a certificate tool into a deployment automation system. Place executable scripts in the appropriate directory under /etc/letsencrypt/renewal-hooks/.

Available Environment Variables

Variable Available In Contains
$RENEWED_DOMAINS deploy/ Space-separated list of renewed domains
$RENEWED_LINEAGE deploy/ Path to renewed cert (e.g., /etc/letsencrypt/live/example.com)
$FAILED_DOMAINS deploy/ For failure handling (post-5.x)

Pattern 1: Web Server Reload

#!/bin/bash
# /etc/letsencrypt/renewal-hooks/deploy/01-reload-nginx.sh
set -euo pipefail

# Validate config before reloading (prevents taking down the server)
if nginx -t 2>/dev/null; then
    systemctl reload nginx
    echo "[$(date)] Nginx reloaded for: $RENEWED_DOMAINS"
else
    echo "[$(date)] ERROR: Nginx config test failed after cert renewal" >&2
    # Send alert via monitoring system
    exit 1
fi

Pattern 2: Distribute to Multiple Services

#!/bin/bash
# /etc/letsencrypt/renewal-hooks/deploy/02-distribute-certs.sh
set -euo pipefail

CERT_DIR="$RENEWED_LINEAGE"

# Copy to Postfix mail server
cp "$CERT_DIR/fullchain.pem" /etc/postfix/tls/server.pem
cp "$CERT_DIR/privkey.pem" /etc/postfix/tls/server.key
chmod 600 /etc/postfix/tls/server.key
systemctl reload postfix

# Copy to HAProxy (requires combined format)
cat "$CERT_DIR/fullchain.pem" "$CERT_DIR/privkey.pem" \
    > /etc/haproxy/certs/combined.pem
chmod 600 /etc/haproxy/certs/combined.pem
systemctl reload haproxy

# Copy to Docker container
docker cp "$CERT_DIR/fullchain.pem" myapp:/app/certs/
docker cp "$CERT_DIR/privkey.pem" myapp:/app/certs/
docker exec myapp nginx -s reload

echo "[$(date)] Certificates distributed for: $RENEWED_DOMAINS"

Pattern 3: Push to AWS (ACM / S3)

#!/bin/bash
# /etc/letsencrypt/renewal-hooks/deploy/03-push-to-aws.sh
set -euo pipefail

CERT="$RENEWED_LINEAGE/cert.pem"
CHAIN="$RENEWED_LINEAGE/chain.pem"
KEY="$RENEWED_LINEAGE/privkey.pem"
DOMAIN=$(echo "$RENEWED_DOMAINS" | awk '{print $1}')

# Upload to ACM (for CloudFront/ALB)
aws acm import-certificate \
    --certificate fileb://"$CERT" \
    --certificate-chain fileb://"$CHAIN" \
    --private-key fileb://"$KEY" \
    --region us-east-1 \
    --tags Key=ManagedBy,Value=certbot Key=Domain,Value="$DOMAIN"

# Backup to S3 with encryption
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
aws s3 cp "$RENEWED_LINEAGE/" \
    "s3://my-cert-backup/$DOMAIN/$TIMESTAMP/" \
    --recursive \
    --sse AES256

echo "[$(date)] Pushed $DOMAIN to ACM and backed up to S3"

Pattern 4: Notify on Renewal

#!/bin/bash
# /etc/letsencrypt/renewal-hooks/deploy/99-notify.sh
set -euo pipefail

DOMAIN=$(echo "$RENEWED_DOMAINS" | awk '{print $1}')
EXPIRY=$(openssl x509 -enddate -noout -in "$RENEWED_LINEAGE/cert.pem" | cut -d= -f2)

# Slack notification
curl -s -X POST "$SLACK_WEBHOOK_URL" \
    -H 'Content-Type: application/json' \
    -d "{
        \"text\": \"✅ Certificate renewed: $RENEWED_DOMAINS\nNew expiry: $EXPIRY\"
    }"

# PagerDuty (resolve any open incidents for this certificate)
curl -s -X POST https://events.pagerduty.com/v2/enqueue \
    -H 'Content-Type: application/json' \
    -d "{
        \"routing_key\": \"$PD_ROUTING_KEY\",
        \"event_action\": \"resolve\",
        \"dedup_key\": \"cert-expiry-$RENEWED_DOMAINS\"
    }"

Make all hooks executable:

chmod +x /etc/letsencrypt/renewal-hooks/deploy/*.sh

ACME Account Management

Your ACME account is separate from your certificates. Understanding this prevents confusion during migrations and disaster recovery.

# Register a new account (usually done automatically on first certbot run)
sudo certbot register --email [email protected] --agree-tos

# Update contact email
sudo certbot update_account --email [email protected]

# Show account information
sudo certbot show_account

# Unregister account (careful — this deauthorizes your account with the CA)
sudo certbot unregister

Account key location: /etc/letsencrypt/accounts/acme-v02.api.letsencrypt.org/directory/

Migration: To move Certbot to a new server and maintain the same account, copy the entire /etc/letsencrypt/ directory. The account key in accounts/ must match what Let's Encrypt has on file, or you'll need to re-register.

Rate Limits: What Will Block You

Let's Encrypt enforces rate limits to prevent abuse. Hitting them in production is painful—you can't issue certificates for up to a week.

Limit Value Reset Window
Certificates per Registered Domain 50 per week Rolling 7-day window
Duplicate Certificates 5 per week Rolling 7-day window
Failed Validations 5 per hour per account per hostname Rolling 1-hour window
New Orders 300 per 3 hours per account Rolling 3-hour window
Accounts per IP 10 per 3 hours Rolling 3-hour window
Pending Authorizations 300 per account Cleared on completion

How to Avoid Rate Limit Problems

1. Use staging for testing — always:

# Staging environment — unlimited issuance, but certificates aren't trusted
sudo certbot certonly --staging \
  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  -d "*.example.com" -d example.com

# When satisfied, remove --staging and issue for production

2. Consolidate SANs instead of issuing separate certificates:

# Bad: 5 separate certificates (burns 5 of your 50/week limit)
sudo certbot certonly -d app1.example.com
sudo certbot certonly -d app2.example.com
sudo certbot certonly -d app3.example.com
# ...

# Good: 1 certificate with multiple SANs (burns 1)
sudo certbot certonly \
  -d app1.example.com \
  -d app2.example.com \
  -d app3.example.com \
  -d app4.example.com \
  -d app5.example.com

3. Check your current certificate inventory before issuing:

sudo certbot certificates
# Look for duplicates — delete unnecessary ones
sudo certbot delete --cert-name duplicate-cert

CAA Records: Controlling Who Can Issue Certificates

Certificate Authority Authorization (CAA) DNS records specify which CAs are permitted to issue certificates for your domain. Without them, any CA can issue—this is how misissued certificates happen.

# Check current CAA records
dig CAA example.com +short

# Example CAA configurations (set in your DNS provider):

# Allow only Let's Encrypt
example.com.  IN  CAA  0 issue "letsencrypt.org"

# Allow Let's Encrypt + one other CA
example.com.  IN  CAA  0 issue "letsencrypt.org"
example.com.  IN  CAA  0 issue "sectigo.com"

# Allow wildcards only from Let's Encrypt
example.com.  IN  CAA  0 issuewild "letsencrypt.org"

# Send violation reports to security team
example.com.  IN  CAA  0 iodef "mailto:[email protected]"

Why this matters: If you set CAA records and then try to issue from a different CA (or Let's Encrypt issues from a different domain than listed), issuance fails. Always verify CAA records before troubleshooting mysterious issuance failures.

Certificate Transparency Monitoring

Every publicly trusted certificate is logged to Certificate Transparency (CT) logs. Monitor these to detect unauthorized issuance.

# Check CT logs for your domain (via crt.sh)
curl -s "https://crt.sh/?q=%25.example.com&output=json" | \
    python3 -c "
import json, sys
for cert in json.load(sys.stdin):
    print(f\"{cert['not_before']}  {cert['common_name']}  issuer={cert['issuer_name']}\")
" | sort -r | head -20

Automated monitoring options:

  • crt.sh RSS/Atom feeds: Subscribe to https://crt.sh/atom?q=%25.example.com
  • Facebook Certificate Transparency Monitoring: Free service at developers.facebook.com
  • Certspotter (sslmate.com): Free for one domain, alerts on new issuance
  • Build your own: Poll the crt.sh JSON API hourly and alert on unexpected issuers

Troubleshooting

Common Errors and Solutions

Error Root Cause Diagnostic Steps Fix
Invalid response from /.well-known/acme-challenge/ HTTP-01 validation failure curl -v http://example.com/.well-known/acme-challenge/test Ensure port 80 open; verify webroot path; disable HTTPS redirects for /.well-known/
DNS problem: NXDOMAIN looking up TXT for _acme-challenge TXT record missing or not propagated dig TXT _acme-challenge.domain @8.8.8.8 +short Wait for DNS propagation; verify zone; lower TTL to 60s
DNS problem: SERVFAIL looking up TXT Authoritative DNS server failure or DNSSEC issue dig TXT _acme-challenge.domain +dnssec @8.8.8.8 Check DNSSEC chain; verify authoritative nameserver
too many certificates already issued for exact set of domains Duplicate certificate rate limit (5/week) sudo certbot certificates Delete duplicates; wait 7 days; consolidate SANs
too many certificates (50) already issued Registered domain rate limit Review all issuance at crt.sh Wait 7 days; use SANs instead of separate certificates
too many failed authorizations Failed validation rate limit (5/hour) Check /var/log/letsencrypt/letsencrypt.log Fix underlying validation issue; wait 1 hour
Plugin not found or Namespace collision Mixing apt and snap installations which certbot && snap list certbot sudo apt remove certbot python3-certbot-*; reinstall via snap
Could not bind to port 80 Standalone mode but web server occupying port sudo ss -tlnp \| grep :80 Stop web server or use --webroot mode instead
Timeout during connect (likely firewall problem) Let's Encrypt cannot reach your server Check firewall rules for port 80 from external Open port 80 inbound; check cloud security groups
The server experienced an internal error Let's Encrypt server-side issue Check letsencrypt.status.io Retry in 15–30 minutes
Certificate not yet due for renewal Certificate outside renewal window sudo certbot certificates Use --force-renewal if needed early
unauthorized: CAA record prevents issuance CAA DNS record blocks Let's Encrypt dig CAA example.com +short Add 0 issue "letsencrypt.org" CAA record

Debugging Workflow

Renewal fails
    ├─ Check /var/log/letsencrypt/letsencrypt.log
    │      └─ Identify: challenge failure? rate limit? DNS? connectivity?
    ├─ Challenge failure (HTTP-01)?
    │      ├─ curl http://yourdomain/.well-known/acme-challenge/test
    │      ├─ Is port 80 open? (sudo ss -tlnp | grep :80)
    │      ├─ Is redirect sending Let's Encrypt to HTTPS? (curl -v)
    │      └─ Is webroot path correct in renewal config?
    ├─ Challenge failure (DNS-01)?
    │      ├─ dig TXT _acme-challenge.domain @8.8.8.8 +short
    │      ├─ Is API token still valid? (test manual API call)
    │      ├─ Did DNS provider change API? (check plugin changelog)
    │      └─ Is DNSSEC broken? (dig +dnssec)
    ├─ Rate limited?
    │      ├─ Which limit? (log message tells you)
    │      ├─ Check crt.sh for recent issuance
    │      └─ Wait (1hr for failed validations; 7 days for cert limits)
    └─ Everything looks fine but still failing?
           ├─ Run with --dry-run -v for verbose output
           ├─ Check Let's Encrypt status page (letsencrypt.status.io)
           └─ Try --staging to isolate server-side issues

Enterprise Fleet Patterns

Centralized Issuance with Distribution

For organizations managing 50+ servers, running Certbot on every box creates operational complexity. Instead, centralize certificate issuance:

┌─────────────────┐        ┌───────────────┐
│  Certbot Host   │───────→│  Web Server 1 │
│  (bastion/CI)   │───────→│  Web Server 2 │
│                 │───────→│  Web Server 3 │
│  - DNS plugin   │───────→│  Mail Server  │
│  - All renewals │───────→│  API Gateway  │
│  - Monitoring   │        └───────────────┘
└─────────────────┘
         └──→ S3/Vault (backup + secret store)

Ansible Playbook: Renew + Distribute

---
# certbot-renew.yml
- name: Renew certificates on certbot host
  hosts: certbot_host
  become: yes
  tasks:
    - name: Run Certbot renewal
      command: certbot renew --deploy-hook "/opt/scripts/cert-distributed.sh"
      register: renew_result
      changed_when: "'No renewals were attempted' not in renew_result.stdout"

    - name: Check for renewal failures
      fail:
        msg: "Certbot renewal failed: {{ renew_result.stderr }}"
      when: renew_result.rc != 0

- name: Distribute certificates to fleet
  hosts: webservers
  become: yes
  tasks:
    - name: Sync certificate files from certbot host
      synchronize:
        src: "/etc/letsencrypt/live/{{ cert_name }}/"
        dest: "/etc/ssl/certs/{{ cert_name }}/"
        mode: push
        rsync_opts:
          - "--chmod=D750,F640"
      notify: reload nginx

    - name: Set private key permissions
      file:
        path: "/etc/ssl/certs/{{ cert_name }}/privkey.pem"
        mode: '0600'
        owner: root
        group: ssl-cert

  handlers:
    - name: reload nginx
      service:
        name: nginx
        state: reloaded

HashiCorp Vault Integration

For organizations using Vault as a secret store, push renewed certificates there instead of distributing files:

#!/bin/bash
# /etc/letsencrypt/renewal-hooks/deploy/vault-push.sh
set -euo pipefail

DOMAIN=$(echo "$RENEWED_DOMAINS" | awk '{print $1}')
VAULT_PATH="secret/certs/$DOMAIN"

vault kv put "$VAULT_PATH" \
    cert=@"$RENEWED_LINEAGE/cert.pem" \
    chain=@"$RENEWED_LINEAGE/chain.pem" \
    fullchain=@"$RENEWED_LINEAGE/fullchain.pem" \
    privkey=@"$RENEWED_LINEAGE/privkey.pem" \
    renewed_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
    expires="$(openssl x509 -enddate -noout -in "$RENEWED_LINEAGE/cert.pem" | cut -d= -f2)"

echo "[$(date)] Pushed $DOMAIN to Vault at $VAULT_PATH"

Consuming services pull from Vault using native integrations (Vault Agent, Vault CSI driver, etc.).

Kubernetes: cert-manager (Not Certbot)

In Kubernetes, don't use Certbot. Use cert-manager, which is purpose-built for the Kubernetes lifecycle and handles renewal, secret rotation, and multi-issuer scenarios natively.

Installation

# Install cert-manager
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.17.0/cert-manager.yaml

# Verify pods are running
kubectl get pods -n cert-manager
# Wait for all 3 pods: cert-manager, cert-manager-cainjector, cert-manager-webhook

ClusterIssuer Configuration

# letsencrypt-issuer.yaml
---
# Staging (always test first)
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-staging
spec:
  acme:
    server: https://acme-staging-v02.api.letsencrypt.org/directory
    email: [email protected]
    privateKeySecretRef:
      name: letsencrypt-staging-account
    solvers:
      - http01:
          ingress:
            ingressClassName: nginx
---
# Production
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: [email protected]
    privateKeySecretRef:
      name: letsencrypt-prod-account
    solvers:
      # HTTP-01 for standard domains
      - http01:
          ingress:
            ingressClassName: nginx
      # DNS-01 for wildcards (example: Cloudflare)
      - dns01:
          cloudflare:
            email: [email protected]
            apiTokenSecretRef:
              name: cloudflare-api-token
              key: api-token
        selector:
          dnsNames:
            - "*.example.com"

Certificate Request

# example-cert.yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: example-com-tls
  namespace: production
spec:
  secretName: example-com-tls
  duration: 2160h    # 90 days (requested; CA may override)
  renewBefore: 720h  # Renew 30 days before expiry
  privateKey:
    algorithm: ECDSA
    size: 256
  dnsNames:
    - example.com
    - www.example.com
    - "*.example.com"
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer

Ingress Annotation (Simpler Alternative)

# Annotate Ingress directly for automatic certificate provisioning
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: example-ingress
  annotations:
    cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
  tls:
    - hosts:
        - example.com
        - www.example.com
      secretName: example-com-tls
  rules:
    - host: example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: example-service
                port:
                  number: 80

Monitoring cert-manager

# Check certificate status
kubectl get certificates -A

# Detailed certificate information
kubectl describe certificate example-com-tls -n production

# Check certificate requests
kubectl get certificaterequests -A

# Check challenges (if stuck)
kubectl get challenges -A
kubectl describe challenge <challenge-name> -n <namespace>

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

Common failure: "Waiting for HTTP-01 challenge propagation"

  • Ensure your Ingress controller can serve /.well-known/acme-challenge/ paths
  • Verify ingress class matches ClusterIssuer solver configuration

Monitoring & Alerting

Prometheus + Blackbox Exporter

The most robust approach: probe your actual endpoints and alert on certificate expiry.

Blackbox Exporter configuration:

# /etc/blackbox_exporter/config.yml
modules:
  https_cert_check:
    prober: http
    timeout: 10s
    http:
      preferred_ip_protocol: ip4
      tls_config:
        insecure_skip_verify: false

Prometheus scrape configuration:

# prometheus.yml
scrape_configs:
  - job_name: 'ssl-cert-check'
    metrics_path: /probe
    params:
      module: [https_cert_check]
    static_configs:
      - targets:
          - https://example.com
          - https://api.example.com
          - https://app.example.com
    relabel_configs:
      - source_labels: [__address__]
        target_label: __param_target
      - source_labels: [__param_target]
        target_label: instance
      - target_label: __address__
        replacement: blackbox-exporter:9115

Alert rules:

# alerts/ssl-certs.yml
groups:
  - name: ssl-certificate-alerts
    rules:
      - alert: CertExpiringIn30Days
        expr: probe_ssl_earliest_cert_expiry - time() < 86400 * 30
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "Certificate expiring within 30 days"
          description: "{{ $labels.instance }} expires in {{ $value | humanizeDuration }}"

      - alert: CertExpiringIn7Days
        expr: probe_ssl_earliest_cert_expiry - time() < 86400 * 7
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "URGENT: Certificate expiring within 7 days"
          description: "{{ $labels.instance }} expires in {{ $value | humanizeDuration }}"

      - alert: CertExpired
        expr: probe_ssl_earliest_cert_expiry - time() <= 0
        for: 1m
        labels:
          severity: page
        annotations:
          summary: "OUTAGE: Certificate has expired"
          description: "{{ $labels.instance }} certificate is expired"

Simple Cron-Based Monitoring (No Prometheus)

For smaller setups without Prometheus:

#!/bin/bash
# /opt/scripts/check-certs.sh — run via cron every 6 hours
set -euo pipefail

ALERT_DAYS=14
SLACK_WEBHOOK="${SLACK_WEBHOOK_URL:-}"
DOMAINS=("example.com" "api.example.com" "app.example.com")

for domain in "${DOMAINS[@]}"; do
    expiry_epoch=$(echo | openssl s_client -connect "$domain:443" -servername "$domain" 2>/dev/null \
        | openssl x509 -noout -enddate 2>/dev/null \
        | cut -d= -f2 \
        | xargs -I{} date -d {} +%s 2>/dev/null || echo 0)

    if [ "$expiry_epoch" -eq 0 ]; then
        message="⚠️ Cannot check certificate for $domain"
    else
        now=$(date +%s)
        days_left=$(( (expiry_epoch - now) / 86400 ))

        if [ "$days_left" -le 0 ]; then
            message="🔴 EXPIRED: $domain certificate has expired!"
        elif [ "$days_left" -le "$ALERT_DAYS" ]; then
            message="🟡 WARNING: $domain expires in $days_left days"
        else
            continue  # Certificate is fine, skip
        fi
    fi

    echo "$message"

    if [ -n "$SLACK_WEBHOOK" ]; then
        curl -s -X POST "$SLACK_WEBHOOK" \
            -H 'Content-Type: application/json' \
            -d "{\"text\": \"$message\"}"
    fi
done

Cron entry (every 6 hours):

0 */6 * * * /opt/scripts/check-certs.sh >> /var/log/cert-check.log 2>&1

Security Hardening

Key Type and Cipher Selection

# ECDSA P-384 (recommended — strong + fast)
sudo certbot certonly --key-type ecdsa --elliptic-curve secp384r1 -d example.com

# ECDSA P-256 (lighter, perfectly adequate)
sudo certbot certonly --key-type ecdsa --elliptic-curve secp256r1 -d example.com

# RSA 4096 (only if you need compatibility with very old clients)
sudo certbot certonly --key-type rsa --rsa-key-size 4096 -d example.com

Recommendation: Use ECDSA P-256 or P-384. RSA is slower, produces larger handshakes, and offers no security advantage at equivalent strength. The only reason to use RSA is compatibility with clients that don't support ECDSA (increasingly rare in 2026).

Nginx TLS Hardening

# /etc/nginx/snippets/ssl-hardening.conf
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;  # Let client choose (modern best practice)

ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;  # Disable for forward secrecy

ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;

add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;

Usage in server block:

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

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    include /etc/nginx/snippets/ssl-hardening.conf;

    # Application configuration...
}

SSL Configuration Validation

# Quick certificate check
openssl x509 -in /etc/letsencrypt/live/example.com/cert.pem -noout -subject -issuer -dates -ext subjectAltName

# Test live certificate
openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>/dev/null | openssl x509 -noout -text

# SSL Labs scan (comprehensive audit)
# Visit: https://www.ssllabs.com/ssltest/analyze.html?d=example.com

# Programmatic testing (testssl.sh)
git clone --depth 1 https://github.com/drwetter/testssl.sh.git
./testssl.sh/testssl.sh example.com

Disaster Recovery

Backup Strategy

#!/bin/bash
# /opt/scripts/backup-letsencrypt.sh
set -euo pipefail

BACKUP_DIR="/backup/letsencrypt"
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
DEST="$BACKUP_DIR/letsencrypt-$TIMESTAMP.tar.gz"

mkdir -p "$BACKUP_DIR"

# Back up everything except live/ (which contains only symlinks)
tar czf "$DEST" \
    --exclude='/etc/letsencrypt/live' \
    /etc/letsencrypt/

# Encrypt with GPG (never store private keys in plain backups)
gpg --symmetric --cipher-algo AES256 "$DEST"
rm "$DEST"  # Remove unencrypted version

# Retain last 30 backups
ls -t "$BACKUP_DIR"/*.gpg | tail -n +31 | xargs rm -f 2>/dev/null || true

echo "[$(date)] Backup created: ${DEST}.gpg"

Schedule daily backups:

# Cron entry
0 2 * * * /opt/scripts/backup-letsencrypt.sh >> /var/log/letsencrypt-backup.log 2>&1

Recovery Procedure

# 1. Restore from encrypted backup
gpg --decrypt letsencrypt-20260130-020000.tar.gz.gpg | sudo tar xzf - -C /

# 2. Recreate symlinks in live/
sudo certbot certificates
# If certificates show up, symlinks were recreated automatically

# 3. Test renewal to verify everything works
sudo certbot renew --force-renewal --dry-run

# 4. If account key is lost, re-register
sudo certbot register --email [email protected] --agree-tos
# Then re-obtain all certificates

Preparing for Short-Lived Certificates

With 6-day certificates becoming the default by February 2028, your infrastructure must handle daily renewals without human intervention. Here's the readiness checklist:

Automation Readiness Checklist

  • [ ] Renewals are fully automated — no human touches any part of the process
  • [ ] DNS plugin installed (if using DNS-01) — manual challenges impossible at daily frequency
  • [ ] Deploy hooks tested — every downstream service reloads automatically
  • [ ] Monitoring alerts at 50% lifetime — for 6-day certificates, that's 3 days
  • [ ] Cron runs at least twice daily — provides retry window if first attempt fails
  • [ ] Staging tested with --force-renewal — simulates rapid renewal cadence
  • [ ] Backup/restore tested — can recover certificate infrastructure within 1 hour
  • [ ] No hardcoded expiry assumptions — no scripts assume 90-day or 30-day windows
  • [ ] Rate limits understood — 50 certificates/week/domain still applies
  • [ ] CI/CD can deploy certificate changes — if certificates baked into containers, pipelines must handle daily rebuilds

Cron Configuration for Short-Lived Certificates

# Current (for 90-day certificates): twice daily is sufficient
0 3,15 * * * root certbot renew --quiet --deploy-hook "/opt/scripts/deploy-certs.sh"

# For 6-day certificates: run 4x daily with staggered retry
0 */6 * * * root certbot renew --quiet --deploy-hook "/opt/scripts/deploy-certs.sh"

# With failure alerting
0 */6 * * * root certbot renew --quiet --deploy-hook "/opt/scripts/deploy-certs.sh" || /opt/scripts/alert-renewal-failure.sh

Opt-In to Short Lifetimes Early (Available May 2026)

# Test with short-lived certificates before they become default
sudo certbot certonly \
  --preferred-profile shortlived \
  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  -d example.com

Monitor how your infrastructure handles the accelerated renewal cycle before it becomes mandatory.

Alternative ACME Clients

Certbot isn't the only option. For specific use cases, alternatives may be better:

Client Language Best For Key Features
acme.sh Bash Minimal environments, no dependencies Pure bash; 100+ DNS providers
lego Go Single binary deployment, CI/CD Static binary; 80+ DNS providers
cert-manager Go Kubernetes CRD-based; native K8s integration
Caddy Go Simple web serving Built-in ACME; zero-config HTTPS
win-acme C# Windows/IIS Native Windows; IIS integration

Quick Reference Card

# ─── ISSUE CERTIFICATES ──────────────────────────────────
certbot run --nginx -d example.com                    # Auto (nginx)
certbot certonly --webroot -w /var/www -d example.com  # Manual webroot
certbot certonly --dns-cloudflare ... -d *.example.com # Wildcard

# ─── MANAGE CERTIFICATES ─────────────────────────────────
certbot certificates                          # List all
certbot renew --dry-run                       # Test renewal
certbot renew                                 # Renew all due
certbot renew --cert-name X --force-renewal   # Force specific
certbot delete --cert-name X                  # Remove certificate
certbot revoke --cert-path ... --reason X     # Revoke

# ─── VERIFY & DEBUG ──────────────────────────────────────
openssl x509 -in cert.pem -noout -dates       # Check expiry
openssl s_client -connect host:443             # Test live cert
dig CAA example.com +short                     # Check CAA
dig TXT _acme-challenge.domain +short          # Check DNS-01

# ─── EMERGENCY RESPONSE ──────────────────────────────────
certbot revoke --cert-path /path --reason keycompromise
certbot delete --cert-name compromised-cert
certbot certonly ...  # Re-issue immediately