Skip to content

Split View: SSL/TLS 인증서 완벽 가이드: 발급부터 자동 갱신, 트러블슈팅까지

✨ Learn with Quiz
|

SSL/TLS 인증서 완벽 가이드: 발급부터 자동 갱신, 트러블슈팅까지

들어가며

HTTPS는 이제 선택이 아닌 필수입니다. 브라우저는 HTTP 사이트에 "안전하지 않음" 경고를 표시하고, 검색 엔진은 HTTPS를 랭킹 시그널로 활용합니다. 하지만 SSL/TLS 인증서를 제대로 이해하고 운영하는 것은 생각보다 복잡합니다. 인증서 종류 선택, 올바른 설정, 자동 갱신, 체인 구성 등 놓치기 쉬운 부분이 많습니다.

이 글에서는 SSL/TLS의 동작 원리부터 실전 운영까지 한 번에 정리합니다.

1. SSL/TLS 동작 원리

대칭 암호화 vs 비대칭 암호화

SSL/TLS는 두 가지 암호화 방식을 함께 사용합니다.

구분대칭 암호화비대칭 암호화
하나의 키로 암호화/복호화공개키(암호화) + 개인키(복호화)
속도빠름느림 (100~1000배)
알고리즘AES-256, ChaCha20RSA, ECDSA, Ed25519
용도실제 데이터 전송키 교환, 인증서 서명
키 길이128/256 bit2048/4096 bit (RSA)

TLS 1.3 Handshake 과정

TLS 1.3에서는 handshake가 1-RTT로 줄었습니다.

Client                                           Server
  |                                                 |
  |--- ClientHello (supported ciphers, key share) ->|
  |                                                 |
  |<-- ServerHello (chosen cipher, key share,       |
  |    EncryptedExtensions, Certificate,            |
  |    CertificateVerify, Finished) ---------------|
  |                                                 |
  |--- Finished (encrypted) ---------------------->|
  |                                                 |
  |<========= Application Data (encrypted) =======>|

TLS 1.3의 주요 개선점:

  1. 1-RTT Handshake: TLS 1.2의 2-RTT에서 1-RTT로 단축
  2. 0-RTT Resumption: 재접속 시 추가 지연 없이 데이터 전송 가능
  3. 강화된 보안: 취약한 암호 스위트 제거 (RC4, 3DES, CBC 모드 등)
  4. Forward Secrecy 필수: 모든 키 교환에 ECDHE 또는 DHE 사용

인증서 검증 흐름

[브라우저] -> 서버 인증서 수신
    -> 인증서 유효기간 확인
    -> 도메인명 일치 확인
    -> 인증서 체인 검증 (서버 인증서 → 중간 CA → 루트 CA)
    -> OCSP/CRL로 폐기 여부 확인
    -> 검증 성공 → 자물쇠 아이콘 표시

2. 인증서 종류

검증 레벨별 분류

종류검증 수준발급 시간비용용도
DV (Domain Validation)도메인 소유권만 확인수 분무료~저가개인 블로그, 소규모 서비스
OV (Organization Validation)조직 실재 확인1~3일중간기업 웹사이트
EV (Extended Validation)법적 실체 확인1~2주고가금융, 전자상거래

적용 범위별 분류

종류적용 범위예시비용
단일 도메인하나의 FQDNwww.example.com가장 저렴
와일드카드같은 레벨의 모든 서브도메인*.example.com중간
SAN/멀티도메인여러 도메인을 하나의 인증서에example.com, example.org도메인당 추가 비용

와일드카드 인증서 주의사항:

# *.example.com이 커버하는 범위
www.example.com    # O 커버됨
api.example.com    # O 커버됨
example.com        # X 커버 안 됨 (SAN에 별도 추가 필요)
sub.api.example.com # X 커버 안 됨 (2단계 서브도메인)

3. Let's Encrypt + Certbot 실전

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 (개발용)
brew install certbot

