Skip to content

Certbot Commands Reference 2026: certonly, renew, dns-01 & Production Examples

Certbot Commands & Certificate Management Guide (2026)

Section titled “Certbot Commands & Certificate Management Guide (2026)”

Certbot commands are the foundation of automated certificate management in 2026. This complete reference covers every essential command — certonly, renew, dns-01, rate-limiting, dry-run testing and production examples — with real output and troubleshooting tips so you can run reliable ACME automation at scale. Separate sections also cover, enterprise fleet management, and preparation for Let’s Encrypt’s shift to short-lived certificates (6-day lifetimes by 2028).

Need help with ACME? Ask Axel Axelspire AI bot with own augmented memory for all ACME/certbot.

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

Section titled “What’s Changed: The 2025–2028 Let’s Encrypt Timeline”

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

DateChangeImpact
NowNo TLS Client Auth EKU in classic profileUse --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, 20266-day certs + IP addresses available (shortlived profile)Opt-in for ultra-short testing; automation mandatory
May 13, 2026Opt-in 45-day certs (tlsserver profile)Early adopters/test automation
Feb 2027Default to 64 days (classic profileRenewal window ~21 days; manual processes break
Feb 2028Default 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

Section titled “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.

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)
ChallengeMechanismPorts RequiredWildcard SupportBest For
HTTP-01Token file at /.well-known/acme-challenge/{token}80 inboundNoSingle servers, simple setups
DNS-01TXT record at _acme-challenge.{domain}NoneYesWildcards, non-web servers, firewalled environments
TLS-ALPN-01TLS negotiation on port 443443 inboundNoWhen 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
Terminal window
# 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).

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

Terminal window
# 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.

Terminal window
# 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)

Section titled “certbot run — Obtain + Install (Web Server Integration)”
Terminal window
# Nginx (auto-configures server blocks)
sudo certbot run --nginx \
-d example.com \
-d www.example.com \
--agree-tos \
--email security@example.com \
--redirect \
--hsts \
--staple-ocsp
# Apache
sudo certbot run --apache \
-d example.com \
-d www.example.com \
--agree-tos \
--email security@example.com

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

Section titled “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):

Terminal window
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):

Terminal window
# 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):

Terminal window
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):

Terminal window
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:

Terminal window
sudo certbot certonly --standalone \
-d example.com \
--ip-address 192.0.2.1 \
--ip-address 2001:db8::1
Terminal window
# 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.

Terminal window
# 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:

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

certbot delete — Remove Managed Certificate

Section titled “certbot delete — Remove Managed Certificate”
Terminal window
sudo certbot delete --cert-name old-domain.com
# Removes from /etc/letsencrypt/ entirely — renewal config, archive, live symlinks

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

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

VariableAvailable InContains
$RENEWED_DOMAINSdeploy/Space-separated list of renewed domains
$RENEWED_LINEAGEdeploy/Path to renewed cert (e.g., /etc/letsencrypt/live/example.com)
$FAILED_DOMAINSdeploy/For failure handling (post-5.x)
/etc/letsencrypt/renewal-hooks/deploy/01-reload-nginx.sh
#!/bin/bash
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

Section titled “Pattern 2: Distribute to Multiple Services”
/etc/letsencrypt/renewal-hooks/deploy/02-distribute-certs.sh
#!/bin/bash
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"
/etc/letsencrypt/renewal-hooks/deploy/03-push-to-aws.sh
#!/bin/bash
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"
/etc/letsencrypt/renewal-hooks/deploy/99-notify.sh
#!/bin/bash
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:

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

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

Terminal window
# Register a new account (usually done automatically on first certbot run)
sudo certbot register --email security@example.com --agree-tos
# Update contact email
sudo certbot update_account --email new-security@example.com
# 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.

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.

LimitValueReset Window
Certificates per Registered Domain50 per weekRolling 7-day window
Duplicate Certificates5 per weekRolling 7-day window
Failed Validations5 per hour per account per hostnameRolling 1-hour window
New Orders300 per 3 hours per accountRolling 3-hour window
Accounts per IP10 per 3 hoursRolling 3-hour window
Pending Authorizations300 per accountCleared on completion

1. Use staging for testing — always:

