Skip to main content

SSL/TLS Production Pro Tips

Getting the certificate issued and HTTPS configured is just the beginning. True production-grade HTTPS means achieving SSL Labs A+, automating expiry alerts before certificates expire, and full deployment automation.


SSL Labs A+ Complete Achievement Guide​

A complete Nginx configuration for an A+ grade at SSL Labs.

# /etc/nginx/conf.d/ssl-aplus.conf

# Global settings (http block)
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;

# HTTPS server
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name example.com www.example.com;

# Certificate (Let's Encrypt)
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

# TLS 1.2 + 1.3 only
ssl_protocols TLSv1.2 TLSv1.3;

# Safe cipher suites (ECDHE/DHE 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:DHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers off;
ssl_ecdh_curve X25519:prime256v1:secp384r1;

# OCSP Stapling
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;

# HSTS (the key to A+!)
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

# Additional security headers
add_header X-Frame-Options "SAMEORIGIN" 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;

server_tokens off;

location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

# HTTP β†’ HTTPS
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}

Automated Certificate Expiry Monitoring​

Shell Script Monitoring​

#!/bin/bash
# /usr/local/bin/check-ssl-expiry.sh

DOMAINS=("example.com" "api.example.com" "admin.example.com")
ALERT_DAYS=30 # Alert when this many days or fewer remain
SLACK_WEBHOOK="https://hooks.slack.com/services/YOUR/WEBHOOK/URL"

for domain in "${DOMAINS[@]}"; do
# Query certificate expiry
expiry=$(echo | openssl s_client -servername "$domain" \
-connect "$domain:443" 2>/dev/null \
| openssl x509 -noout -enddate 2>/dev/null \
| cut -d= -f2)

if [ -z "$expiry" ]; then
send_slack "❌ [$domain] SSL certificate query failed!"
continue
fi

# Calculate days remaining
expiry_epoch=$(date -d "$expiry" +%s 2>/dev/null || date -j -f "%b %d %T %Y %Z" "$expiry" +%s)
now_epoch=$(date +%s)
days_left=$(( (expiry_epoch - now_epoch) / 86400 ))

if [ "$days_left" -le "$ALERT_DAYS" ]; then
message="⚠️ [$domain] SSL certificate expires in ${days_left} days! (expiry: $expiry)"
curl -s -X POST "$SLACK_WEBHOOK" \
-H 'Content-type: application/json' \
--data "{\"text\":\"$message\"}"
else
echo "βœ… $domain: ${days_left} days remaining ($expiry)"
fi
done
# Add to crontab (daily at 9 AM)
0 9 * * * /usr/local/bin/check-ssl-expiry.sh >> /var/log/ssl-check.log 2>&1

Prometheus + Blackbox Exporter​

# prometheus.yml
scrape_configs:
- job_name: 'ssl-expiry'
metrics_path: /probe
params:
module: [https_2xx]
static_configs:
- targets:
- https://example.com
- https://api.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
# Grafana alert condition (expiry within 30 days)
probe_ssl_earliest_cert_expiry - time() < 30 * 24 * 3600

Let's Encrypt Renewal Validation​

# Renewal simulation (test without actually renewing)
sudo certbot renew --dry-run

# Force-renew a specific domain
sudo certbot renew --force-renewal -d example.com

# Auto-reload Nginx after renewal (post-hook)
sudo certbot renew \
--post-hook "systemctl reload nginx" \
--pre-hook "nginx -t"

# Check certbot timer logs
sudo journalctl -u certbot.timer -n 50

Renewal Failure Notifications​

# /etc/letsencrypt/renewal-hooks/deploy/notify-renewal.sh
#!/bin/bash

SLACK_WEBHOOK="https://hooks.slack.com/services/YOUR/WEBHOOK"
DOMAINS=$RENEWED_DOMAINS
EXPIRY=$RENEWED_LINEAGE

