본문으로 건너뛰기

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을 설치하지 않고도 컨테이너로 실행할 수 있습니다.

인증서 발급 흐름은 다음과 같습니다.

  1. Certbot이 Let's Encrypt 서버에 도메인 소유권 증명 요청을 보냅니다.
  2. Let's Encrypt가 http://<도메인>/.well-known/acme-challenge/ 경로로 검증 요청을 보냅니다.
  3. Nginx가 해당 경로를 Certbot이 마운트한 webroot 디렉터리로 프록시합니다.
  4. 검증 성공 시 인증서가 발급되어 공유 볼륨에 저장됩니다.
  5. 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 requestsLet'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