Terminal window
# 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:

Terminal window
# 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:

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

CAA Records: Controlling Who Can Issue Certificates

Section titled “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.

Terminal window
# 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:security@example.com"

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.

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

Terminal window
# 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
ErrorRoot CauseDiagnostic StepsFix
Invalid response from /.well-known/acme-challenge/HTTP-01 validation failurecurl -v http://example.com/.well-known/acme-challenge/testEnsure port 80 open; verify webroot path; disable HTTPS redirects for /.well-known/
DNS problem: NXDOMAIN looking up TXT for _acme-challengeTXT record missing or not propagateddig TXT _acme-challenge.domain @8.8.8.8 +shortWait for DNS propagation; verify zone; lower TTL to 60s
DNS problem: SERVFAIL looking up TXTAuthoritative DNS server failure or DNSSEC issuedig TXT _acme-challenge.domain +dnssec @8.8.8.8Check DNSSEC chain; verify authoritative nameserver
too many certificates already issued for exact set of domainsDuplicate certificate rate limit (5/week)sudo certbot certificatesDelete duplicates; wait 7 days; consolidate SANs
too many certificates (50) already issuedRegistered domain rate limitReview all issuance at crt.shWait 7 days; use SANs instead of separate certificates
too many failed authorizationsFailed validation rate limit (5/hour)Check /var/log/letsencrypt/letsencrypt.logFix underlying validation issue; wait 1 hour
Plugin not found or Namespace collisionMixing apt and snap installationswhich certbot && snap list certbotsudo apt remove certbot python3-certbot-*; reinstall via snap
Could not bind to port 80Standalone mode but web server occupying portsudo ss -tlnp | grep :80Stop web server or use --webroot mode instead
Timeout during connect (likely firewall problem)Let’s Encrypt cannot reach your serverCheck firewall rules for port 80 from externalOpen port 80 inbound; check cloud security groups
The server experienced an internal errorLet’s Encrypt server-side issueCheck letsencrypt.status.ioRetry in 15–30 minutes
Certificate not yet due for renewalCertificate outside renewal windowsudo certbot certificatesUse --force-renewal if needed early
unauthorized: CAA record prevents issuanceCAA DNS record blocks Let’s Encryptdig CAA example.com +shortAdd 0 issue "letsencrypt.org" CAA record
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

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

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

/etc/letsencrypt/renewal-hooks/deploy/vault-push.sh
#!/bin/bash
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.).

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.

Terminal window
# 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
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: platform-team@example.com
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: platform-team@example.com
privateKeySecretRef:
name: letsencrypt-prod-account
solvers:
# HTTP-01 for standard domains
- http01:
ingress:
ingressClassName: nginx
# DNS-01 for wildcards (example: Cloudflare)
- dns01:
cloudflare:
email: dns-admin@example.com
apiTokenSecretRef:
name: cloudflare-api-token
key: api-token
selector:
dnsNames:
- "*.example.com"
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
# 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
Terminal window
# 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

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)

Section titled “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):

Terminal window
0 */6 * * * /opt/scripts/check-certs.sh >> /var/log/cert-check.log 2>&1
Terminal window
# 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).

/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...
}
Terminal window
# 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
/opt/scripts/backup-letsencrypt.sh
#!/bin/bash
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:

Terminal window
# Cron entry
0 2 * * * /opt/scripts/backup-letsencrypt.sh >> /var/log/letsencrypt-backup.log 2>&1
Terminal window
# 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 security@example.com --agree-tos
# Then re-obtain all 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:

  • 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

Section titled “Cron Configuration for Short-Lived Certificates”
Terminal window
# 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)

Section titled “Opt-In to Short Lifetimes Early (Available May 2026)”
Terminal window
# 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.

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

ClientLanguageBest ForKey Features
acme.shBashMinimal environments, no dependenciesPure bash; 100+ DNS providers
legoGoSingle binary deployment, CI/CDStatic binary; 80+ DNS providers
cert-managerGoKubernetesCRD-based; native K8s integration
CaddyGoSimple web servingBuilt-in ACME; zero-config HTTPS
win-acmeC#Windows/IISNative Windows; IIS integration
Terminal window
# ─── 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