- Authors
- Name
- Introduction
- 1. How SSL/TLS Works
- 2. Certificate Types
- 3. Let's Encrypt + Certbot in Practice
- 4. Nginx SSL Configuration (A+ Grade)
- 5. AWS ACM Certificate Management
- 6. Certificate Auto-Renewal
- 7. Certificate Debugging Command Collection
- 8. Troubleshooting
- 9. Advanced Configuration
- 10. Operations Checklists
- Conclusion
Introduction
HTTPS is no longer optional -- it is essential. Browsers display "Not Secure" warnings on HTTP sites, and search engines use HTTPS as a ranking signal. However, properly understanding and operating SSL/TLS certificates is more complex than you might think. There are many easy-to-miss details, including certificate type selection, correct configuration, auto-renewal, and chain construction.
This article covers everything from the fundamentals of SSL/TLS to real-world operations in one place.
1. How SSL/TLS Works
Symmetric vs Asymmetric Encryption
SSL/TLS uses both encryption methods together.
| Category | Symmetric Encryption | Asymmetric Encryption |
|---|---|---|
| Key | Single key for encrypt/decrypt | Public key (encrypt) + Private key (decrypt) |
| Speed | Fast | Slow (100~1000x) |
| Algorithm | AES-256, ChaCha20 | RSA, ECDSA, Ed25519 |
| Purpose | Actual data transmission | Key exchange, certificate signing |
| Key Length | 128/256 bit | 2048/4096 bit (RSA) |
TLS 1.3 Handshake Process
In TLS 1.3, the handshake has been reduced to 1-RTT.
Client Server
| |
|--- ClientHello (supported ciphers, key share) ->|
| |
|<-- ServerHello (chosen cipher, key share, |
| EncryptedExtensions, Certificate, |
| CertificateVerify, Finished) ---------------|
| |
|--- Finished (encrypted) ---------------------->|
| |
|<========= Application Data (encrypted) =======>|
Key improvements in TLS 1.3:
- 1-RTT Handshake: Reduced from 2-RTT in TLS 1.2 to 1-RTT
- 0-RTT Resumption: Data can be sent without additional delay on reconnection
- Enhanced Security: Removed vulnerable cipher suites (RC4, 3DES, CBC mode, etc.)
- Mandatory Forward Secrecy: All key exchanges use ECDHE or DHE
Certificate Verification Flow
[Browser] -> Receives server certificate
-> Checks certificate validity period
-> Verifies domain name match
-> Validates certificate chain (Server cert -> Intermediate CA -> Root CA)
-> Checks revocation status via OCSP/CRL
-> Verification successful -> Displays padlock icon
2. Certificate Types
Classification by Validation Level
| Type | Validation Level | Issuance Time | Cost | Use Case |
|---|---|---|---|---|
| DV (Domain Validation) | Domain ownership only | Minutes | Free~Low | Personal blogs, small services |
| OV (Organization Validation) | Organization existence | 1~3 days | Medium | Corporate websites |
| EV (Extended Validation) | Legal entity verification | 1~2 weeks | High | Finance, e-commerce |
Classification by Coverage
| Type | Coverage | Example | Cost |
|---|---|---|---|
| Single Domain | One FQDN | www.example.com | Lowest |
| Wildcard | All subdomains at the same level | *.example.com | Medium |
| SAN/Multi-Domain | Multiple domains in one certificate | example.com, example.org | Additional cost per domain |
Wildcard certificate considerations:
# What *.example.com covers
www.example.com # O Covered
api.example.com # O Covered
example.com # X Not covered (must be added separately to SAN)
sub.api.example.com # X Not covered (second-level subdomain)
3. Let's Encrypt + Certbot in Practice
Installing Certbot
# Ubuntu/Debian
sudo apt update
sudo apt install -y certbot python3-certbot-nginx
# CentOS/Rocky Linux
sudo dnf install -y epel-release
sudo dnf install -y certbot python3-certbot-nginx
# macOS (for development)
brew install certbot
# Docker
docker run -it --rm \
-v /etc/letsencrypt:/etc/letsencrypt \
-v /var/lib/letsencrypt:/var/lib/letsencrypt \
certbot/certbot certonly --help
Certificate Issuance Methods
Webroot Method (No Service Downtime)
# Issue while Nginx is running
sudo certbot certonly --webroot \
-w /var/www/html \
-d example.com \
-d www.example.com \
--email admin@example.com \
--agree-tos \
--no-eff-email
# Must add .well-known path in Nginx configuration
# location /.well-known/acme-challenge/ {
# root /var/www/html;
# }
Standalone Method (Requires Port 80)
# Must temporarily stop the service using port 80
sudo systemctl stop nginx
sudo certbot certonly --standalone \
-d example.com \
-d www.example.com
sudo systemctl start nginx
DNS Method (Required for Wildcard Certificates)
# Verify via DNS TXT record
sudo certbot certonly --manual \
--preferred-challenges dns \
-d "*.example.com" \
-d example.com
# Use DNS plugin for automation (Cloudflare example)
sudo pip install certbot-dns-cloudflare
# Create API token file
cat > /etc/letsencrypt/cloudflare.ini << 'EOF'
dns_cloudflare_api_token = YOUR_CLOUDFLARE_API_TOKEN
EOF
chmod 600 /etc/letsencrypt/cloudflare.ini
# Automated issuance via Cloudflare DNS
sudo certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
-d "*.example.com" \
-d example.com
Issued Certificate File Structure
/etc/letsencrypt/live/example.com/
├── cert.pem # Server certificate
├── chain.pem # Intermediate CA certificate
├── fullchain.pem # cert.pem + chain.pem (used by Nginx)
├── privkey.pem # Private key
└── README
4. Nginx SSL Configuration (A+ Grade)
Basic SSL Configuration
# /etc/nginx/sites-available/example.com
server {
listen 80;
server_name example.com www.example.com;
# HTTP -> HTTPS redirect
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com www.example.com;
# Certificate configuration
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# SSL protocols (allow only TLS 1.2 and 1.3)
ssl_protocols TLSv1.2 TLSv1.3;
# Cipher suites (server preference)
ssl_prefer_server_ciphers on;
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';
# ECDH curves
ssl_ecdh_curve X25519:prime256v1:secp384r1;
# SSL session cache
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# OCSP Stapling
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
resolver 8.8.8.8 8.8.4.4 valid=300s;
resolver_timeout 5s;
# DH parameters (2048 bits or more)
ssl_dhparam /etc/nginx/dhparam.pem;
# Security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';" always;
root /var/www/example.com;
index index.html;
}
Generating DH Parameters
# Generate 2048-bit DH parameters (takes about 1~2 minutes)
sudo openssl dhparam -out /etc/nginx/dhparam.pem 2048
# 4096-bit (more secure but takes 5~10 minutes)
sudo openssl dhparam -out /etc/nginx/dhparam.pem 4096
SSL Labs Test
# Validate configuration
sudo nginx -t
# Apply configuration
sudo systemctl reload nginx
# Verify A+ grade on SSL Labs
# https://www.ssllabs.com/ssltest/analyze.html?d=example.com
# Verify via CLI
curl -sS https://api.ssllabs.com/api/v3/analyze?host=example.com | jq '.endpoints[0].grade'
5. AWS ACM Certificate Management
ACM Certificate Issuance
# Request certificate via AWS CLI
aws acm request-certificate \
--domain-name example.com \
--subject-alternative-names "*.example.com" \
--validation-method DNS \
--region ap-northeast-2
# Check validation status
aws acm describe-certificate \
--certificate-arn arn:aws:acm:ap-northeast-2:123456789012:certificate/abc-123 \
--query 'Certificate.DomainValidationOptions'
Automating ACM + Route53 with Terraform
# ACM certificate resource
resource "aws_acm_certificate" "main" {
domain_name = "example.com"
subject_alternative_names = ["*.example.com"]
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
tags = {
Environment = "production"
ManagedBy = "terraform"
}
}
# Auto-create Route53 DNS validation records
resource "aws_route53_record" "cert_validation" {
for_each = {
for dvo in aws_acm_certificate.main.domain_validation_options : dvo.domain_name => {
name = dvo.resource_record_name
type = dvo.resource_record_type
record = dvo.resource_record_value
}
}
allow_overwrite = true
name = each.value.name
records = [each.value.record]
ttl = 60
type = each.value.type
zone_id = data.aws_route53_zone.main.zone_id
}
# Wait for certificate validation to complete
resource "aws_acm_certificate_validation" "main" {
certificate_arn = aws_acm_certificate.main.arn
validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn]
}
# Attach certificate to ALB
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.main.arn
port = 443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = aws_acm_certificate_validation.main.certificate_arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.main.arn
}
}
ACM vs Let's Encrypt Comparison
| Item | AWS ACM | Let's Encrypt |
|---|---|---|
| Cost | Free (when used with AWS services) | Free |
| Validity | 13 months (auto-renewal) | 90 days (auto-renewal required) |
| Wildcard | Supported | Supported (DNS validation required) |
| Scope | AWS ALB, CloudFront, API GW | Can be used anywhere |
| Certificate Export | Not possible (except Private CA) | Possible |
| Management Overhead | Almost none | Certbot management required |
6. Certificate Auto-Renewal
Renewal with Cron
# Test Certbot renewal
sudo certbot renew --dry-run
# Register cron job
sudo crontab -e
# Check for renewal daily at 3 AM (auto-renews when expiring within 30 days)
0 3 * * * /usr/bin/certbot renew --quiet --deploy-hook "systemctl reload nginx"
Renewal with Systemd Timer (Recommended)
# /etc/systemd/system/certbot-renewal.service
cat > /etc/systemd/system/certbot-renewal.service << 'EOF'
[Unit]
Description=Certbot Renewal
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot renew --quiet --deploy-hook "systemctl reload nginx"
ExecStartPost=/bin/bash -c 'echo "Certbot renewal completed at $(date)" >> /var/log/certbot-renewal.log'
EOF
# /etc/systemd/system/certbot-renewal.timer
cat > /etc/systemd/system/certbot-renewal.timer << 'EOF'
[Unit]
Description=Run certbot renewal twice daily
[Timer]
OnCalendar=*-*-* 00,12:00:00
RandomizedDelaySec=3600
Persistent=true
[Install]
WantedBy=timers.target
EOF
# Enable
sudo systemctl daemon-reload
sudo systemctl enable --now certbot-renewal.timer
# Check timer status
sudo systemctl list-timers | grep certbot
Renewal Monitoring Script
#!/bin/bash
# /usr/local/bin/check-ssl-expiry.sh
DOMAINS=("example.com" "api.example.com" "admin.example.com")
ALERT_DAYS=14
SLACK_WEBHOOK="https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
for domain in "${DOMAINS[@]}"; do
expiry_date=$(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_date" ]; then
echo "ERROR: Cannot connect to $domain"
continue
fi
expiry_epoch=$(date -d "$expiry_date" +%s 2>/dev/null || date -j -f "%b %d %T %Y %Z" "$expiry_date" +%s)
current_epoch=$(date +%s)
days_left=$(( (expiry_epoch - current_epoch) / 86400 ))
echo "$domain: $days_left days until expiry ($expiry_date)"
if [ "$days_left" -lt "$ALERT_DAYS" ]; then
curl -s -X POST "$SLACK_WEBHOOK" \
-H 'Content-type: application/json' \
-d "{\"text\":\"SSL Certificate Expiry Warning: $domain - ${days_left} days remaining\"}"
fi
done
# Grant execution permission and register cron
chmod +x /usr/local/bin/check-ssl-expiry.sh
echo "0 9 * * * /usr/local/bin/check-ssl-expiry.sh" | sudo crontab -
7. Certificate Debugging Command Collection
# Check certificate information
openssl x509 -in /etc/letsencrypt/live/example.com/cert.pem -text -noout
# Check expiry date only
openssl x509 -in /etc/letsencrypt/live/example.com/cert.pem -noout -enddate
# Check remote server certificate
echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null \
| openssl x509 -noout -text
# Check certificate chain
echo | openssl s_client -showcerts -servername example.com -connect example.com:443 2>/dev/null
# Verify certificate and private key match
openssl x509 -noout -modulus -in cert.pem | openssl md5
openssl rsa -noout -modulus -in privkey.pem | openssl md5
# If both values match, they correspond
# Check certificate SAN
openssl x509 -in cert.pem -noout -ext subjectAltName
# PEM -> PFX(PKCS12) conversion
openssl pkcs12 -export -out cert.pfx \
-inkey privkey.pem -in cert.pem -certfile chain.pem
# PFX -> PEM conversion
openssl pkcs12 -in cert.pfx -out cert.pem -nodes
8. Troubleshooting
Issue 1: Certificate Expired (ERR_CERT_DATE_INVALID)
# Check current expiry date
echo | openssl s_client -connect example.com:443 2>/dev/null \
| openssl x509 -noout -dates
# Force immediate renewal
sudo certbot renew --force-renewal --cert-name example.com
sudo systemctl reload nginx
Issue 2: Incomplete Certificate Chain (ERR_CERT_AUTHORITY_INVALID)
# Verify chain
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt \
/etc/letsencrypt/live/example.com/fullchain.pem
# Verify Nginx is using fullchain.pem
# Incorrect configuration:
# ssl_certificate /etc/letsencrypt/live/example.com/cert.pem;
# Correct configuration:
# ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
Issue 3: Mixed Content Warning
# Find http:// resources in HTML
grep -rn "http://" /var/www/html/ --include="*.html" --include="*.js" --include="*.css"
# Auto-upgrade with CSP header
# Add to Nginx configuration:
# add_header Content-Security-Policy "upgrade-insecure-requests;" always;
Issue 4: SSL Handshake Failure
# Check supported protocols
nmap --script ssl-enum-ciphers -p 443 example.com
# Test connection with specific TLS version
openssl s_client -connect example.com:443 -tls1_2
openssl s_client -connect example.com:443 -tls1_3
# Client does not support the cipher suite
# -> Add compatible suites to ssl_ciphers configuration
Issue 5: HSTS-Related Issues
# Once HSTS header is set, you cannot go back to HTTP
# Use a short max-age during testing
add_header Strict-Transport-Security "max-age=300" always;
# Use a sufficiently long value in production
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
Issue 6: Let's Encrypt Rate Limits
# Rate Limit details
# - Same domain: 5 issuances per week (excluding renewals)
# - Duplicate certificates: 5 per week
# - Per account: 300 requests per 3 hours
# Use staging server for testing
sudo certbot certonly --staging \
--webroot -w /var/www/html \
-d test.example.com
# Delete staging certificate and issue production certificate
sudo certbot delete --cert-name test.example.com
9. Advanced Configuration
mTLS (Mutual TLS Authentication)
server {
listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /etc/nginx/ssl/server.crt;
ssl_certificate_key /etc/nginx/ssl/server.key;
# Require client certificate
ssl_client_certificate /etc/nginx/ssl/ca.crt;
ssl_verify_client on;
ssl_verify_depth 2;
location / {
# Forward client certificate info to backend
proxy_set_header X-Client-Cert-DN $ssl_client_s_dn;
proxy_set_header X-Client-Cert-Serial $ssl_client_serial;
proxy_pass http://backend;
}
}
Generating Client Certificates
# Generate CA key and certificate
openssl genrsa -out ca.key 4096
openssl req -new -x509 -days 3650 -key ca.key -out ca.crt \
-subj "/C=KR/ST=Seoul/O=MyOrg/CN=Internal CA"
# Generate client key and CSR
openssl genrsa -out client.key 2048
openssl req -new -key client.key -out client.csr \
-subj "/C=KR/ST=Seoul/O=MyOrg/CN=client1"
# Issue client certificate
openssl x509 -req -days 365 \
-in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out client.crt
# Test
curl --cert client.crt --key client.key https://api.example.com/
Certificate Transparency (CT) Log Check
# Search domain certificates in CT logs
# https://crt.sh/?q=example.com
# Verify via CLI
curl -s "https://crt.sh/?q=example.com&output=json" | jq '.[0:5] | .[] | {id, name_value, not_after}'
10. Operations Checklists
Initial Setup Checklist
- Allow only TLS 1.2 and above (
ssl_protocols TLSv1.2 TLSv1.3) - Use only strong cipher suites (ECDHE + AES-GCM/ChaCha20)
- Configure HSTS header (
Strict-Transport-Security) - HTTP -> HTTPS redirect (301)
- Enable OCSP Stapling
- Generate DH parameters with 2048 bits or more
- Verify A+ grade on SSL Labs
- Confirm no Mixed Content
Auto-Renewal Checklist
- Configure Certbot auto-renewal (cron or systemd timer)
- Test renewal (
certbot renew --dry-run) - Set up Nginx reload hook after renewal
- Deploy expiry monitoring script
- Configure Slack/Email alerts (14 days before expiry)
Regular Inspection Checklist
- Check certificate expiry dates monthly
- Re-test with SSL Labs quarterly
- Check for new security vulnerability patches (OpenSSL, Nginx)
- Monitor CT logs for unauthorized certificate issuances
- Review security header changes
Incident Response Checklist
- Document immediate renewal procedures for certificate expiry
- Familiarize with certificate revocation procedures
- Prepare emergency certificate re-issuance procedures
- Establish rollback plan (backup previous certificates)
- Designate notification channels for incidents
Conclusion
SSL/TLS certificate management is not a one-time setup. Let's Encrypt's 90-day validity policy is a good practice that enforces automation. Based on the content covered in this article, we encourage you to build a secure and automated certificate management system.
Key Takeaways:
- Use TLS 1.3 as default, while allowing TLS 1.2 for compatibility
- Let's Encrypt + Certbot for free certificate automated issuance and renewal
- Nginx configuration optimized for SSL Labs A+ grade
- Auto-renewal configured with systemd timer with mandatory monitoring
- Troubleshooting starts with the
openssl s_clientcommand