Docker 환경에서 Let's Encrypt SSL 인증서 자동 갱신 구현하기
웹 서비스를 운영할 때 HTTPS는 선택이 아닌 필수입니다. Let's Encrypt를 사용하면 무료로 SSL 인증서를 발급받을 수 있지만, 90일마다 갱신해야 하는 번거로움이 있습니다. 이 글에서는 Docker 환경에서 Certbot을 활용해 인증서 자동 갱신을 구현하는 방법을 다룹니다. 테스트
목차
전체 아키텍처
┌─────────────────────────────────────────────────────────────┐
│ Client │
└─────────────────────────┬───────────────────────────────────┘
│ HTTPS (443)
▼
┌─────────────────────────────────────────────────────────────┐
│ Nginx Container │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ SSL Termination │ │
│ │ - /etc/nginx/ssl/fullchain.pem │ │
│ │ - /etc/nginx/ssl/privkey.pem │ │
│ └───────────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ ACME Challenge │ │
│ │ - /.well-known/acme-challenge/ → /var/www/certbot │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│Frontend │ │ Backend │ │ MinIO │
│ :3000 │ │ :8080 │ │ :9000 │
└─────────┘ └─────────┘ └─────────┘핵심 구성 요소
| 구성 요소 | 역할 |
|---|---|
| Nginx | SSL 종료(Termination), 리버스 프록시, ACME Challenge 서빙 |
| Certbot | Let's Encrypt 인증서 발급 및 갱신 |
| Cron | 주기적인 인증서 갱신 자동화 |
디렉토리 구조
~/project/
├── nginx/
│ ├── conf.d/
│ │ └── production.conf # Nginx 설정
│ ├── ssl/ # 인증서 저장 위치
│ │ ├── live/
│ │ │ └── example.com/
│ │ │ ├── fullchain.pem
│ │ │ └── privkey.pem
│ │ ├── fullchain.pem # 심볼릭 링크
│ │ └── privkey.pem # 심볼릭 링크
│ └── certbot/
│ └── www/ # ACME Challenge 파일 위치
├── docker-compose.yml
├── docker-compose.prod.yml
└── scripts/
└── init-ssl.sh # SSL 초기화 스크립트Nginx 설정
HTTP → HTTPS 리다이렉트 및 ACME Challenge
# HTTP 서버 (포트 80)
server {
listen 80;
server_name example.com www.example.com;
# Let's Encrypt 인증용 - ACME Challenge
# Certbot이 도메인 소유권을 검증할 때 이 경로를 사용
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# 나머지 모든 요청은 HTTPS로 리다이렉트
location / {
return 301 https://$host$request_uri;
}
}ACME Challenge 동작 방식:
- Certbot이
/var/www/certbot/.well-known/acme-challenge/디렉토리에 랜덤 토큰 파일 생성 - Let's Encrypt 서버가
http://example.com/.well-known/acme-challenge/<token>으로 접근 - 파일 내용 검증 후 도메인 소유권 확인
- 인증서 발급
HTTPS 서버 설정
# HTTPS 서버 (포트 443)
server {
listen 443 ssl http2;
server_name example.com www.example.com;
# SSL 인증서 경로
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
# SSL 프로토콜 설정 (보안 강화)
ssl_protocols TLSv1.2 TLSv1.3;
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 세션 캐싱 (성능 최적화)
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
# OCSP Stapling (인증서 유효성 검증 속도 향상)
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
# HSTS (HTTP Strict Transport Security)
# 브라우저가 향후 2년간 HTTPS만 사용하도록 강제
add_header Strict-Transport-Security "max-age=63072000" always;
# 보안 헤더
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;
# www → non-www 리다이렉트
if ($host = www.example.com) {
return 301 https://example.com$request_uri;
}
# 프록시 설정...
}SSL 설정 상세 설명
| 설정 | 설명 |
|---|---|
ssl_protocols TLSv1.2 TLSv1.3 |
취약한 TLS 1.0/1.1 비활성화 |
ssl_session_cache shared:SSL:50m |
SSL 세션을 50MB 공유 캐시에 저장하여 핸드셰이크 오버헤드 감소 |
ssl_stapling on |
OCSP 응답을 Nginx가 캐시하여 클라이언트에 전달 |
HSTS max-age=63072000 |
2년간 HTTPS 강제 (초 단위) |
Docker Compose 구성
docker-compose.prod.yml
services:
nginx:
image: nginx:alpine
ports:
- "80:80" # HTTP (ACME Challenge + 리다이렉트)
- "443:443" # HTTPS
volumes:
# Nginx 설정 파일
- ./nginx/conf.d/production.conf:/etc/nginx/conf.d/default.conf:ro
# SSL 인증서 (읽기 전용)
- ./nginx/ssl:/etc/nginx/ssl:ro
# ACME Challenge 디렉토리 (읽기 전용)
- ./nginx/certbot/www:/var/www/certbot:ro
depends_on:
frontend:
condition: service_started
backend:
condition: service_healthy
restart: unless-stopped볼륨 마운트 설명
| 호스트 경로 | 컨테이너 경로 | 용도 |
|---|---|---|
./nginx/ssl |
/etc/nginx/ssl |
Let's Encrypt 인증서 |
./nginx/certbot/www |
/var/www/certbot |
ACME Challenge 파일 |
:ro (read-only) 플래그: Nginx 컨테이너는 인증서를 읽기만 하고 수정하지 않으므로 보안을 위해 읽기 전용으로 마운트합니다.
SSL 초기화 스크립트
scripts/init-ssl.sh
#!/bin/bash
set -e
# ===========================================
# Let's Encrypt SSL 인증서 초기화 스크립트
# ===========================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
DOMAIN="example.com"
EMAIL="admin@example.com"
# 색상 정의
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# 자체 서명 인증서 생성 (초기 nginx 시작용)
create_dummy_cert() {
log_info "임시 자체 서명 인증서 생성 중..."
mkdir -p "$PROJECT_DIR/nginx/ssl"
# OpenSSL로 임시 인증서 생성 (1일 유효)
openssl req -x509 -nodes -newkey rsa:4096 -days 1 \
-keyout "$PROJECT_DIR/nginx/ssl/privkey.pem" \
-out "$PROJECT_DIR/nginx/ssl/fullchain.pem" \
-subj "/CN=$DOMAIN"
log_info "임시 인증서 생성 완료"
}
# Let's Encrypt 인증서 발급
issue_cert() {
log_info "Let's Encrypt 인증서 발급 중..."
# certbot Docker 컨테이너로 인증서 발급
docker run --rm \
-v "$PROJECT_DIR/nginx/ssl:/etc/letsencrypt/live/$DOMAIN" \
-v "$PROJECT_DIR/nginx/certbot/www:/var/www/certbot" \
certbot/certbot certonly \
--webroot \
--webroot-path=/var/www/certbot \
--email "$EMAIL" \
--agree-tos \
--no-eff-email \
-d "$DOMAIN" \
-d "www.$DOMAIN"
log_info "인증서 발급 완료"
}
# 인증서 갱신
renew_cert() {
log_info "인증서 갱신 중..."
docker run --rm \
-v "$PROJECT_DIR/nginx/ssl:/etc/letsencrypt" \
-v "$PROJECT_DIR/nginx/certbot/www:/var/www/certbot" \
certbot/certbot renew
# nginx 설정 리로드 (무중단)
docker exec blog-nginx nginx -s reload
log_info "인증서 갱신 완료"
}
# 사용법
usage() {
echo "사용법: $0 {init|issue|renew}"
echo ""
echo "Commands:"
echo " init - 초기 설정 (임시 인증서 생성)"
echo " issue - Let's Encrypt 인증서 발급"
echo " renew - 인증서 갱신"
}
# 메인
case "${1:-}" in
init)
create_dummy_cert
;;
issue)
issue_cert
;;
renew)
renew_cert
;;
*)
usage
exit 1
;;
esac스크립트 사용 방법
# 1. 실행 권한 부여
chmod +x scripts/init-ssl.sh
# 2. 임시 인증서 생성 (Nginx 최초 시작용)
./scripts/init-ssl.sh init
# 3. Let's Encrypt 인증서 발급
./scripts/init-ssl.sh issue
# 4. 인증서 수동 갱신
./scripts/init-ssl.sh renew자동 갱신 Cron 설정
Cron Job 등록
# crontab 편집
crontab -e
# 다음 라인 추가 (매일 새벽 3시 실행)
0 3 * * * docker run --rm \
-v ~/project/nginx/ssl:/etc/letsencrypt \
-v ~/project/nginx/certbot/www:/var/www/certbot \
certbot/certbot renew --quiet && \
docker exec blog-nginx nginx -s reloadCron 표현식 설명
0 3 * * *
│ │ │ │ │
│ │ │ │ └── 요일 (0-7, 0과 7은 일요일)
│ │ │ └──── 월 (1-12)
│ │ └────── 일 (1-31)
│ └──────── 시 (0-23)
└────────── 분 (0-59)
= 매일 새벽 3시 0분에 실행갱신 프로세스 상세
┌─────────────────────────────────────────────────────────────┐
│ Cron Job (매일 03:00) │
└─────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ certbot/certbot renew --quiet │
│ │
│ 1. 인증서 만료일 확인 │
│ 2. 만료 30일 이내인 경우에만 갱신 시도 │
│ 3. ACME Challenge 수행 │
│ - /.well-known/acme-challenge/에 토큰 파일 생성 │
│ - Let's Encrypt 서버가 해당 파일 접근하여 검증 │
│ 4. 새 인증서 발급 및 저장 │
└─────────────────────────┬───────────────────────────────────┘
│ 성공 시
▼
┌─────────────────────────────────────────────────────────────┐
│ docker exec blog-nginx nginx -s reload │
│ │
│ - Nginx 프로세스 재시작 없이 설정 리로드 │
│ - 새 인증서 즉시 적용 │
│ - 기존 연결 유지 (무중단) │
└─────────────────────────────────────────────────────────────┘왜 매일 실행하나요?
Let's Encrypt 인증서는 90일간 유효하며, Certbot은 만료 30일 이내인 경우에만 갱신을 시도합니다.
- 매일 실행해도 불필요한 갱신은 발생하지 않음
- 갱신 실패 시 재시도 기회 확보 (30일간 매일 시도 가능)
- 서버 다운타임이나 네트워크 문제 대비
nginx -s reload vs restart
| 명령어 | 동작 | 다운타임 |
|---|---|---|
nginx -s reload |
설정 리로드, 기존 연결 유지 | 없음 |
nginx restart |
프로세스 재시작 | 수 초 발생 |
-s reload를 사용하면 무중단으로 새 인증서를 적용할 수 있습니다.
초기 설정 가이드
1단계: 디렉토리 생성
mkdir -p ~/project/nginx/ssl
mkdir -p ~/project/nginx/certbot/www2단계: 임시 인증서로 Nginx 시작
Let's Encrypt 인증서 발급을 위해서는 먼저 Nginx가 실행되어 ACME Challenge를 서빙해야 합니다. 닭과 달걀 문제를 해결하기 위해 임시 자체 서명 인증서를 먼저 생성합니다.
# 임시 nginx 컨테이너로 certbot 챌린지 서빙
docker run -d --name temp-nginx \
-p 80:80 \
-v ~/project/nginx/certbot/www:/var/www/certbot:ro \
nginx:alpine3단계: Let's Encrypt 인증서 발급
docker run -it --rm \
-v ~/project/nginx/ssl:/etc/letsencrypt \
-v ~/project/nginx/certbot/www:/var/www/certbot \
certbot/certbot certonly \
--webroot \
--webroot-path=/var/www/certbot \
-d example.com \
-d www.example.com \
--email your-email@example.com \
--agree-tos \
--no-eff-email옵션 설명:
--webroot: 웹 서버의 특정 디렉토리를 사용하여 도메인 검증-d: 인증서에 포함할 도메인 (여러 개 지정 가능)--agree-tos: Let's Encrypt 서비스 약관 동의--no-eff-email: EFF 뉴스레터 구독 거부
4단계: 심볼릭 링크 생성
# 임시 nginx 제거
docker stop temp-nginx && docker rm temp-nginx
# 인증서 심볼릭 링크 생성
cd ~/project/nginx/ssl
ln -sf live/example.com/fullchain.pem fullchain.pem
ln -sf live/example.com/privkey.pem privkey.pem왜 심볼릭 링크를 사용하나요?
Certbot은 인증서를 live/<domain>/ 디렉토리에 저장하고, 갱신 시 새 인증서를 archive/ 디렉토리에 저장한 후 live/의 심볼릭 링크를 업데이트합니다. Nginx 설정에서 심볼릭 링크를 참조하면 갱신 후에도 설정 변경 없이 새 인증서를 사용할 수 있습니다.
5단계: 프로덕션 서비스 시작
cd ~/project
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d6단계: Cron 등록
crontab -e
# 추가:
# 0 3 * * * docker run --rm -v ~/project/nginx/ssl:/etc/letsencrypt -v ~/project/nginx/certbot/www:/var/www/certbot certbot/certbot renew --quiet && docker exec blog-nginx nginx -s reload문제 해결
인증서 상태 확인
# 인증서 만료일 및 상태 확인
docker run --rm \
-v ~/project/nginx/ssl:/etc/letsencrypt \
certbot/certbot certificates출력 예시:
Certificate Name: example.com
Domains: example.com www.example.com
Expiry Date: 2024-03-15 10:30:00+00:00 (VALID: 89 days)
Certificate Path: /etc/letsencrypt/live/example.com/fullchain.pem
Private Key Path: /etc/letsencrypt/live/example.com/privkey.pemOpenSSL로 인증서 정보 확인
# 인증서 유효 기간 확인
openssl x509 -in ~/project/nginx/ssl/fullchain.pem -text -noout | grep -A2 "Validity"
# 인증서 발급자 확인
openssl x509 -in ~/project/nginx/ssl/fullchain.pem -issuer -noout
# 인증서에 포함된 도메인 확인
openssl x509 -in ~/project/nginx/ssl/fullchain.pem -text -noout | grep -A1 "Subject Alternative Name"갱신 테스트 (Dry Run)
실제 갱신 없이 테스트만 수행:
docker run --rm \
-v ~/project/nginx/ssl:/etc/letsencrypt \
-v ~/project/nginx/certbot/www:/var/www/certbot \
certbot/certbot renew --dry-run성공 시 출력:
Congratulations, all simulated renewals succeeded:
/etc/letsencrypt/live/example.com/fullchain.pem (success)일반적인 문제
| 문제 | 원인 | 해결 방법 |
|---|---|---|
| Challenge 실패 | 포트 80 차단 | 방화벽에서 80 포트 허용 |
| 인증서 찾을 수 없음 | 심볼릭 링크 오류 | ln -sf 명령으로 재생성 |
| Nginx 리로드 실패 | 컨테이너 이름 불일치 | docker ps로 정확한 이름 확인 |
| Rate Limit 초과 | 너무 많은 발급 요청 | 1주일 대기 또는 스테이징 환경 사용 |
| DNS 검증 실패 | DNS 전파 지연 | 잠시 대기 후 재시도 |
Rate Limit 정보
Let's Encrypt Rate Limit:
- 인증서 발급: 동일 도메인 조합에 대해 주당 5회
- 갱신: 갱신은 Rate Limit에 포함되지 않음
- 실패한 검증: 시간당 5회
Rate Limit 우회 (테스트용)
개발/테스트 시에는 스테이징 환경을 사용하세요:
docker run -it --rm \
-v ~/project/nginx/ssl:/etc/letsencrypt \
-v ~/project/nginx/certbot/www:/var/www/certbot \
certbot/certbot certonly \
--webroot \
--webroot-path=/var/www/certbot \
-d example.com \
--staging # 스테이징 환경 (Rate Limit 완화, 신뢰되지 않는 인증서)Cron 로그 확인
# Ubuntu/Debian
grep CRON /var/log/syslog
# CentOS/RHEL
grep CRON /var/log/cron
# systemd 기반 시스템
journalctl -u cron수동으로 갱신 테스트
# 실제 갱신 실행 (verbose 모드)
docker run --rm \
-v ~/project/nginx/ssl:/etc/letsencrypt \
-v ~/project/nginx/certbot/www:/var/www/certbot \
certbot/certbot renew -v보안 고려사항
1. 인증서 파일 권한
# 인증서 디렉토리 권한 확인
ls -la ~/project/nginx/ssl/
# 권장 권한 설정
chmod 755 ~/project/nginx/ssl/
chmod 644 ~/project/nginx/ssl/fullchain.pem
chmod 600 ~/project/nginx/ssl/privkey.pem # 개인키는 제한적 권한2. HSTS Preload
HSTS를 더 강화하려면 preload 리스트에 등록할 수 있습니다:
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;hstspreload.org에서 등록할 수 있습니다.
3. SSL Labs 테스트
설정 후 SSL Labs에서 A+ 등급을 목표로 테스트하세요.
마무리
Docker 환경에서 Let's Encrypt SSL 인증서 자동 갱신은 다음 세 가지 요소로 구성됩니다:
- Nginx 설정: ACME Challenge 경로 노출 및 SSL 설정
- Certbot: Docker 컨테이너로 인증서 발급/갱신
- Cron: 주기적인 갱신 자동화
한 번 설정해두면 인증서 만료 걱정 없이 안정적으로 HTTPS 서비스를 운영할 수 있습니다.
체크리스트
- Nginx에서
/.well-known/acme-challenge/경로 설정 - SSL 인증서 경로 볼륨 마운트
- 초기 인증서 발급 완료
- 심볼릭 링크 생성
- Cron job 등록
- Dry-run 테스트 통과
- SSL Labs에서 A+ 등급 확인
댓글