Certbot gives you a valid TLS certificate in 5 minutes — but a valid certificate is not the same as a secure TLS configuration. I use Certbot for all my production deployments, and I follow it immediately with a hardening step that most tutorials skip. The difference matters: an SSL Labs scan on a default Certbot+nginx setup might score B or even C due to legacy cipher support, missing HSTS, or outdated TLS versions still enabled. An A+ rating requires deliberate configuration beyond just getting the certificate. After hardening TLS on a dozen client deployments, here is what I actually configure.
SSL Labs (ssllabs.com/ssltest) grades TLS configurations from A+ to F based on: supported protocol versions, cipher suites offered, certificate validity and chain completeness, HSTS (HTTP Strict Transport Security) presence and duration, OCSP stapling, and forward secrecy support. Most default nginx+Certbot configurations score B because they still support TLS 1.0 and 1.1 (deprecated, vulnerable to BEAST and POODLE attacks) and use cipher suites without forward secrecy. Getting to A+ requires disabling legacy protocols, configuring a secure cipher list, enabling OCSP stapling, and adding HSTS with a long max-age.
Minimum secure TLS configuration in 2025: disable TLS 1.0 and 1.1 (use `ssl_protocols TLSv1.2 TLSv1.3`), prefer TLS 1.3 where both client and server support it (handles cipher negotiation automatically), for TLS 1.2 use a restricted cipher list with ECDHE for forward secrecy, disable RC4 and 3DES explicitly, set `ssl_prefer_server_ciphers on` for TLS 1.2 to enforce your cipher priority over the client's. Mozilla's SSL Configuration Generator (ssl-config.mozilla.org) generates the correct nginx/Apache/HAProxy config for your target compatibility level — use it rather than writing cipher strings by hand.
# Complete nginx TLS hardening config (scores A+ on SSL Labs)
# Generated from Mozilla SSL Configuration Generator (Intermediate profile)
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name yourdomain.com www.yourdomain.com;
# Certbot-managed certificates
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/yourdomain.com/chain.pem;
# TLS protocols: disable 1.0 and 1.1
ssl_protocols TLSv1.2 TLSv1.3;
# Cipher suites for TLS 1.2 (TLS 1.3 handles its own ciphers)
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:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# DH parameters (generate once: openssl dhparam -out /etc/ssl/dhparam.pem 2048)
ssl_dhparam /etc/ssl/dhparam.pem;
# Session configuration
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
# OCSP stapling
ssl_stapling on;
ssl_stapling_verify on;
resolver 8.8.8.8 8.8.4.4 valid=300s;
resolver_timeout 5s;
# HSTS — max 1 year, include subdomains, preload
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# Security headers
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# HTTP → HTTPS redirect
server {
listen 80;
listen [::]:80;
server_name yourdomain.com www.yourdomain.com;
return 301 https://$host$request_uri;
}From my experience running production HTTPS across multiple client deployments: generate your nginx TLS config from Mozilla's SSL Configuration Generator rather than copying from blog posts (including mine). Select 'Modern' for APIs that only need to support current browsers/clients (drops TLS 1.2 support, simplifies to TLS 1.3 only), 'Intermediate' for general web applications (TLS 1.2 + 1.3 with secure ciphers), and 'Old' only if you must support IE11 or older Android. Regenerate yearly as the recommendations update.
HSTS (Strict-Transport-Security) tells browsers to only ever connect to your domain over HTTPS, even if the user types http://. The basic header: `Strict-Transport-Security: max-age=31536000` (1 year). Enhanced: add `includeSubDomains` to cover all subdomains and `preload` to submit your domain to the browser preload list. The preload list is built into Chrome, Firefox, and Edge — domains on it are HTTPS-only before the browser ever makes a request, preventing SSL stripping attacks on first visit. Submitting: hstspreload.org. Requirements: max-age ≥ 31536000, includeSubDomains, preload directive, and all subdomains must redirect HTTP to HTTPS.
When a browser validates a TLS certificate, it checks the certificate's revocation status by querying the CA's OCSP server. This adds latency (100-300ms) and a privacy leak (the CA sees which sites you visit). OCSP stapling has the server periodically fetch the OCSP response from the CA and include it in the TLS handshake — the browser gets revocation status without a separate request. In nginx: `ssl_stapling on; ssl_stapling_verify on; resolver 8.8.8.8 8.8.4.4 valid=300s;`. Verify it works: `openssl s_client -connect yourdomain.com:443 -status` — look for 'OCSP Response Status: successful'.
Before enabling HSTS with preload and includeSubDomains, verify that ALL your subdomains serve HTTPS correctly — including any staging, dev, admin, or internal subdomains. Once you are on the preload list, removing yourself takes months and requires passing all validation checks again. I have seen clients break internal tools that were on HTTP-only subdomains by adding includeSubDomains without auditing all their subdomains first. Start with max-age=300 (5 minutes) and test thoroughly before increasing to 31536000.
Certificate Transparency (CT) logs are public, append-only records of all TLS certificates issued by trusted CAs. Any certificate for your domain will appear in CT logs within minutes of issuance. Monitor CT logs to detect unauthorized certificates (issued through CA compromise or phishing the CA). Free monitoring: crt.sh (search your domain), Facebook's certificate transparency monitoring, or Cert Spotter. Set up alerts for new certificates on your domain — a new certificate you did not issue is a serious security event that warrants immediate investigation.
# Verify OCSP stapling is working
openssl s_client -connect yourdomain.com:443 -status 2>/dev/null | grep -A 3 "OCSP Response Status"
# Expected: OCSP Response Status: successful (0x0)
# Verify TLS version and cipher
openssl s_client -connect yourdomain.com:443 -tls1_2 2>/dev/null | grep "Protocol|Cipher"
openssl s_client -connect yourdomain.com:443 2>/dev/null | grep "Protocol|Cipher"
# Check certificate expiry
echo | openssl s_client -connect yourdomain.com:443 2>/dev/null | openssl x509 -noout -dates
# Monitor cert expiry with Prometheus Blackbox Exporter
# blackbox.yml config:
# modules:
# https_2xx:
# prober: http
# http:
# valid_http_versions: ["HTTP/1.1", "HTTP/2.0"]
# tls_config:
# insecure_skip_verify: false
#
# prometheus.yml scrape:
# - job_name: 'tls-expiry'
# metrics_path: /probe
# params:
# module: [https_2xx]
# static_configs:
# - targets: ['https://yourdomain.com']
# relabel_configs:
# - source_labels: [__address__]
# target_label: __param_target
# - target_label: __address__
# replacement: blackbox-exporter:9115
#
# Alert rule:
# - alert: SSLCertificateExpiringSoon
# expr: probe_ssl_earliest_cert_expiry - time() < 7 * 24 * 3600
# labels:
# severity: criticalThe complete nginx TLS configuration I use for production APIs — combining Let's Encrypt certificates with TLS 1.3 preference, secure cipher suites, OCSP stapling, HSTS, and security headers. This configuration scores A+ on SSL Labs and passes security header checks on securityheaders.com. Review and update when Mozilla releases new recommendations, typically annually.
Even with auto-renewal configured, monitor certificate expiry as an independent check. Certbot renewals fail for reasons: DNS validation failures, Let's Encrypt rate limits, nginx reload errors after renewal. Set up external certificate expiry monitoring (Uptime Robot has a free SSL monitor, Grafana with Blackbox Exporter works for self-hosted). Alert at 30 days and 7 days remaining. The Prometheus Blackbox Exporter ssl_expiry_seconds metric lets you graph certificate expiry and alert in your existing monitoring stack. A certificate expiry that causes downtime on a production API is an embarrassing and entirely preventable incident.