HTTPS is table stakes. Here’s how to set up certificates properly and avoid the 3am “certificate expired” panic.

Let’s Encrypt with Certbot

Standalone Mode (No Web Server)

1
2
3
4
5
6
7
8
9
# Install
sudo apt install certbot

# Get certificate (stops any service on port 80)
sudo certbot certonly --standalone -d example.com -d www.example.com

# Certificates stored in:
# /etc/letsencrypt/live/example.com/fullchain.pem
# /etc/letsencrypt/live/example.com/privkey.pem

Webroot Mode (Server Running)

1
2
# Certbot verifies via http://example.com/.well-known/acme-challenge/
sudo certbot certonly --webroot -w /var/www/html -d example.com

Nginx Plugin

1
sudo certbot --nginx -d example.com -d www.example.com

Certbot modifies nginx config automatically.

Auto-Renewal

1
2
3
4
5
6
7
8
# Test renewal
sudo certbot renew --dry-run

# Certbot installs a systemd timer or cron job automatically
systemctl list-timers | grep certbot

# Manual cron if needed
0 0 * * * certbot renew --quiet --post-hook "systemctl reload nginx"

Nginx SSL Configuration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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;
    
    # Modern SSL settings
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
    ssl_prefer_server_ciphers off;
    
    # HSTS (careful: hard to undo)
    add_header Strict-Transport-Security "max-age=63072000" always;
    
    # OCSP stapling
    ssl_stapling on;
    ssl_stapling_verify on;
    resolver 8.8.8.8 8.8.4.4 valid=300s;
}

# Redirect HTTP to HTTPS
server {
    listen 80;
    server_name example.com;
    return 301 https://$host$request_uri;
}

Certificate Chain Verification

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Check certificate details
openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -text -noout

# Verify chain
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt \
  /etc/letsencrypt/live/example.com/fullchain.pem

# Check remote certificate
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null | \
  openssl x509 -noout -dates

Monitoring Expiration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#!/bin/bash
# check_cert_expiry.sh

DOMAIN=$1
DAYS_WARNING=14

expiry=$(echo | openssl s_client -servername $DOMAIN -connect $DOMAIN:443 2>/dev/null | \
  openssl x509 -noout -enddate | cut -d= -f2)

expiry_epoch=$(date -d "$expiry" +%s)
now_epoch=$(date +%s)
days_left=$(( ($expiry_epoch - $now_epoch) / 86400 ))

if [ $days_left -lt $DAYS_WARNING ]; then
    echo "WARNING: $DOMAIN certificate expires in $days_left days"
    exit 1
fi

echo "OK: $DOMAIN certificate valid for $days_left days"

Wildcard Certificates

1
2
3
4
5
6
# Requires DNS validation
sudo certbot certonly --manual --preferred-challenges dns \
  -d "*.example.com" -d example.com

# Certbot will ask you to create a TXT record:
# _acme-challenge.example.com -> "random-verification-string"

For automation, use DNS plugins:

1
2
3
4
# Cloudflare
sudo certbot certonly --dns-cloudflare \
  --dns-cloudflare-credentials ~/.secrets/cloudflare.ini \
  -d "*.example.com"

Multiple Domains (SAN)

1
2
3
4
5
certbot certonly --standalone \
  -d example.com \
  -d www.example.com \
  -d api.example.com \
  -d cdn.example.com

All domains on one certificate.

Self-Signed for Development

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Generate self-signed cert (dev only!)
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
  -keyout dev.key \
  -out dev.crt \
  -subj "/CN=localhost"

# With SANs for multiple hostnames
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
  -keyout dev.key -out dev.crt \
  -subj "/CN=localhost" \
  -addext "subjectAltName=DNS:localhost,DNS:*.local,IP:127.0.0.1"

Kubernetes cert-manager

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: admin@example.com
    privateKeySecretRef:
      name: letsencrypt-prod
    solvers:
      - http01:
          ingress:
            class: nginx
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: example-cert
spec:
  secretName: example-tls
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
  dnsNames:
    - example.com
    - www.example.com

Troubleshooting

Certificate Not Trusted

1
2
3
4
5
6
7
8
# Check chain is complete
openssl s_client -connect example.com:443 -servername example.com

# Look for:
# "Verify return code: 0 (ok)"

# If not, you may be missing intermediate certificates
# Use fullchain.pem, not just cert.pem

Mixed Content Warnings

Browser console shows HTTP resources on HTTPS page.

1
2
# Force all resources to HTTPS
add_header Content-Security-Policy "upgrade-insecure-requests";

HSTS Issues

Once enabled, browsers refuse HTTP for that domain. Test carefully:

1
2
3
4
5
# Start with short max-age
add_header Strict-Transport-Security "max-age=300";

# Increase after testing
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload";

Security Headers Checklist

1
2
3
4
5
6
# Full security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

Test with: https://securityheaders.com

The Renewal Panic Prevention Plan

  1. Monitor expiration (Prometheus, UptimeRobot, simple cron script)
  2. Alert at 14 days remaining
  3. Auto-renew at 30 days remaining
  4. Test renewal works: certbot renew --dry-run
  5. Post-renewal hook reloads services

The goal is to never think about certificates except when setting them up.