Docker Compose로 멀티 도메인 WordPress + SSL 자동 갱신 구축하기




하나의 서버에서 여러 개의 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

🛠️ 관리 및 유지보수

새 도메인 추가하기

  1. DNS 설정: 새 도메인의 A 레코드 추가
  2. Nginx 설정: 새 .conf 파일 생성
  3. Docker Compose: 새 WordPress 서비스 추가
  4. 스크립트 수정: domains_config에 도메인 추가
  5. 인증서 발급: ./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 로그를 통해 디버깅하고, 정기적인 백업을 잊지 마세요!


참고 자료:




Leave a Comment