# Docker
docker run -it --rm \
  -v /etc/letsencrypt:/etc/letsencrypt \
  -v /var/lib/letsencrypt:/var/lib/letsencrypt \
  certbot/certbot certonly --help

인증서 발급 방식

Webroot 방식 (서비스 중단 없음)

# Nginx가 실행 중인 상태에서 발급
sudo certbot certonly --webroot \
  -w /var/www/html \
  -d example.com \
  -d www.example.com \
  --email admin@example.com \
  --agree-tos \
  --no-eff-email

# Nginx 설정에 .well-known 경로 추가 필요
# location /.well-known/acme-challenge/ {
#     root /var/www/html;
# }

Standalone 방식 (80번 포트 필요)

# 80번 포트를 사용하는 서비스를 잠시 중단해야 함
sudo systemctl stop nginx
sudo certbot certonly --standalone \
  -d example.com \
  -d www.example.com
sudo systemctl start nginx

DNS 방식 (와일드카드 인증서 발급 시 필수)

# DNS TXT 레코드로 검증
sudo certbot certonly --manual \
  --preferred-challenges dns \
  -d "*.example.com" \
  -d example.com

# 자동화를 위한 DNS 플러그인 사용 (Cloudflare 예시)
sudo pip install certbot-dns-cloudflare

# API 토큰 파일 생성
cat > /etc/letsencrypt/cloudflare.ini << 'EOF'
dns_cloudflare_api_token = YOUR_CLOUDFLARE_API_TOKEN
EOF
chmod 600 /etc/letsencrypt/cloudflare.ini

# Cloudflare DNS로 자동 발급
sudo certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  -d "*.example.com" \
  -d example.com

발급된 인증서 파일 구조

/etc/letsencrypt/live/example.com/
├── cert.pem       # 서버 인증서
├── chain.pem      # 중간 CA 인증서
├── fullchain.pem  # cert.pem + chain.pem (Nginx에서 사용)
├── privkey.pem    # 개인키
└── README

4. Nginx SSL 설정 (A+ 등급)

기본 SSL 설정

# /etc/nginx/sites-available/example.com
server {
    listen 80;
    server_name example.com www.example.com;

    # HTTP -> HTTPS 리다이렉트
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name example.com www.example.com;

    # 인증서 설정
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    # SSL 프로토콜 (TLS 1.2, 1.3만 허용)
    ssl_protocols TLSv1.2 TLSv1.3;

    # 암호 스위트 (서버 우선)
    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 커브
    ssl_ecdh_curve X25519:prime256v1:secp384r1;

    # SSL 세션 캐시
    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 파라미터 (2048비트 이상)
    ssl_dhparam /etc/nginx/dhparam.pem;

    # 보안 헤더
    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;
}

DH 파라미터 생성

# 2048비트 DH 파라미터 생성 (약 1~2분 소요)
sudo openssl dhparam -out /etc/nginx/dhparam.pem 2048

# 4096비트 (더 안전하지만 5~10분 소요)
sudo openssl dhparam -out /etc/nginx/dhparam.pem 4096

SSL Labs 테스트

# 설정 검증
sudo nginx -t

# 설정 적용
sudo systemctl reload nginx

# SSL Labs에서 A+ 등급 확인
# https://www.ssllabs.com/ssltest/analyze.html?d=example.com

# CLI로 확인
curl -sS https://api.ssllabs.com/api/v3/analyze?host=example.com | jq '.endpoints[0].grade'

5. AWS ACM 인증서 관리

ACM 인증서 발급

# AWS CLI로 인증서 요청
aws acm request-certificate \
  --domain-name example.com \
  --subject-alternative-names "*.example.com" \
  --validation-method DNS \
  --region ap-northeast-2

# 검증 상태 확인
aws acm describe-certificate \
  --certificate-arn arn:aws:acm:ap-northeast-2:123456789012:certificate/abc-123 \
  --query 'Certificate.DomainValidationOptions'

