하나의 서버에서 여러 개의 WordPress 사이트를 운영하면서 SSL 인증서까지 자동으로 관리하고 싶으신가요? Docker Compose와 Let’s Encrypt를 활용하면 이 모든 것을 간단하게 해결할 수 있습니다.
이번 포스트에서는 Nginx 리버스 프록시 + 멀티 WordPress + Certbot SSL 자동 갱신을 Docker Compose로 구축하는 방법을 상세히 알아보겠습니다.
🎯 목표
- 하나의 서버에서 여러 WordPress 사이트 운영
- 각 사이트마다 독립적인 SSL 인증서 적용
- Let’s Encrypt를 통한 SSL 인증서 자동 갱신
- Docker Compose를 활용한 간편한 관리
📁 프로젝트 구조
먼저 우리가 구축할 프로젝트의 디렉터리 구조를 살펴보겠습니다:
wordpress-multi/
├── docker-compose.yml
├── init-letsencrypt-multi.sh
├── nginx/
│ ├── nginx.conf
│ └── conf.d/
│ ├── blog.conf # blog.mydomain.com
│ └── shop.conf # shop.mydomain.com
└── certbot/
├── conf/ # SSL 인증서 저장소
└── www/ # ACME 챌린지용
WordPress는 컨테이너 내에서 모든 기능을 처리하므로 별도의 파일 디렉터리가 필요하지 않습니다. 매우 깔끔한 구조죠!
🐳 Docker Compose 설정
docker-compose.yml
version: '3.8'
services:
nginx:
image: nginx:alpine
container_name: nginx-proxy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
depends_on:
- certbot
- blog-wordpress
- shop-wordpress
networks:
- web
certbot:
image: certbot/certbot
container_name: certbot
restart: unless-stopped
volumes:
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
networks:
- web
# 블로그 WordPress (blog.mydomain.com)
blog-wordpress:
image: wordpress:latest
container_name: blog-wordpress
restart: unless-stopped
environment:
WORDPRESS_DB_HOST: blog-db
WORDPRESS_DB_USER: blog_user
WORDPRESS_DB_PASSWORD: blog_secure_password
WORDPRESS_DB_NAME: blog_db
WORDPRESS_CONFIG_EXTRA: |
define('WP_HOME', 'https://blog.mydomain.com');
define('WP_SITEURL', 'https://blog.mydomain.com');
define('FORCE_SSL_ADMIN', true);
volumes:
- blog_wordpress_data:/var/www/html
depends_on:
- blog-db
networks:
- web
# 블로그 데이터베이스
blog-db:
image: mysql:8.0
container_name: blog-db
restart: unless-stopped
environment:
MYSQL_DATABASE: blog_db
MYSQL_USER: blog_user
MYSQL_PASSWORD: blog_secure_password
MYSQL_ROOT_PASSWORD: blog_root_password
volumes:
- blog_db_data:/var/lib/mysql
networks:
- web
# 쇼핑몰 WordPress (shop.mydomain.com)
shop-wordpress:
image: wordpress:latest
container_name: shop-wordpress
restart: unless-stopped
environment:
WORDPRESS_DB_HOST: shop-db
WORDPRESS_DB_USER: shop_user
WORDPRESS_DB_PASSWORD: shop_secure_password
WORDPRESS_DB_NAME: shop_db
WORDPRESS_CONFIG_EXTRA: |
define('WP_HOME', 'https://shop.mydomain.com');
define('WP_SITEURL', 'https://shop.mydomain.com');
define('FORCE_SSL_ADMIN', true);
volumes:
- shop_wordpress_data:/var/www/html
depends_on:
- shop-db
networks:
- web
# 쇼핑몰 데이터베이스
shop-db:
image: mysql:8.0
container_name: shop-db
restart: unless-stopped
environment:
MYSQL_DATABASE: shop_db
MYSQL_USER: shop_user
MYSQL_PASSWORD: shop_secure_password
MYSQL_ROOT_PASSWORD: shop_root_password
volumes:
- shop_db_data:/var/lib/mysql
networks:
- web
networks:
web:
driver: bridge
volumes:
blog_wordpress_data:
blog_db_data:
shop_wordpress_data:
shop_db_data:
🌐 Nginx 설정
nginx/nginx.conf
기본 Nginx 설정 파일입니다:
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# SSL Settings
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
ssl_session_timeout 10m;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
# Security Headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
# Gzip Settings
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private must-revalidate auth;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/x-javascript
application/xml+rss
application/javascript
application/json;
include /etc/nginx/conf.d/*.conf;
}
nginx/conf.d/blog.conf
블로그 사이트용 설정:
# HTTP 서버 - HTTPS로 리다이렉트
server {
listen 80;
server_name blog.mydomain.com;
# Let's Encrypt ACME 챌린지
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# HTTPS로 리다이렉트
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS 서버 - WordPress Blog
server {
listen 443 ssl http2;
server_name blog.mydomain.com;
# SSL 인증서 설정
ssl_certificate /etc/letsencrypt/live/blog.mydomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/blog.mydomain.com/privkey.pem;
# SSL 보안 설정
ssl_session_timeout 1d;
ssl_session_cache shared:MozTLS:10m;
ssl_session_tickets off;
# HSTS
add_header Strict-Transport-Security "max-age=63072000" always;
# OCSP stapling
ssl_stapling on;
ssl_stapling_verify on;
# WordPress 특화 설정
client_max_body_size 100M;
# WordPress 프록시 설정
location / {
proxy_pass http://blog-wordpress:80;
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_set_header X-Forwarded-Host $host;
# WordPress 특화 헤더
proxy_redirect off;
proxy_buffering off;
}
# Let's Encrypt ACME 챌린지
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
}
nginx/conf.d/shop.conf
쇼핑몰 사이트용 설정:
# HTTP 서버 - HTTPS로 리다이렉트
server {
listen 80;
server_name shop.mydomain.com;
# Let's Encrypt ACME 챌린지
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# HTTPS로 리다이렉트
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS 서버 - WordPress Shop
server {
listen 443 ssl http2;
server_name shop.mydomain.com;
# SSL 인증서 설정
ssl_certificate /etc/letsencrypt/live/shop.mydomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/shop.mydomain.com/privkey.pem;
# SSL 보안 설정
ssl_session_timeout 1d;
ssl_session_cache shared:MozTLS:10m;
ssl_session_tickets off;
# HSTS
add_header Strict-Transport-Security "max-age=63072000" always;
# OCSP stapling
ssl_stapling on;
ssl_stapling_verify on;
# WordPress 특화 설정
client_max_body_size 100M;
# WordPress 프록시 설정
location / {
proxy_pass http://shop-wordpress:80;
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_set_header X-Forwarded-Host $host;
# WordPress 특화 헤더
proxy_redirect off;
proxy_buffering off;
}
# Let's Encrypt ACME 챌린지
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
}
🔐 SSL 인증서 자동 발급 스크립트
init-letsencrypt-multi.sh
#!/bin/bash
# 멀티 도메인 설정
declare -A domains_config=(
["blog.mydomain.com"]="blog.mydomain.com"
["shop.mydomain.com"]="shop.mydomain.com"
)
rsa_key_size=4096
data_path="./certbot"
email="admin@mydomain.com" # 실제 이메일로 변경
staging=0 # 테스트: 1, 운영: 0
# 색상 정의
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
echo -e "${GREEN}Let's Encrypt 멀티 도메인 인증서 초기화 시작${NC}"
# 기존 데이터 확인
if [ -d "$data_path" ]; then
read -p "기존 데이터가 발견되었습니다. 계속하시겠습니까? (y/N) " decision
if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then
exit
fi
fi
# TLS 매개변수 다운로드
if [ ! -e "$data_path/conf/options-ssl-nginx.conf" ] || [ ! -e "$data_path/conf/ssl-dhparams.pem" ]; then
echo -e "${YELLOW}### TLS 매개변수 다운로드 중...${NC}"
mkdir -p "$data_path/conf"
curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf > "$data_path/conf/options-ssl-nginx.conf"
curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem > "$data_path/conf/ssl-dhparams.pem"
echo
fi
# 각 도메인별 더미 인증서 생성
for domain in "${!domains_config[@]}"; do
echo -e "${YELLOW}### 더미 인증서 생성 중: ${domain}${NC}"
path="/etc/letsencrypt/live/$domain"
mkdir -p "$data_path/conf/live/$domain"
docker-compose run --rm --entrypoint "\
openssl req -x509 -nodes -newkey rsa:$rsa_key_size -days 1\
-keyout '$path/privkey.pem' \
-out '$path/fullchain.pem' \
-subj '/CN=localhost'" certbot
echo
done
# Nginx 시작
echo -e "${YELLOW}### Nginx 시작 중...${NC}"
docker-compose up --force-recreate -d nginx
# 각 도메인별 실제 인증서 발급
for domain in "${!domains_config[@]}"; do
echo -e "${BLUE}### 처리 중인 도메인: ${domain}${NC}"
# 기존 더미 인증서 삭제
echo -e "${YELLOW}### 더미 인증서 삭제 중: ${domain}${NC}"
docker-compose run --rm --entrypoint "\
rm -Rf /etc/letsencrypt/live/$domain && \
rm -Rf /etc/letsencrypt/archive/$domain && \
rm -Rf /etc/letsencrypt/renewal/$domain.conf" certbot
# 실제 인증서 요청
echo -e "${YELLOW}### 실제 인증서 요청 중: ${domain}${NC}"
case "$email" in
"") email_arg="--register-unsafely-without-email" ;;
*) email_arg="--email $email" ;;
esac
if [ $staging != "0" ]; then staging_arg="--staging"; fi
docker-compose run --rm --entrypoint "\
certbot certonly --webroot -w /var/www/certbot \
$staging_arg \
$email_arg \
-d $domain \
--rsa-key-size $rsa_key_size \
--agree-tos \
--force-renewal" certbot
if [ $? -eq 0 ]; then
echo -e "${GREEN}### ${domain} 인증서 발급 성공!${NC}"
else
echo -e "${RED}### ${domain} 인증서 발급 실패!${NC}"
fi
done
# Nginx 재시작
echo -e "${YELLOW}### Nginx 재시작 중...${NC}"
docker-compose exec nginx nginx -s reload
echo -e "${GREEN}### 완료! 모든 도메인의 인증서가 처리되었습니다.${NC}"
echo -e "${GREEN}### 인증서는 12시간마다 자동으로 갱신됩니다.${NC}"
🚀 설치 및 실행 가이드
1. 사전 준비
DNS 설정
blog.mydomain.com A YOUR_SERVER_IP
shop.mydomain.com A YOUR_SERVER_IP
서버 환경
- Docker 및 Docker Compose 설치
- 80, 443 포트 개방
- 방화벽 설정 확인
2. 설정 파일 수정
도메인 변경
docker-compose.yml
: WordPress 환경변수의 도메인 수정nginx/conf.d/*.conf
:server_name
변경init-letsencrypt-multi.sh
: 도메인 배열 및 이메일 수정
보안 설정
- 데이터베이스 비밀번호 변경
- WordPress 시크릿 키 설정 (선택사항)
3. 인증서 발급 및 실행
# 1. 실행 권한 부여
chmod +x init-letsencrypt-multi.sh
# 2. 테스트 모드로 먼저 실행 (staging=1)
./init-letsencrypt-multi.sh
# 3. 성공 확인 후 운영 모드로 실행 (staging=0)
./init-letsencrypt-multi.sh
# 4. 모든 서비스 시작
docker-compose up -d
# 5. 상태 확인
docker-compose ps
4. WordPress 초기 설정
각 도메인에 접속하여 WordPress 초기 설정을 진행합니다:
https://blog.mydomain.com
– 블로그 설정https://shop.mydomain.com
– 쇼핑몰 설정
🔄 자동 갱신 메커니즘
인증서 갱신 주기
- Certbot: 12시간마다 인증서 갱신 확인
- 실제 갱신: 만료 30일 전부터 갱신 시작
- Nginx 리로드: 6시간마다 설정 리로드
갱신 상태 확인
# 인증서 상태 확인
docker-compose exec certbot certbot certificates
# 수동 갱신 테스트
docker-compose exec certbot certbot renew --dry-run
# 로그 확인
docker-compose logs certbot
🛠️ 관리 및 유지보수
새 도메인 추가하기
- DNS 설정: 새 도메인의 A 레코드 추가
- Nginx 설정: 새
.conf
파일 생성 - Docker Compose: 새 WordPress 서비스 추가
- 스크립트 수정:
domains_config
에 도메인 추가 - 인증서 발급:
./init-letsencrypt-multi.sh
재실행
유용한 명령어
# 전체 서비스 재시작
docker-compose restart
# 특정 서비스만 재시작
docker-compose restart blog-wordpress
# 로그 실시간 확인
docker-compose logs -f nginx
# 데이터베이스 접속
docker-compose exec blog-db mysql -u blog_user -p blog_db
# WordPress 파일 백업
docker-compose exec blog-wordpress tar -czf /tmp/wp-backup.tar.gz /var/www/html
백업 전략
데이터베이스 백업
# 블로그 DB 백업
docker-compose exec blog-db mysqldump -u blog_user -p blog_db > blog_backup.sql
# 쇼핑몰 DB 백업
docker-compose exec shop-db mysqldump -u shop_user -p shop_db > shop_backup.sql
파일 백업
# WordPress 파일 백업
docker run --rm -v wordpress-multi_blog_wordpress_data:/source -v $(pwd):/backup alpine tar -czf /backup/blog_files.tar.gz -C /source .
🔧 문제 해결
자주 발생하는 문제들
1. 인증서 발급 실패
# DNS 전파 확인
nslookup blog.mydomain.com
# 포트 접근성 확인
telnet your-server-ip 80
# Let's Encrypt 제한 확인 (주간 50회 제한)
2. WordPress 연결 오류
# 컨테이너 상태 확인
docker-compose ps
# 네트워크 연결 확인
docker-compose exec nginx ping blog-wordpress
# WordPress 로그 확인
docker-compose logs blog-wordpress
3. SSL 인증서 갱신 실패
# Certbot 로그 확인
docker-compose logs certbot
# 수동 갱신 시도
docker-compose exec certbot certbot renew --force-renewal
🎉 마무리
이제 하나의 서버에서 여러 WordPress 사이트를 SSL과 함께 안전하게 운영할 수 있습니다!
이 구성의 장점:
- ✅ 간편한 관리: Docker Compose로 모든 서비스 통합 관리
- ✅ 자동 SSL: Let’s Encrypt 인증서 자동 갱신
- ✅ 확장성: 새 도메인 추가가 간단
- ✅ 독립성: 각 WordPress 사이트가 완전히 독립적
- ✅ 보안: 최신 SSL/TLS 설정 및 보안 헤더 적용
운영 중 궁금한 점이나 문제가 생기면 Docker 로그를 통해 디버깅하고, 정기적인 백업을 잊지 마세요!
참고 자료: