SSL/TLS Certificate Management
Certificate Types
| Type | Issued to | Validation | Browser trust | Wildcard support |
|---|---|---|---|---|
| DV (Domain Validated) | Domain owner | Automated DNS/HTTP | Yes | Via wildcard DV |
| OV (Organization Validated) | Legal entity | Manual org check | Yes | No |
| EV (Extended Validation) | Legal entity + strict checks | Extensive manual | Yes (green bar deprecated) | No |
Wildcard (e.g. *.example.com) | Domain owner | DNS challenge only | Yes | Yes — one level only |
| Self-signed | Anyone | None | No — browser warning | Yes |
| mkcert (local CA) | Localhost, IPs | Local trust store | Yes (after trust-store install) | Yes |
Let's Encrypt issues DV certs only. Wildcard certs require the DNS-01 challenge.
ACME Protocol Basics
ACME (Automatic Certificate Management Environment, RFC 8555) works as follows:
- Client (Certbot) contacts the CA (Let's Encrypt) and creates an Order for one or more domains.
- CA issues a Challenge — either HTTP-01 (place a file at
/.well-known/acme-challenge/<token>) or DNS-01 (create a_acme-challenge.<domain>TXT record). - Client completes the challenge.
- CA verifies the challenge and issues the certificate.
- Client stores
fullchain.pem(cert + intermediates) andprivkey.pem.
Certificates are valid for 90 days; automate renewal to run at least every 60 days.
Certbot — All Challenge Types
Standalone (no existing web server on port 80)
# Install Certbot (Ubuntu/Debian)
sudo apt-get install -y certbot
# Obtain cert — Certbot spins up a temporary HTTP server on port 80
sudo certbot certonly \
--standalone \
--preferred-challenges http \
-d example.com \
-d www.example.com \
--email admin@example.com \
--agree-tos \
--no-eff-email
# Files created in: /etc/letsencrypt/live/example.com/
# fullchain.pem — certificate + intermediates (use this in ssl_certificate)
# privkey.pem — private key
# chain.pem — intermediates only (use for OCSP stapling)
# cert.pem — leaf certificate only (rarely needed)
Webroot (Nginx / Apache keeps running)
# Ensure the webroot is served by your web server at /.well-known/acme-challenge/
# Nginx: location /.well-known/acme-challenge/ { root /var/www/certbot; }
sudo certbot certonly \
--webroot \
--webroot-path /var/www/certbot \
-d example.com \
-d www.example.com \
--email admin@example.com \
--agree-tos \
--no-eff-email
DNS Challenge — Cloudflare (wildcard support)
# Install Certbot Cloudflare DNS plugin
sudo apt-get install -y python3-certbot-dns-cloudflare
# Create credentials file (readable only by root)
sudo mkdir -p /etc/letsencrypt/credentials
sudo tee /etc/letsencrypt/credentials/cloudflare.ini > /dev/null <<'EOF'
# Cloudflare API token (Zone:DNS:Edit permission, scoped to zone)
dns_cloudflare_api_token = YOUR_CF_API_TOKEN_HERE
EOF
sudo chmod 600 /etc/letsencrypt/credentials/cloudflare.ini
# Obtain wildcard + apex certificate
sudo certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/credentials/cloudflare.ini \
--dns-cloudflare-propagation-seconds 60 \
-d example.com \
-d "*.example.com" \
--email admin@example.com \
--agree-tos \
--no-eff-email
Nginx SSL Block (Production-Ready)
# Inside server { listen 443 ssl; ... }
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem; # OCSP stapling
# Protocol: drop TLS 1.0 and 1.1 (deprecated in RFC 8996)
ssl_protocols TLSv1.2 TLSv1.3;
# Cipher suites: TLS 1.3 ciphers are implicit and non-configurable.
# The list below applies to TLS 1.2 only.
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:DHE-RSA-AES128-GCM-SHA256';
# Let clients (especially TLS 1.3) pick their preferred cipher
ssl_prefer_server_ciphers off;
# Session resumption (reduces handshake overhead for returning clients)
ssl_session_cache shared:SSL:10m; # 1MB ≈ 4000 sessions; 10m ≈ 40000 sessions
ssl_session_timeout 1d;
ssl_session_tickets off; # Disable — tickets use a server-side key that
# if leaked undermines forward secrecy
# DHE key exchange parameters (generated once; 2048-bit minimum)
ssl_dhparam /etc/nginx/ssl/dhparam.pem;
# OCSP Stapling: server fetches and caches the OCSP response;
# eliminates client round-trip to CA's OCSP responder
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
# HSTS: tell browsers to always use HTTPS for this domain
# preload: submit to browser preload lists (irreversible for ~1 year)
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
DHParam Generation
# Generate 2048-bit DH parameters (runs once; takes ~30 seconds)
sudo mkdir -p /etc/nginx/ssl
sudo openssl dhparam -out /etc/nginx/ssl/dhparam.pem 2048
# 4096-bit for highest security (takes several minutes — overkill for most apps)
sudo openssl dhparam -out /etc/nginx/ssl/dhparam.pem 4096
Renewal Automation — systemd Timer
/etc/systemd/system/certbot-renewal.service
[Unit]
Description=Certbot Renewal
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot renew \
--quiet \
--deploy-hook /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
StandardOutput=journal
StandardError=journal
/etc/systemd/system/certbot-renewal.timer
[Unit]
Description=Run Certbot renewal twice daily
[Timer]
OnCalendar=*-*-* 03,15:00:00 # 3 AM and 3 PM UTC
RandomizedDelaySec=3600 # Spread load on Let's Encrypt's servers
Persistent=true # Run immediately if last run was missed (e.g. server was off)
[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now certbot-renewal.timer
sudo systemctl list-timers certbot-renewal
Deploy Hook — /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/bin/bash
# Called by Certbot after a successful renewal.
# RENEWED_LINEAGE is set to the path of the renewed certificate.
set -euo pipefail
echo "[certbot-deploy] Reloading Nginx after renewal of ${RENEWED_LINEAGE}"
# Validate new config before reloading
nginx -t || { echo "[certbot-deploy] ERROR: Nginx config test failed"; exit 1; }
systemctl reload nginx
echo "[certbot-deploy] Nginx reloaded successfully"
sudo chmod 700 /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
Local Development — mkcert
# Install mkcert (Linux/macOS)
# macOS: brew install mkcert
# Linux: download binary from https://github.com/FiloSottile/mkcert/releases
sudo apt-get install -y libnss3-tools # Required for Chrome/Firefox trust store
wget -q "https://dl.filippo.io/mkcert/latest?for=linux/amd64" -O mkcert
chmod +x mkcert && sudo mv mkcert /usr/local/bin/
# Install local CA into system and browser trust stores
mkcert -install
# Generate certificate for local domains and IPs
mkcert localhost 127.0.0.1 ::1 myapp.local "*.myapp.local"
# Output: localhost+5.pem + localhost+5-key.pem in current directory
# Reference these files in your local Nginx / dev server TLS config
Certificate Inspection Commands
# Inspect the live certificate served by a host
openssl s_client -connect example.com:443 -servername example.com \
</dev/null 2>/dev/null | openssl x509 -noout -text
# Check expiry date only
openssl s_client -connect example.com:443 -servername example.com \
</dev/null 2>/dev/null | openssl x509 -noout -dates
# Inspect a cert file on disk
openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem \
-noout -text | grep -E 'DNS:|Not (Before|After)|Subject:|Issuer:'
# Verify cert chain
openssl verify -CAfile /etc/letsencrypt/live/example.com/chain.pem \
/etc/letsencrypt/live/example.com/cert.pem
# Test OCSP stapling response
openssl s_client -connect example.com:443 -servername example.com \
-status </dev/null 2>/dev/null | grep -A6 "OCSP Response"
# List all managed Certbot certificates and expiry
sudo certbot certificates
# Dry-run renewal test (does not modify files or contact Let's Encrypt production)
sudo certbot renew --dry-run
# Check certificate SANs
openssl x509 -in fullchain.pem -noout -ext subjectAltName
# Check that private key matches certificate
openssl x509 -noout -modulus -in cert.pem | md5sum
openssl rsa -noout -modulus -in privkey.pem | md5sum
# Both MD5 hashes must match
ssl_session_cache vs ssl_session_tickets
| Setting | Mechanism | Forward secrecy | Recommended |
|---|---|---|---|
ssl_session_cache shared:SSL:10m | Server-side session store | Preserved (new ephemeral key per session) | Yes |
ssl_session_tickets on | Client holds encrypted session token | Ticket key acts like a long-term secret — if leaked, all sessions exposed | Off (ssl_session_tickets off) |
Anti-Patterns
| Anti-pattern | Why it's harmful | Fix |
|---|---|---|
| TLS 1.0 / 1.1 enabled | Both deprecated (RFC 8996); vulnerable to BEAST, POODLE | ssl_protocols TLSv1.2 TLSv1.3; only |
| Self-signed cert in production | Browser shows security warning; blocks automated clients | Use Let's Encrypt (free) or a paid CA |
| No renewal monitoring | Certificate expires without warning — site goes down | Monitor expiry: Prometheus ssl_expiry_seconds, UptimeRobot TLS check, or certbot renew --dry-run in CI |
| Weak DHParam (512/768-bit or none) | DHE key exchange vulnerable to Logjam | Generate 2048-bit: openssl dhparam -out dhparam.pem 2048 |
ssl_session_tickets on (default in Nginx) | Ticket key is rotated rarely; long-term key exposure breaks forward secrecy | ssl_session_tickets off; |
| No HSTS header | Browsers re-attempt HTTP on each visit; MITM downgrade possible | Add HSTS; start with short max-age; add preload only when stable |
HSTS preload added too early | Preload list submission is hard to reverse (~1 year) | Test with short max-age=300, then extend to 63072000 before submitting |
ssl_prefer_server_ciphers on with TLS 1.3 | TLS 1.3 ignores this directive; may penalise clients unnecessarily on TLS 1.2 | Set off; ensure cipher list is already strong |
| No CAA DNS record | Any CA can issue a cert for your domain | Add CAA 0 issue "letsencrypt.org" record to your DNS zone |
| Credential/API token in certbot ini file without chmod 600 | Anyone with shell access can read the token | chmod 600 /etc/letsencrypt/credentials/cloudflare.ini |
Troubleshooting
| Symptom | Likely cause | Diagnostic / Fix |
|---|---|---|
| ACME HTTP-01 challenge fails | Port 80 not reachable from internet, or wrong webroot path | Check firewall; curl http://example.com/.well-known/acme-challenge/test; verify Nginx root for challenge location |
| DNS-01 challenge fails | TXT record not propagated within propagation-seconds window | Increase --dns-cloudflare-propagation-seconds 120; verify with dig TXT _acme-challenge.example.com @8.8.8.8 |
| Renewal error: "Problem binding to port 80" | Another process (Nginx) is using port 80 during standalone challenge | Switch to webroot challenge; or stop Nginx, renew, restart |
| "certificate verify failed" in client | Missing intermediate in fullchain.pem | Use fullchain.pem (not cert.pem) in ssl_certificate |
| HSTS lock-in on wrong redirect | Preloaded or long max-age HSTS prevents HTTP access | Cannot be quickly undone — must wait for max-age to expire; test with fresh browser profile |
| Mixed content warnings | HTTPS page loading HTTP resources | Audit page with browser DevTools; set Content-Security-Policy: upgrade-insecure-requests as interim fix |
| OCSP stapling "no response" | Nginx can't reach OCSP responder (firewall or resolver missing) | Ensure resolver directive is set; verify outbound HTTPS is allowed from server |
| Private key / cert mismatch | Wrong cert installed or key regenerated | Compare modulus MD5: openssl x509 -noout -modulus -in cert.pem | md5sum vs openssl rsa -noout -modulus -in privkey.pem | md5sum |
| "Too many certificates already issued" | Let's Encrypt rate limit: 50 certs per registered domain per week | Use --staging flag for testing; wait for rate limit window to reset |
| mkcert cert not trusted in Chrome | mkcert -install not run, or browser uses its own trust store | Re-run mkcert -install; restart browser; for Chrome on Linux, also install in ~/.pki/nssdb |