Terraform으로 ACM + Route53 자동화

# ACM 인증서 리소스
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"
  }
}

# Route53 DNS 검증 레코드 자동 생성
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
}

# 인증서 검증 완료 대기
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]
}

# 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 비교

항목AWS ACMLet's Encrypt
비용무료 (AWS 서비스 연동 시)무료
유효기간13개월 (자동 갱신)90일 (자동 갱신 필요)
와일드카드지원지원 (DNS 검증 필수)
사용 범위AWS ALB, CloudFront, API GW어디서든 사용 가능
인증서 내보내기불가 (Private CA 제외)가능
관리 부담거의 없음Certbot 관리 필요

6. 인증서 자동 갱신

Cron을 이용한 갱신

# Certbot 갱신 테스트
sudo certbot renew --dry-run

# Cron 작업 등록
sudo crontab -e

# 매일 새벽 3시에 갱신 확인 (30일 이내 만료 시 자동 갱신)
0 3 * * * /usr/bin/certbot renew --quiet --deploy-hook "systemctl reload nginx"

Systemd Timer를 이용한 갱신 (권장)

# /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

# 활성화
sudo systemctl daemon-reload
sudo systemctl enable --now certbot-renewal.timer

# 타이머 상태 확인
sudo systemctl list-timers | grep certbot

갱신 모니터링 스크립트

#!/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 인증서 만료 경고: $domain - ${days_left}일 남음\"}"
    fi
done
# 실행 권한 부여 및 cron 등록
chmod +x /usr/local/bin/check-ssl-expiry.sh
echo "0 9 * * * /usr/local/bin/check-ssl-expiry.sh" | sudo crontab -

7. 인증서 디버깅 명령어 모음

# 인증서 정보 확인
openssl x509 -in /etc/letsencrypt/live/example.com/cert.pem -text -noout

# 만료일만 확인
openssl x509 -in /etc/letsencrypt/live/example.com/cert.pem -noout -enddate

# 원격 서버 인증서 확인
echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null \
    | openssl x509 -noout -text

# 인증서 체인 확인
echo | openssl s_client -showcerts -servername example.com -connect example.com:443 2>/dev/null

# 인증서와 개인키 매칭 확인
openssl x509 -noout -modulus -in cert.pem | openssl md5
openssl rsa -noout -modulus -in privkey.pem | openssl md5
# 두 값이 같으면 매칭됨

# 인증서 SAN 확인
openssl x509 -in cert.pem -noout -ext subjectAltName

# PEM → PFX(PKCS12) 변환
openssl pkcs12 -export -out cert.pfx \
    -inkey privkey.pem -in cert.pem -certfile chain.pem

# PFX → PEM 변환
openssl pkcs12 -in cert.pfx -out cert.pem -nodes

8. 트러블슈팅

문제 1: 인증서 만료 (ERR_CERT_DATE_INVALID)

# 현재 만료일 확인
echo | openssl s_client -connect example.com:443 2>/dev/null \
    | openssl x509 -noout -dates

# 즉시 갱신
sudo certbot renew --force-renewal --cert-name example.com
sudo systemctl reload nginx

문제 2: 인증서 체인 불완전 (ERR_CERT_AUTHORITY_INVALID)

# 체인 검증
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt \
    /etc/letsencrypt/live/example.com/fullchain.pem

# Nginx에서 fullchain.pem을 사용하고 있는지 확인
# 잘못된 설정:
# ssl_certificate /etc/letsencrypt/live/example.com/cert.pem;
# 올바른 설정:
# ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;

문제 3: Mixed Content 경고

# HTML에서 http:// 리소스 찾기
grep -rn "http://" /var/www/html/ --include="*.html" --include="*.js" --include="*.css"

# CSP 헤더로 자동 업그레이드
# Nginx 설정에 추가:
# add_header Content-Security-Policy "upgrade-insecure-requests;" always;

