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";
|
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#
- Monitor expiration (Prometheus, UptimeRobot, simple cron script)
- Alert at 14 days remaining
- Auto-renew at 30 days remaining
- Test renewal works:
certbot renew --dry-run - Post-renewal hook reloads services
The goal is to never think about certificates except when setting them up.
📬 Get the Newsletter
Weekly insights on DevOps, automation, and CLI mastery. No spam, unsubscribe anytime.