1. 요구사항
가상의 서비스 myservice.com을 배포합니다. 개인 프로젝트나 사내 대시보드에서 흔히 볼 수 있는 구성입니다.
┌──▶ web (Next.js, :3000)
│
인터넷 ──▶ 프록시 (80/443) ───────┼──▶ api-1, api-2 (REST, :8080) ← /api/* (접두사 제거)
myservice.com │
www → 리다이렉트 ├──▶ api-1, api-2 (WebSocket) ← /ws/*
│
└──▶ /admin/* (사내 IP만 허용, Basic 인증)요구사항 체크리스트:
www.myservice.com→myservice.com영구 리다이렉트- HTTPS 자동 발급·갱신, HTTP → HTTPS 리다이렉트, HTTP/2
/api/*→ API 서버 2대로 로드 밸런싱 (/api접두사 제거, least_conn)/ws/*→ API 서버로 WebSocket 프록시/_next/static/*→ 1년 캐시 헤더/admin/*→ 사내 대역(10.0.0.0/8)만 허용 + Basic 인증- 공통 보안 헤더, 응답 압축
/api/*에 IP당 초당 20회 Rate Limit- JSON 액세스 로그
- 백엔드 장애 감지
2. 공통: Docker Compose
# compose.yaml
services:
web:
image: myservice/web:latest # Next.js (output: 'standalone')
expose: ["3000"]
restart: unless-stopped
api-1:
image: myservice/api:latest
expose: ["8080"]
restart: unless-stopped
api-2:
image: myservice/api:latest
expose: ["8080"]
restart: unless-stopped
# ↓ 아래 둘 중 하나만 사용
proxy:
# (A) Nginx
image: nginx:stable-alpine
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/snippets:/etc/nginx/snippets:ro
- ./nginx/.htpasswd:/etc/nginx/.htpasswd:ro
- ./certbot/conf:/etc/letsencrypt:ro
- ./certbot/www:/var/www/certbot:ro
ports: ["80:80", "443:443"]
depends_on: [web, api-1, api-2]
restart: unless-stopped
certbot: # Nginx 사용 시에만 필요
image: certbot/certbot
volumes:
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
entrypoint: >
sh -c "trap exit TERM; while :; do certbot renew --webroot -w /var/www/certbot;
sleep 12h & wait $${!}; done"Caddy 버전의 proxy 서비스는 이렇습니다.
proxy:
# (B) Caddy (Rate Limit 플러그인 포함 커스텀 빌드)
build: ./caddy # Dockerfile은 6편의 xcaddy 멀티 스테이지 빌드
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
ports: ["80:80", "443:443", "443:443/udp"]
environment:
- ADMIN_PASSWORD_HASH=${ADMIN_PASSWORD_HASH}
depends_on: [web, api-1, api-2]
restart: unless-stopped
volumes:
caddy_data:
caddy_config:여기서부터 이미 차이가 보입니다. Nginx는 인증서 발급·갱신을 위한 certbot 컨테이너와 공유 볼륨, 갱신 후 리로드 처리가 필요합니다. (3편의 공식 ACME 모듈을 쓰면 certbot을 없앨 수 있지만, 공식 Docker 이미지에 모듈이 포함되어 있는지 확인하고 별도 설정을 해야 합니다.)
참고: 위 certbot 루프는 갱신만 합니다. 갱신된 인증서를 Nginx가 읽으려면 주기적으로
nginx -s reload가 필요합니다. 흔히 proxy 컨테이너의 command에 6시간마다 reload하는 루프를 넣거나, 호스트 cron으로docker compose exec proxy nginx -s reload를 돌립니다. 최초 인증서 발급도 별도 절차(임시 HTTP 전용 설정으로 띄운 뒤certbot certonly실행)가 필요합니다.
3. Nginx 구성
nginx.conf
user nginx;
worker_processes auto;
worker_rlimit_nofile 65535;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 4096;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
server_tokens off;
sendfile on;
tcp_nopush on;
keepalive_timeout 65;
client_max_body_size 10m;
# Docker 내장 DNS (컨테이너 재생성 시 IP 변경 대응)
resolver 127.0.0.11 valid=10s ipv6=off;
log_format json escape=json
'{"time":"$time_iso8601","ip":"$remote_addr","method":"$request_method",'
'"uri":"$request_uri","status":$status,"bytes":$body_bytes_sent,'
'"rt":$request_time,"urt":"$upstream_response_time","upstream":"$upstream_addr",'
'"ua":"$http_user_agent"}';
access_log /var/log/nginx/access.log json;
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_proxied any;
gzip_vary on;
gzip_types text/plain text/css application/json application/javascript image/svg+xml;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
limit_req_zone $binary_remote_addr zone=api_rl:10m rate=20r/s;
limit_req_status 429;
upstream web_upstream {
server web:3000;
keepalive 16;
}
upstream api_upstream {
least_conn;
server api-1:8080 max_fails=3 fail_timeout=15s;
server api-2:8080 max_fails=3 fail_timeout=15s;
keepalive 32;
}
include /etc/nginx/conf.d/*.conf;
}snippets
# snippets/proxy.conf
proxy_http_version 1.1;
proxy_set_header Connection "";
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;# snippets/security.conf
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;conf.d/myservice.conf
# HTTP: ACME 챌린지 + HTTPS 리다이렉트
server {
listen 80;
server_name myservice.com www.myservice.com;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://myservice.com$request_uri;
}
}
# www → apex
server {
listen 443 ssl;
http2 on;
server_name www.myservice.com;
ssl_certificate /etc/letsencrypt/live/myservice.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/myservice.com/privkey.pem;
return 301 https://myservice.com$request_uri;
}
# 메인
server {
listen 443 ssl;
http2 on;
server_name myservice.com;
ssl_certificate /etc/letsencrypt/live/myservice.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/myservice.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
include snippets/security.conf;
# 3) REST API: 접두사 제거 + 로드 밸런싱 + Rate Limit
location /api/ {
limit_req zone=api_rl burst=40 nodelay;
proxy_pass http://api_upstream/;
include snippets/proxy.conf;
proxy_next_upstream error timeout http_502 http_503;
}
# 4) WebSocket
location /ws/ {
proxy_pass http://api_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 1h;
}
# 5) Next.js 정적 자산
location ^~ /_next/static/ {
proxy_pass http://web_upstream;
include snippets/proxy.conf;
include snippets/security.conf; # add_header 상속 함정 회피!
add_header Cache-Control "public, max-age=31536000, immutable";
}
# 6) 관리자
location /admin/ {
allow 10.0.0.0/8;
deny all;
auth_basic "Admin";
auth_basic_user_file /etc/nginx/.htpasswd;
proxy_pass http://web_upstream;
include snippets/proxy.conf;
}
# 나머지 → Next.js
location / {
proxy_pass http://web_upstream;
include snippets/proxy.conf;
}
}Docker에서의 함정: Nginx는
upstream의 호스트명을 시작 시점에 한 번만 해석합니다.api-1컨테이너가 재생성되어 IP가 바뀌면 Nginx는 예전 IP로 계속 요청합니다(→ 502).upstream블록의server지시어에resolve파라미터를 쓰는 방법(1.27.3부터 오픈소스에서도 지원,resolver필요)이나, 컨테이너 재배포 후 Nginx를 reload하는 방법으로 대응합니다. Caddy는 요청 시점에 DNS를 해석하므로 이 문제가 없습니다.
resolve를 적용하면 upstream은 이렇게 바뀝니다.
upstream api_upstream {
zone api_upstream 64k; # resolve 사용 시 공유 메모리 zone 필수
least_conn;
server api-1:8080 resolve max_fails=3 fail_timeout=15s;
server api-2:8080 resolve max_fails=3 fail_timeout=15s;
keepalive 32;
}합계: 메인 설정 + 스니펫 2개 + 사이트 설정 ≈ 130줄, 그리고 certbot 운영 절차.
4. Caddy 구성
{
email ops@myservice.com
order rate_limit before basic_auth
}
(security) {
header {
Strict-Transport-Security "max-age=63072000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
}
# 1) www → apex
www.myservice.com {
redir https://myservice.com{uri} permanent
}
# 2) 메인 (HTTPS, HTTP→HTTPS, HTTP/2·3 자동)
myservice.com {
import security
encode zstd gzip
log {
output file /data/logs/access.log {
roll_size 100MiB
roll_keep 10
}
format json
}
# 3) REST API
handle_path /api/* {
rate_limit {
zone api {
key {remote_host}
events 20
window 1s
}
}
reverse_proxy api-1:8080 api-2:8080 {
lb_policy least_conn
lb_try_duration 5s
health_uri /health
health_interval 10s
fail_duration 15s
}
}
# 4) WebSocket (Upgrade 헤더 자동 처리)
handle /ws/* {
reverse_proxy api-1:8080 api-2:8080 {
lb_policy least_conn
health_uri /health
}
}
# 6) 관리자: IP 검사 → 인증 → 프록시 순서를 보장하려고 route 사용
@outside_office not remote_ip 10.0.0.0/8
handle /admin/* {
route {
respond @outside_office 403
basic_auth {
admin {$ADMIN_PASSWORD_HASH}
}
reverse_proxy web:3000
}
}
# 5) + 나머지 → Next.js
handle {
header /_next/static/* Cache-Control "public, max-age=31536000, immutable"
reverse_proxy web:3000
}
}지시어 순서 함정 실습: 처음엔 사이트 레벨에
respond @admin_blocked 403을 두고 그 아래handle /admin/*을 썼다고 해봅시다. 기본 순서에서handle이respond보다 앞이라, 관리자 요청은handle에서 처리가 끝나고 403 차단이 절대 실행되지 않습니다. 또handle내부에서도 기본 순서상basic_auth가respond보다 앞이라, 외부 IP에도 인증 창이 먼저 뜹니다. 그래서route로 작성 순서를 강제했습니다. 6편에서 강조한 "작성 순서 ≠ 실행 순서"가 실제로 사고를 내는 지점입니다.
Docker 포트 매핑과 클라이언트 IP: 환경에 따라(예: Docker Desktop, userland-proxy) 프록시 컨테이너가 보는 클라이언트 IP가 Docker 게이트웨이 IP로 바뀔 수 있습니다. 그러면 IP 기반 제한과 Rate Limit이 의도대로 동작하지 않으니, 로그에서 실제 IP가 찍히는지 꼭 확인하세요. 이는 Nginx도 마찬가지입니다.
(basic_auth는 v2.8 이전에는 basicauth라는 이름이었습니다. 비밀번호 해시는 caddy hash-password로 생성합니다.)
합계: ≈ 70줄, 인증서 운영 절차 없음.
5. 나란히 비교
요구사항별 대응
| 요구사항 | Nginx | Caddy |
|---|---|---|
| www 리다이렉트 | 별도 server 블록 + 인증서 지정 | 사이트 블록 + redir |
| HTTPS 발급·갱신 | certbot 컨테이너 + 리로드 루프 (또는 ACME 모듈) | 자동 |
| HTTP→HTTPS | 80 server 블록 수동 | 자동 |
| HTTP/3 | 빌드 옵션 확인 + quic listen + Alt-Svc | 자동 |
| 접두사 제거 | proxy_pass http://up/; (슬래시 규칙) | handle_path |
| 로드 밸런싱 | upstream + least_conn | lb_policy least_conn |
| 헬스 체크 | 패시브만 (max_fails) | 액티브 + 패시브 |
| WebSocket | map + 헤더 3줄 | 자동 |
| 프록시 헤더 | 스니펫으로 매번 include | 자동 |
| 보안 헤더 | add_header 상속 함정 주의 | 함정 없음 |
| IP 제한 | allow/deny | remote_ip 매처 |
| Basic 인증 | auth_basic + htpasswd | basic_auth + bcrypt 해시 |
| Rate Limit | 내장 limit_req | 플러그인 필요 (커스텀 빌드) |
| 로그 로테이션 | logrotate (또는 Docker 로그 드라이버) | 내장 |
| 컨테이너 IP 변경 | resolve 파라미터 또는 리로드 필요 | 요청 시 해석 |
운영 관점
배포 시 무중단 전환
- Nginx: 백엔드를 교체할 때
upstream을 수정하고nginx -s reload. 혹은 패시브 헬스 체크에 의존해 일부 요청이 실패한 뒤 제외됩니다. - Caddy:
lb_try_duration으로 재시작 순간의 요청을 버텨주고, 액티브 헬스 체크로 미리 감지합니다. Admin API로 업스트림을 코드에서 바꾸는 것도 가능합니다.
설정 검증
# Nginx
docker compose exec proxy nginx -t
docker compose exec proxy nginx -s reload
# Caddy
docker compose exec -w /etc/caddy proxy caddy validate --config Caddyfile
docker compose exec -w /etc/caddy proxy caddy reload --config Caddyfile문제가 생겼을 때 찾을 수 있는 자료의 양
솔직히 이 부분은 Nginx가 압도적입니다. 어떤 에러 메시지든 검색하면 Stack Overflow 답변이 있고, 클라우드 벤더 문서와 사내 위키도 Nginx를 기준으로 쓰여 있는 경우가 대부분입니다. Caddy는 공식 문서와 커뮤니티 포럼(caddy.community)의 품질이 높지만 절대량은 적습니다.
6. 성능은?
같은 하드웨어에서 단순 벤치마크를 돌리면, 정적 파일 서빙이나 아주 높은 동시성에서 Nginx가 더 적은 메모리와 CPU로 약간 더 높은 처리량을 보이는 경우가 일반적입니다. C로 작성되어 메모리 관리를 직접 하고, 20년간 최적화된 결과입니다. Caddy는 Go 런타임과 GC가 있어 메모리 사용량의 기본선이 더 높습니다.
하지만 실무에서는 다음을 기억하세요.
- 대부분의 웹 서비스에서 병목은 프록시가 아니라 애플리케이션과 DB입니다. 초당 수천~수만 요청 규모에서 Caddy가 병목이 되는 일은 드뭅니다.
- 벤치마크 결과는 TLS 설정, 압축 여부, keepalive, 커널 설정에 따라 크게 달라집니다. 인터넷의 벤치마크 수치보다 자기 워크로드로 직접 측정한 결과를 믿으세요.
# 예: k6로 간단한 부하 테스트
k6 run --vus 200 --duration 60s script.js
# 또는
wrk -t4 -c400 -d60s https://myservice.com/api/health7. Nginx → Caddy 마이그레이션 대응표
| Nginx | Caddy |
|---|---|
server { server_name a.com; } | a.com { } |
listen 80; (HTTP만) | http://a.com { } 또는 :80 { } |
location /path/ { } | handle /path/* { } |
location = /path { } | handle /path { } |
location ~ \.php$ { } | @php path *.php + handle @php { } |
root /var/www; | root * /var/www |
alias /data/; | handle_path /x/* { root * /data } |
index index.html; | file_server (기본 index.html) |
try_files $uri $uri/ /index.html; | try_files {path} {path}/ /index.html |
return 301 https://b.com$request_uri; | redir https://b.com{uri} permanent |
rewrite ^/a/(.*)$ /b/$1 last; | @a path_regexp a ^/a/(.*)$ + rewrite @a /b/{re.a.1} |
return 200 "ok"; | respond "ok" 200 |
proxy_pass http://up; | reverse_proxy up |
proxy_pass http://up/; (접두사 제거) | handle_path /x/* { reverse_proxy up } |
proxy_set_header X-Foo bar; | reverse_proxy { header_up X-Foo bar } |
proxy_read_timeout 300s; | transport http { read_timeout 300s } |
proxy_buffering off; | flush_interval -1 |
upstream { least_conn; server a; server b; } | reverse_proxy a b { lb_policy least_conn } |
add_header X-Foo bar; | header X-Foo bar |
gzip on; | encode gzip (zstd 권장 병기) |
client_max_body_size 10m; | request_body { max_size 10MB } |
allow 10.0.0.0/8; deny all; | @deny not remote_ip 10.0.0.0/8 + respond @deny 403 |
auth_basic | basic_auth |
error_page 404 /404.html; | handle_errors { ... } |
access_log ... json; | log { format json } |
set_real_ip_from | 전역 servers { trusted_proxies ... } |
limit_req | rate_limit (플러그인) |
proxy_cache | cache (플러그인) |
stream { } | layer4 { } (플러그인) |
nginx -t | caddy validate |
nginx -T | caddy adapt --pretty |
nginx -V | caddy list-modules |
마이그레이션 팁
- 한 번에 다 옮기지 말 것: 사이트 하나씩, 혹은 서브도메인 하나씩 옮깁니다.
- 기존 인증서는 그대로 써도 된다: 처음엔
tls cert.pem key.pem으로 기존 인증서를 지정하고, 안정화되면 자동 발급으로 전환합니다. - Let's Encrypt 스테이징으로 먼저 테스트: 전역 옵션에
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory를 넣어 rate limit 걱정 없이 테스트합니다. - 경로 매처의 와일드카드를 빠뜨리지 않았는지 확인합니다. (
/apivs/api/*) caddy adapt로 JSON을 확인하면 지시어가 실제 어떤 순서로 정렬되었는지 볼 수 있습니다.
정리
- 같은 요구사항을 구현했을 때 Caddy 설정은 Nginx의 절반 정도였고, 인증서 운영 절차가 사라졌다.
- Nginx는 Rate Limit·캐시·L4가 내장이라 커스텀 빌드 없이 공식 이미지로 해결된다.
- Docker 환경에서는 Nginx의 DNS 캐싱(→
resolve파라미터) 문제를 알고 있어야 한다. - 성능은 Nginx가 우위인 경우가 많지만, 대부분의 서비스에서 결정적 차이는 아니다.
마지막 편에서는 지금까지의 내용을 바탕으로 Nginx와 Caddy의 장단점을 항목별로 비교하고, 상황별 선택 가이드를 제시합니다.