문제 4: SSL Handshake 실패

# 지원하는 프로토콜 확인
nmap --script ssl-enum-ciphers -p 443 example.com

# 특정 TLS 버전으로 연결 테스트
openssl s_client -connect example.com:443 -tls1_2
openssl s_client -connect example.com:443 -tls1_3

# 클라이언트가 지원하지 않는 암호 스위트 문제
# → ssl_ciphers 설정에 호환성 있는 스위트 추가

문제 5: HSTS 관련 이슈

# HSTS 헤더가 설정되어 있으면 HTTP로 돌아갈 수 없음
# 테스트 시에는 max-age를 짧게 설정
add_header Strict-Transport-Security "max-age=300" always;

# 프로덕션에서는 충분히 길게
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

문제 6: Let's Encrypt Rate Limit

# Rate Limit 확인
# - 동일 도메인: 주당 5회 발급 (갱신 제외)
# - 중복 인증서: 주당 5회
# - 계정당: 3시간에 300개 요청

# 테스트 시에는 staging 서버 사용
sudo certbot certonly --staging \
  --webroot -w /var/www/html \
  -d test.example.com

# staging 인증서 삭제 후 본 발급
sudo certbot delete --cert-name test.example.com

9. 고급 설정

mTLS (상호 TLS 인증)

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;

    # 클라이언트 인증서 요구
    ssl_client_certificate /etc/nginx/ssl/ca.crt;
    ssl_verify_client on;
    ssl_verify_depth 2;

    location / {
        # 클라이언트 인증서 정보를 백엔드로 전달
        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;
    }
}

클라이언트 인증서 생성

# CA 키 및 인증서 생성
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"

# 클라이언트 키 및 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"

# 클라이언트 인증서 발급
openssl x509 -req -days 365 \
    -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
    -out client.crt

# 테스트
curl --cert client.crt --key client.key https://api.example.com/

Certificate Transparency (CT) 로그 확인

# CT 로그에서 도메인 인증서 검색
# https://crt.sh/?q=example.com

# CLI로 확인
curl -s "https://crt.sh/?q=example.com&output=json" | jq '.[0:5] | .[] | {id, name_value, not_after}'

10. 운영 체크리스트

초기 설정 체크리스트

  • TLS 1.2 이상만 허용 (ssl_protocols TLSv1.2 TLSv1.3)
  • 강력한 암호 스위트만 사용 (ECDHE + AES-GCM/ChaCha20)
  • HSTS 헤더 설정 (Strict-Transport-Security)
  • HTTP → HTTPS 리다이렉트 (301)
  • OCSP Stapling 활성화
  • DH 파라미터 2048비트 이상 생성
  • SSL Labs에서 A+ 등급 확인
  • Mixed Content 없는지 확인

자동 갱신 체크리스트

  • Certbot 자동 갱신 설정 (cron 또는 systemd timer)
  • 갱신 테스트 (certbot renew --dry-run)
  • 갱신 후 Nginx reload hook 설정
  • 만료 모니터링 스크립트 배포
  • Slack/Email 알림 설정 (만료 14일 전)

정기 점검 체크리스트

  • 매월 인증서 만료일 확인
  • 분기별 SSL Labs 재점검
  • 새 보안 취약점 패치 확인 (OpenSSL, Nginx)
  • CT 로그에서 비정상 인증서 발급 모니터링
  • 보안 헤더 변경사항 점검

인시던트 대응 체크리스트

  • 인증서 만료 시 즉시 갱신 절차 문서화
  • 인증서 폐기(revoke) 절차 숙지
  • 긴급 인증서 재발급 절차 준비
  • 롤백 계획 수립 (이전 인증서 백업)
  • 인시던트 발생 시 알림 채널 지정

마무리

