Docker 환경 SSL 설정
운영 환경에서 HTTPS는 선택이 아닌 필수입니다. Docker 환경에서는 Certbot 컨테이너와 Nginx 컨테이너가 볼륨을 공유하는 방식으로 Let's Encrypt 인증서를 발급하고 자동으로 갱신할 수 있습니다. 이 챕터에서는 개발 환경용 self-signed 인증서부터 프로덕션용 Let's Encrypt 자동화까지 완전한 SSL 설정 흐름을 다룹니다.
Let's Encrypt + Certbot 컨테이너 개요
Let's Encrypt는 무료로 TLS 인증서를 발급해 주는 공인 인증기관(CA)입니다. Certbot은 Let's Encrypt와 통신해 인증서를 자동 발급·갱신하는 공식 CLI 도구이며, certbot/certbot Docker 이미지를 사용하면 로컬에 Certbot을 설치하지 않고도 컨테이너로 실행할 수 있습니다.
인증서 발급 흐름은 다음과 같습니다.
- Certbot이 Let's Encrypt 서버에 도메인 소유권 증명 요청을 보냅니다.
- Let's Encrypt가
http://<도메인>/.well-known/acme-challenge/경로로 검증 요청을 보냅니다. - Nginx가 해당 경로를 Certbot이 마운트한 webroot 디렉터리로 프록시합니다.
- 검증 성공 시 인증서가 발급되어 공유 볼륨에 저장됩니다.
- Nginx가 볼륨에서 인증서를 읽어 HTTPS를 제공합니다.
디렉터리 구조
project/
├── docker-compose.yml
├── nginx/
│ ├── conf.d/
│ │ ├── default.conf # HTTP (초기 발급용)
│ │ └── ssl.conf # HTTPS (인증서 발급 후 사용)
│ └── nginx.conf
├── certbot/
│ └── renew.sh # 자동 갱신 스크립트
└── .env
개발 환경: self-signed 인증서로 HTTPS 테스트
프로덕션 도메인이 없는 개발 환경에서는 OpenSSL로 self-signed 인증서를 생성해 HTTPS를 테스트합니다.
# 인증서 저장 디렉터리 생성
mkdir -p ./certs
# self-signed 인증서 생성 (10년 유효)
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
-keyout ./certs/privkey.pem \
-out ./certs/fullchain.pem \
-subj "/C=KR/ST=Seoul/L=Seoul/O=Dev/CN=localhost"
개발용 Nginx 설정 파일 nginx/conf.d/dev-ssl.conf:
server {
listen 80;
server_name localhost;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name localhost;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
proxy_pass http://app:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
개발 환경용 docker-compose.dev.yml:
version: "3.9"
services:
nginx:
image: nginx:1.25-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d/dev-ssl.conf:/etc/nginx/conf.d/default.conf:ro
- ./certs:/etc/nginx/certs:ro
depends_on:
- app
app:
image: my-app:latest
expose:
- "8080"
프로덕션 환경: Let's Encrypt 인증서 발급
1단계: HTTP 전용 초기 Nginx 설정
인증서 발급 전에는 HTTP만 허용하는 Nginx 설정이 필요합니다. ACME 챌린지 경로를 webroot로 연결합니다.
nginx/conf.d/default.conf:
server {
listen 80;
server_name example.com www.example.com;
# Certbot ACME 챌린지 경로
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# 인증서 발급 후 HTTPS로 리다이렉트 (초기에는 주석 처리)
# return 301 https://$host$request_uri;
location / {
proxy_pass http://app:8080;
}
}
2단계: 인증서 발급용 docker-compose.yml
version: "3.9"
services:
nginx:
image: nginx:1.25-alpine
container_name: nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- certbot_webroot:/var/www/certbot
- certbot_certs:/etc/letsencrypt
depends_on:
- app
certbot:
image: certbot/certbot:latest
container_name: certbot
volumes:
- certbot_webroot:/var/www/certbot
- certbot_certs:/etc/letsencrypt
# 컨테이너는 명령 실행 후 종료됨 (일회성)
entrypoint: >
sh -c "certbot certonly
--webroot
--webroot-path=/var/www/certbot
--email admin@example.com
--agree-tos
--no-eff-email
-d example.com
-d www.example.com"
app:
image: my-app:latest
container_name: app
restart: unless-stopped
expose:
- "8080"
environment:
- NODE_ENV=production
volumes:
certbot_webroot:
certbot_certs:
인증서 최초 발급 명령:
# Nginx와 앱 먼저 실행
docker compose up -d nginx app
# Certbot 실행하여 인증서 발급
docker compose run --rm certbot
# 발급 확인
docker compose exec nginx ls /etc/letsencrypt/live/example.com/
# 출력: cert.pem chain.pem fullchain.pem privkey.pem README
3단계: HTTPS Nginx 설정 추가
nginx/conf.d/ssl.conf:
# HTTP → HTTPS 리다이렉트
server {
listen 80;
server_name example.com www.example.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS 서버
server {
listen 443 ssl http2;
server_name example.com www.example.com;
# Let's Encrypt 인증서 경로 (컨테이너 내부 기준)
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# 보안 강화 SSL 파라미터
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m;
ssl_session_tickets off;
# HSTS (브라우저가 항상 HTTPS로 접속하도록 강제)
add_header Strict-Transport-Security "max-age=63072000" always;
# 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;
location / {
proxy_pass http://app:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection keep-alive;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
인증서 자동 갱신
Let's Encrypt 인증서는 90일마다 만료됩니다. 크론 스크립트로 자동 갱신을 설정합니다.
certbot/renew.sh:
#!/bin/bash
# 인증서 갱신 스크립트 — 크론으로 주기적 실행
set -e
COMPOSE_FILE="/opt/project/docker-compose.yml"
LOG_FILE="/var/log/certbot-renew.log"
echo "$(date '+%Y-%m-%d %H:%M:%S') 인증서 갱신 시작" >> "$LOG_FILE"
# certbot renew 실행 (만료 30일 이내인 경우만 갱신)
docker compose -f "$COMPOSE_FILE" run --rm certbot renew --quiet 2>> "$LOG_FILE"
if [ $? -eq 0 ]; then
echo "$(date '+%Y-%m-%d %H:%M:%S') 인증서 갱신 성공, Nginx 리로드 중..." >> "$LOG_FILE"
# Nginx 무중단 리로드 (기존 연결 유지)
docker compose -f "$COMPOSE_FILE" exec nginx nginx -s reload
echo "$(date '+%Y-%m-%d %H:%M:%S') Nginx 리로드 완료" >> "$LOG_FILE"
else
echo "$(date '+%Y-%m-%d %H:%M:%S') 인증서 갱신 실패" >> "$LOG_FILE"
exit 1
fi
크론 등록 (호스트 서버에서):
# 스크립트 실행 권한 부여
chmod +x /opt/project/certbot/renew.sh
# 크론탭 편집
crontab -e
# 매일 새벽 3시에 실행 (Let's Encrypt 권장: 하루 2회 이상)
0 3 * * * /opt/project/certbot/renew.sh >> /var/log/certbot-cron.log 2>&1
0 15 * * * /opt/project/certbot/renew.sh >> /var/log/certbot-cron.log 2>&1
docker-compose.yml의 certbot 서비스에 renew 명령을 지정하면 별도 명령 없이 갱신만 실행합니다:
services:
certbot:
image: certbot/certbot:latest
volumes:
- certbot_webroot:/var/www/certbot
- certbot_certs:/etc/letsencrypt
# 기본 entrypoint: certbot renew (갱신 전용)
command: renew
완전한 프로덕션 docker-compose.yml
version: "3.9"
services:
nginx:
image: nginx:1.25-alpine
container_name: nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- certbot_webroot:/var/www/certbot
- certbot_certs:/etc/letsencrypt:ro
networks:
- frontend
depends_on:
app:
condition: service_healthy
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "5"
certbot:
image: certbot/certbot:latest
container_name: certbot
volumes:
- certbot_webroot:/var/www/certbot
- certbot_certs:/etc/letsencrypt
networks:
- frontend
app:
image: my-app:latest
container_name: app
restart: unless-stopped
expose:
- "8080"
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
networks:
- frontend
- backend
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
db:
image: postgres:16-alpine
container_name: db
restart: unless-stopped
environment:
- POSTGRES_DB=${DB_NAME}
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
volumes:
certbot_webroot:
certbot_certs:
postgres_data:
networks:
frontend:
backend:
internal: true
Nginx 무중단 리로드
인증서 갱신 후 Nginx를 완전히 재시작하면 기존 연결이 끊깁니다. nginx -s reload를 사용하면 새 워커 프로세스가 새 인증서를 로드하는 동안 기존 워커가 처리 중인 요청을 완료합니다.
# 컨테이너 내부에서 직접 리로드
docker exec nginx nginx -s reload
# docker compose 명령으로 리로드
docker compose exec nginx nginx -s reload
# 설정 파일 문법 검사 후 리로드
docker compose exec nginx nginx -t && docker compose exec nginx nginx -s reload
트러블슈팅
| 문제 | 원인 | 해결 방법 |
|---|---|---|
too many requests | Let's Encrypt 발급 횟수 제한 초과 | --staging 옵션으로 테스트 후 실제 발급 |
| ACME 챌린지 실패 | 방화벽에서 80 포트 차단 | 방화벽에서 80, 443 포트 오픈 |
| 인증서 경로 오류 | 볼륨 마운트 경로 불일치 | docker volume inspect certbot_certs로 경로 확인 |
| Nginx reload 실패 | ssl.conf 문법 오류 | nginx -t로 사전 검사 |
# Let's Encrypt 스테이징 서버로 테스트 (실제 인증서 발급 횟수 소모 없음)
docker compose run --rm certbot certonly \
--webroot \
--webroot-path=/var/www/certbot \
--staging \
--email admin@example.com \
--agree-tos \
-d example.com