curl -s -X POST "$SLACK_WEBHOOK" \
-d "{\"text\":\"βœ… SSL certificate renewed: $DOMAINS\"}"
# Cron wrapper to alert on failure
#!/bin/bash
output=$(certbot renew 2>&1)
if [ $? -ne 0 ]; then
curl -X POST "$SLACK_WEBHOOK" \
-d "{\"text\":\"❌ SSL certificate renewal failed!\n$output\"}"
fi

Common SSL Configuration Mistakes​

Mistake 1: Incomplete Certificate Chain​

# Check the chain
openssl s_client -connect example.com:443 -showcerts 2>/dev/null \
| grep -E "s:|i:"
# s: = current certificate
# i: = issuer (intermediate CA)
# The last i: should be the Root CA

# Use fullchain.pem to fix this
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; # βœ…
ssl_certificate /etc/letsencrypt/live/example.com/cert.pem; # ❌ chain missing

Mistake 2: Wrong Private Key Permissions​

# Private key must be readable by root only
sudo chmod 600 /etc/letsencrypt/live/example.com/privkey.pem
sudo chown root:root /etc/letsencrypt/live/example.com/privkey.pem

# Nginx runs as root so it can still read it

Mistake 3: Mixed Content (HTTP resources on an HTTPS page)​

# Check in browser DevTools console
# Mixed Content: The page was loaded over HTTPS, but requested an insecure resource

# Detect with CSP header
add_header Content-Security-Policy-Report-Only "default-src https:; report-uri /csp-report";

# Fix: change all resource URLs to https:// or //
# <img src="http://cdn.example.com/logo.png"> ❌
# <img src="https://cdn.example.com/logo.png"> βœ…
# <img src="//cdn.example.com/logo.png"> βœ… (protocol-relative)

Mistake 4: HSTS Locks You Out of HTTP​

After setting HSTS max-age, if HTTP access becomes temporarily necessary,
the browser will refuse all HTTP connections for the duration of max-age.

Solution: Start with max-age=300 (5 minutes) and verify everything works,
then increase to max-age=63072000 (2 years).

Deployment Automation Checklist​

#!/bin/bash
# ssl-deploy-check.sh β€” automated SSL validation before deployment

DOMAIN=$1

echo "=== SSL Configuration Check for $DOMAIN ==="

# 1. Certificate validity
expiry=$(echo | openssl s_client -servername "$DOMAIN" \
-connect "$DOMAIN:443" 2>/dev/null | openssl x509 -noout -enddate)
echo "βœ… Certificate: $expiry"

# 2. TLS version check
tls13=$(openssl s_client -connect "$DOMAIN:443" -tls1_3 2>/dev/null | grep "Protocol")
echo "βœ… TLS 1.3: $tls13"

# 3. Verify TLS 1.0/1.1 are blocked
tls10=$(openssl s_client -connect "$DOMAIN:443" -tls1 2>&1 | grep -c "handshake failure")
[ "$tls10" -eq 1 ] && echo "βœ… TLS 1.0 blocked" || echo "❌ TLS 1.0 NOT blocked"

# 4. HSTS header check
hsts=$(curl -sI "https://$DOMAIN" | grep -i "strict-transport")
[ -n "$hsts" ] && echo "βœ… HSTS: $hsts" || echo "❌ HSTS missing"

# 5. OCSP Stapling
stapling=$(echo | openssl s_client -connect "$DOMAIN:443" -status 2>/dev/null | grep -c "OCSP Response Status")
[ "$stapling" -ge 1 ] && echo "βœ… OCSP Stapling enabled" || echo "⚠️ OCSP Stapling not confirmed"

echo "=== Check complete ==="

SSL Performance Optimization Summary​

SettingEffectNginx Directive
SSL session cacheSkip handshake on reconnectionssl_session_cache shared:SSL:10m
TLS 1.31-RTT handshakessl_protocols TLSv1.3
OCSP StaplingEliminate certificate check latencyssl_stapling on
HTTP/2Multiplexing, header compressionhttp2 on
ECDSA certificateFaster than RSASelect EC key at the CA