SSL/TLS 인증서 관리는 한 번 설정하면 끝이 아닙니다. Let's Encrypt의 90일 유효기간 정책은 자동화를 강제하는 좋은 관행입니다. 이 글에서 다룬 내용을 바탕으로 안전하고 자동화된 인증서 관리 체계를 구축하시기 바랍니다.

핵심 정리:

  1. TLS 1.3을 기본으로 사용하되, 호환성을 위해 TLS 1.2도 허용
  2. Let's Encrypt + Certbot으로 무료 인증서 자동 발급 및 갱신
  3. Nginx 설정은 SSL Labs A+ 등급을 목표로 최적화
  4. 자동 갱신은 systemd timer로 설정하고 모니터링 필수
  5. 트러블슈팅openssl s_client 명령어로 시작

SSL/TLS Certificate Complete Guide: From Issuance to Auto-Renewal and Troubleshooting

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.

CategorySymmetric EncryptionAsymmetric Encryption
KeySingle key for encrypt/decryptPublic key (encrypt) + Private key (decrypt)
SpeedFastSlow (100~1000x)
AlgorithmAES-256, ChaCha20RSA, ECDSA, Ed25519
PurposeActual data transmissionKey exchange, certificate signing
Key Length128/256 bit2048/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. 1-RTT Handshake: Reduced from 2-RTT in TLS 1.2 to 1-RTT
  2. 0-RTT Resumption: Data can be sent without additional delay on reconnection
  3. Enhanced Security: Removed vulnerable cipher suites (RC4, 3DES, CBC mode, etc.)
  4. 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

TypeValidation LevelIssuance TimeCostUse Case
DV (Domain Validation)Domain ownership onlyMinutesFree~LowPersonal blogs, small services
OV (Organization Validation)Organization existence1~3 daysMediumCorporate websites
EV (Extended Validation)Legal entity verification1~2 weeksHighFinance, e-commerce

Classification by Coverage

TypeCoverageExampleCost
Single DomainOne FQDNwww.example.comLowest
WildcardAll subdomains at the same level*.example.comMedium
SAN/Multi-DomainMultiple domains in one certificateexample.com, example.orgAdditional 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

ItemAWS ACMLet's Encrypt
CostFree (when used with AWS services)Free
Validity13 months (auto-renewal)90 days (auto-renewal required)
WildcardSupportedSupported (DNS validation required)
ScopeAWS ALB, CloudFront, API GWCan be used anywhere
Certificate ExportNot possible (except Private CA)Possible
Management OverheadAlmost noneCertbot 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"
# /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
# 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:

  1. Use TLS 1.3 as default, while allowing TLS 1.2 for compatibility
  2. Let's Encrypt + Certbot for free certificate automated issuance and renewal
  3. Nginx configuration optimized for SSL Labs A+ grade
  4. Auto-renewal configured with systemd timer with mandatory monitoring
  5. Troubleshooting starts with the openssl s_client command

Quiz

Q1: What is the main topic covered in "SSL/TLS Certificate Complete Guide: From Issuance to Auto-Renewal and Troubleshooting"?

A comprehensive practical guide covering SSL/TLS certificate fundamentals, Let's Encrypt automated issuance, Nginx/AWS environment configuration, certificate auto-renewal, and common error resolution.

Q2: How SSL/TLS Works? Symmetric vs Asymmetric Encryption SSL/TLS uses both encryption methods together. TLS 1.3 Handshake Process In TLS 1.3, the handshake has been reduced to 1-RTT.

Q3: Explain the core concept of Certificate Types. Classification by Validation Level Classification by Coverage Wildcard certificate considerations:

Q4: What are the key aspects of Let's Encrypt + Certbot in Practice? Installing Certbot Certificate Issuance Methods Webroot Method (No Service Downtime) Standalone Method (Requires Port 80) DNS Method (Required for Wildcard Certificates) Issued Certificate File Structure

Q5: What are the key steps for Nginx SSL Configuration (A+ Grade)? Basic SSL Configuration Generating DH Parameters SSL Labs Test