1. 매처(Matcher) 제대로 쓰기
매처는 "이 지시어를 어떤 요청에 적용할지" 정하는 조건입니다. 5편에서 본 것처럼 지시어 바로 뒤에 매처 토큰을 둡니다.
| 매처 토큰 | 예시 | 의미 |
|---|---|---|
* | root * /var/www | 모든 요청 |
| 경로 | /api/* | 경로 매처 (와일드카드 필수) |
@이름 | @static | 이름 있는 매처 |
이름 있는 매처 (Named Matcher)
여러 조건을 묶어 이름을 붙입니다. 블록 안의 조건들은 AND로 결합됩니다.
example.com {
# 단일 조건은 한 줄로
@static path *.css *.js *.png *.svg *.woff2
# 여러 조건은 블록으로 (AND)
@admin_internal {
path /admin/*
remote_ip 192.168.0.0/24 10.0.0.0/8
}
# 부정
@admin_external {
path /admin/*
not remote_ip 192.168.0.0/24 10.0.0.0/8
}
# 메서드 + 헤더
@api_write {
path /api/*
method POST PUT PATCH DELETE
header Content-Type application/json*
}
# 정규식 (캡처 그룹은 {re.이름.1}로 사용)
@legacy path_regexp legacy ^/old/(\d+)$
redir @legacy /new/{re.legacy.1} permanent
# CEL 표현식: 가장 강력한 매처
@mobile expression {header.User-Agent}.contains("Mobile")
header @static Cache-Control "public, max-age=31536000, immutable"
respond @admin_external 403
reverse_proxy @admin_internal localhost:9000
reverse_proxy localhost:3000
}같은 매처 안에서 같은 종류의 조건 값들은 OR입니다(path *.css *.js → css 또는 js). 정리하면 "같은 종류끼리 OR, 다른 종류끼리 AND"입니다.
주요 매처: path, path_regexp, host, method, header, header_regexp, query, remote_ip, client_ip, protocol, file, not, expression, vars.
file 매처는 "파일이 실제로 존재하는가"를 검사합니다. 사실 try_files는 file 매처 + rewrite의 축약형입니다.
2. 지시어 실행 순서: Caddyfile에서 가장 중요한 개념
Nginx 사용자가 Caddy에서 가장 크게 당황하는 지점입니다. Caddyfile에 쓴 순서대로 실행되지 않습니다. Caddy는 지시어마다 미리 정해진 순서가 있고, 파싱할 때 그 순서로 재정렬합니다.
대표적인 기본 순서(일부):
tracing → map → vars → root → header → copy_response_headers
→ request_body → redir → method → rewrite → uri → try_files → basic_auth
→ forward_auth → request_header → encode → push → intercept → templates
→ handle → handle_path → route → abort → error → copy_response
→ respond → metrics → reverse_proxy → php_fastcgi → file_server → acme_server그래서 이 설정은:
example.com {
file_server
root * /var/www
redir /old /new
}실제로는 root → redir → file_server 순으로 동작합니다. 순서를 신경 쓰지 않아도 대체로 "올바르게" 동작하게 만든 설계입니다. 정확한 전체 순서는 공식 문서의 "Directive order"를 참고하세요.
순서를 직접 제어하는 세 가지 방법
① handle: 상호 배타적 그룹 (가장 많이 씀)
example.com {
handle /api/* {
reverse_proxy localhost:8080
}
handle /docs/* {
root * /var/www/docs
file_server
}
handle { # 매처 없는 handle = fallback
reverse_proxy localhost:3000
}
}- 같은 레벨의
handle블록 중 처음 매칭되는 하나만 실행됩니다. (Nginx의 location과 가장 비슷한 개념) handle블록끼리는 경로가 더 구체적인 것이 먼저 평가되도록 정렬되고, 매처 없는handle은 맨 마지막입니다.- 블록 내부의 지시어들은 여전히 기본 순서로 정렬됩니다.
② handle_path: handle + 경로 접두사 제거
handle_path /api/* {
reverse_proxy localhost:8080 # /api/users → /users
}내부적으로는 handle + uri strip_prefix /api와 같습니다.
③ route: 쓴 순서 그대로 실행
route /special/* {
header X-Step "1"
rewrite * /rewritten{uri}
reverse_proxy localhost:5000
}route 블록 안에서는 작성한 순서대로 실행됩니다. 기본 순서가 원하는 동작과 맞지 않을 때만 쓰세요.
④ 전역 order 옵션: 주로 플러그인 지시어(기본 순서가 없는)의 위치를 지정할 때 씁니다.
{
order rate_limit before basic_auth
}요약 비교
| 매칭 방식 | 내부 실행 순서 | |
|---|---|---|
| 그냥 나열 | 각 지시어가 개별적으로 | 기본 순서 |
handle | 첫 번째 일치 하나만 (배타적) | 기본 순서 |
handle_path | 위와 같음 + 접두사 제거 | 기본 순서 |
route | 매칭되면 실행 (배타적 아님) | 작성 순서 |
3. 스니펫과 import
반복되는 설정은 스니펫으로 만듭니다. Nginx의 include snippets/...에 해당합니다.
(security_headers) {
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
}
}
(logging) {
log {
output file /var/log/caddy/{args[0]}.log {
roll_size 50MiB
roll_keep 5
}
format json
}
}
app.example.com {
import security_headers
import logging app
reverse_proxy localhost:3000
}
admin.example.com {
import security_headers
import logging admin
reverse_proxy localhost:9000
}{args[0]},{args[1]}…로 인자를 받을 수 있습니다. (예전 문서의{args.0}표기도 볼 수 있습니다.)- 파일 단위 import도 가능합니다:
import sites/*.caddy. 사이트가 많으면 Nginx의conf.d/처럼 쪼개서 관리하세요. - Nginx와 달리
header가 상위에서 "덮어써지는" 상속 함정이 없습니다. 지시어가 각자 독립적으로 적용되기 때문입니다.
4. 로드 밸런싱과 헬스 체크
api.example.com {
reverse_proxy {
to 10.0.0.11:8080 10.0.0.12:8080 10.0.0.13:8080
# 로드 밸런싱
lb_policy least_conn
lb_retries 2
lb_try_duration 5s
lb_try_interval 250ms
# 액티브 헬스 체크 (오픈소스에 내장!)
health_uri /health
health_interval 10s
health_timeout 2s
health_status 2xx
health_body "ok"
# 패시브 헬스 체크
fail_duration 30s
max_fails 3
unhealthy_status 5xx
unhealthy_latency 3s
# 업스트림 연결 설정
transport http {
dial_timeout 3s
response_header_timeout 30s
keepalive 90s
keepalive_idle_conns 64
}
}
}lb_policy 종류
| 정책 | 설명 |
|---|---|
random | 무작위 (기본값) |
random_choose 2 | 두 개 무작위 선택 후 덜 바쁜 쪽 |
round_robin / weighted_round_robin 3 1 | 순차 / 가중치 순차 |
least_conn | 연결 수 최소 |
first | 사용 가능한 첫 번째 (액티브-스탠바이 구성에 유용) |
ip_hash / client_ip_hash | 클라이언트 IP 기반 고정 |
uri_hash | URI 기반 (캐시 서버 분산) |
header X-User-Id | 특정 헤더 값 기반 |
cookie lb_session | 쿠키 기반 스티키 세션 (쿠키 자동 발급) |
이것이 Nginx와의 큰 차이
3편에서 봤듯이 오픈소스 Nginx에는 액티브 헬스 체크가 없습니다. Caddy는 health_uri 한 줄로 주기적 헬스 체크가 됩니다. 장애 서버가 첫 사용자 요청을 실패시키기 전에 미리 제외된다는 뜻입니다.
lb_try_duration도 실용적입니다. 배포 중 모든 백엔드가 잠깐 재시작되는 순간에도, Caddy가 그 시간만큼 기다리며 재시도하므로 사용자에게 502가 덜 노출됩니다.
동적 업스트림
컨테이너 오케스트레이션 환경에서는 백엔드 목록이 계속 바뀝니다. Caddy는 DNS 조회로 업스트림을 동적으로 결정할 수 있습니다.
reverse_proxy {
dynamic a app.internal 8080 {
refresh 10s
}
# 또는 SRV 레코드: dynamic srv _http._tcp.app.service.consul
lb_policy least_conn
health_uri /health
}5. 프록시 뒤의 Caddy: trusted_proxies
Caddy 앞에 Cloudflare나 L4 로드 밸런서가 있다면 실제 클라이언트 IP를 신뢰할 대상을 지정합니다. (Nginx의 set_real_ip_from)
{
servers {
trusted_proxies static 173.245.48.0/20 103.21.244.0/22
# 또는 사설 대역 전체: trusted_proxies static private_ranges
client_ip_headers CF-Connecting-IP X-Forwarded-For
}
}이걸 설정하면 client_ip 매처와 {client_ip} 플레이스홀더가 실제 사용자 IP를 가리킵니다. (remote_ip는 항상 직접 연결된 상대의 IP입니다.) 신뢰하지 않는 프록시에서 들어온 X-Forwarded-* 헤더는 Caddy가 백엔드로 넘기기 전에 덮어쓰므로 위조 위험도 줄어듭니다.
6. TLS 심화
DNS 챌린지와 와일드카드 인증서
80/443을 외부에 열 수 없거나 *.example.com 와일드카드 인증서가 필요하면 DNS-01 챌린지를 씁니다. DNS 제공자 모듈이 필요하므로 커스텀 빌드를 해야 합니다(7절 참고).
xcaddy build --with github.com/caddy-dns/cloudflare*.example.com, example.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
@app host app.example.com
handle @app {
reverse_proxy localhost:3000
}
@grafana host grafana.example.com
handle @grafana {
reverse_proxy localhost:3001
}
handle {
abort
}
}Route53, DigitalOcean, Gandi 등 대부분의 DNS 제공자 모듈이 caddy-dns 조직에 있습니다.
On-Demand TLS: Caddy만의 기능
SaaS에서 고객이 자기 도메인(shop.customer.com)을 연결하는 기능을 생각해 봅시다. 고객 도메인을 미리 알 수 없으니 설정 파일에 적을 수도 없습니다. Caddy의 On-Demand TLS는 TLS 핸드셰이크가 들어오는 순간 인증서를 발급합니다.
{
on_demand_tls {
ask http://localhost:5555/domain-check
}
}
https:// {
tls {
on_demand
}
reverse_proxy localhost:3000
}ask엔드포인트는 필수에 가깝습니다. Caddy가?domain=shop.customer.com으로 물어보고, 200이면 발급, 아니면 거부합니다. 이게 없으면 공격자가 임의 도메인을 당신 서버로 향하게 해서 인증서 발급을 남발시킬 수 있습니다.- Nginx에서 같은 기능을 구현하려면 OpenResty + Lua(예: lua-resty-auto-ssl) 같은 별도 스택이 필요합니다.
클라이언트 인증서(mTLS), TLS 세부 옵션
internal-api.example.com {
tls {
protocols tls1.3
client_auth {
mode require_and_verify
trust_pool file /etc/caddy/ca/clients-ca.pem
}
}
reverse_proxy localhost:8443
}Caddy를 사내 ACME 서버로
acme_server 지시어로 Caddy 자체를 사내 ACME CA로 만들 수도 있습니다. 폐쇄망의 다른 Caddy 인스턴스들이 이 서버에서 인증서를 자동 발급받게 할 수 있어, 사내 인증서 관리가 크게 단순해집니다.
# CA 역할 서버
ca.internal {
tls internal
acme_server
}
# 다른 서버의 Caddyfile
app.internal {
tls {
ca https://ca.internal/acme/local/directory
# CA 서버는 tls internal(자체 루트)로 서빙한다. CA 서버의 데이터 디렉터리에 있는
# pki/authorities/local/root.crt를 복사해 두고 신뢰시켜야 발급이 된다
ca_root /etc/caddy/ca-internal-root.crt
}
reverse_proxy localhost:8080
}7. 플러그인: xcaddy로 커스텀 빌드
Caddy는 Go 언어 특성상 런타임에 모듈을 로드하지 않고, 컴파일 시점에 포함시킵니다. 이를 쉽게 해주는 도구가 xcaddy입니다.
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
xcaddy build \
--with github.com/caddy-dns/cloudflare \
--with github.com/mholt/caddy-ratelimit \
--with github.com/mholt/caddy-l4
./caddy list-modules | grep -E "dns|rate|layer4"Docker에서는 멀티 스테이지 빌드가 정석입니다.
FROM caddy:2-builder AS builder
RUN xcaddy build \
--with github.com/caddy-dns/cloudflare \
--with github.com/mholt/caddy-ratelimit
FROM caddy:2
COPY --from=builder /usr/bin/caddy /usr/bin/caddy공식 다운로드 페이지에서 플러그인을 체크해 빌드된 바이너리를 받는 방법도 있습니다.
자주 쓰는 플러그인
| 플러그인 | 용도 | Nginx에서는 |
|---|---|---|
caddy-dns/* | DNS 챌린지 | certbot DNS 플러그인 |
mholt/caddy-ratelimit | Rate Limit | 내장 (limit_req) |
caddyserver/cache-handler | HTTP 캐시 | 내장 (proxy_cache) |
mholt/caddy-l4 | L4(TCP/UDP) 프록시 | 내장 (stream) |
greenpau/caddy-security | OAuth/OIDC/SAML 인증 포털 | 서드파티/외부 프록시 |
ueffel/caddy-brotli | Brotli 압축 | 서드파티 모듈 |
이 표가 두 서버의 철학 차이를 잘 보여줍니다. Rate Limit, 캐시, L4 프록시는 Nginx에선 기본 기능이지만 Caddy에선 플러그인입니다. 반대로 자동 HTTPS, 액티브 헬스 체크, 동적 설정 API는 Caddy가 기본으로 갖고 있습니다.
Rate Limit 예시 (플러그인)
{
order rate_limit before basic_auth
}
api.example.com {
rate_limit {
zone per_ip {
key {client_ip}
events 100
window 1m
}
}
reverse_proxy localhost:8080
}8. JSON 설정과 Admin API
Caddyfile은 JSON으로 변환된다
caddy adapt --config Caddyfile --prettyexample.com {
reverse_proxy localhost:3000
}위 Caddyfile은 대략 이런 JSON이 됩니다.
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [":443"],
"routes": [
{
"match": [{ "host": ["example.com"] }],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"handler": "reverse_proxy",
"upstreams": [{ "dial": "localhost:3000" }]
}
]
}
]
}
],
"terminal": true
}
]
}
}
}
}
}JSON은 장황하지만 Caddy의 모든 기능을 표현할 수 있는 원본 설정입니다. Caddyfile로 표현이 안 되는 고급 기능도 JSON으로는 가능합니다.
Admin API로 실시간 설정 변경
Caddy는 기본적으로 localhost:2019에 관리 API를 엽니다.
# 현재 설정 조회
curl localhost:2019/config/ | jq
# 특정 경로만 조회
curl localhost:2019/config/apps/http/servers/srv0/routes
# 전체 설정 교체 (무중단)
curl localhost:2019/load \
-H "Content-Type: application/json" \
-d @caddy.json
# Caddyfile을 그대로 로드
curl localhost:2019/load \
-H "Content-Type: text/caddyfile" \
--data-binary @Caddyfile
# 업스트림 상태 확인
curl localhost:2019/reverse_proxy/upstreams | jq/config/ 하위 경로에 대해 GET, POST(배열에 추가), PUT(삽입), PATCH(교체), DELETE가 모두 가능합니다. 예를 들어 배포 스크립트에서 새 업스트림을 추가하고 구 버전을 빼는 블루-그린 전환을 설정 파일 수정 없이 API 호출로 할 수 있습니다. 설정에 @id 필드를 넣어두면 /id/my_upstreams처럼 긴 경로 대신 ID로 접근할 수도 있습니다.
caddy reload도 내부적으로는 이 /load 엔드포인트를 호출합니다.
주의: Admin API 보안과 설정 일관성
- Admin API는 기본적으로 localhost에만 바인딩됩니다. 원격에 노출하지 마세요. 필요 없다면 끌 수 있습니다:
{ admin off } - API로 바꾼 설정은 Caddyfile에 반영되지 않습니다. Caddy는 마지막 설정을
autosave.json에 저장하고caddy run --resume으로 복원할 수 있지만, "진실의 원천(source of truth)"을 Caddyfile로 할지 API로 할지 팀에서 정해두지 않으면 재시작 시 설정이 되돌아가는 사고가 납니다.
9. 관측성과 운영
Prometheus 메트릭
{
metrics {
per_host
}
}
:2020 {
metrics /metrics
}Admin 엔드포인트(localhost:2019/metrics)에서도 메트릭을 제공합니다. 요청 수, 응답 시간 히스토그램, 업스트림 상태 등이 노출됩니다.
로그 필터링 (민감 정보 마스킹)
log {
output file /var/log/caddy/access.log
format filter {
request>headers>Authorization delete
request>headers>Cookie delete
request>remote_ip ip_mask {
ipv4 24
ipv6 32
}
}
}개인정보 보호 요구사항이 있는 환경에서 유용합니다.
성능 관련 설정
Caddy는 Nginx만큼 튜닝 포인트가 많지 않고, 기본값이 대부분의 환경에서 합리적입니다. 손볼 만한 것은 다음 정도입니다.
{
servers {
timeouts {
read_body 30s
read_header 10s
write 60s
idle 5m
}
max_header_size 16KB
protocols h1 h2 h3
}
}
example.com {
request_body {
max_size 10MB # Nginx client_max_body_size
}
reverse_proxy localhost:3000 {
flush_interval -1 # SSE/스트리밍: 즉시 플러시 (Nginx proxy_buffering off)
}
}- Go 런타임 환경 변수
GOMAXPROCS(사용할 CPU 수),GOMEMLIMIT(메모리 소프트 상한)로 컨테이너 리소스 제한에 맞출 수 있습니다. - 파일 디스크립터 제한(
LimitNOFILE)은 Nginx와 마찬가지로 확인이 필요합니다. 공식 systemd 유닛에는 넉넉한 값이 설정되어 있습니다. - 참고로
reverse_proxy는 응답이text/event-stream이면 자동으로 즉시 플러시합니다.flush_interval -1은 그 외의 스트리밍 응답에 필요합니다.
정리
- 이름 있는 매처는 "같은 종류 OR, 다른 종류 AND"로 동작하고, CEL
expression으로 복잡한 조건도 표현한다. - Caddyfile의 지시어는 작성 순서가 아니라 기본 순서로 실행된다. 제어가 필요하면
handle(배타적),handle_path(접두사 제거),route(작성 순서)를 쓴다. - 액티브 헬스 체크, 동적 업스트림, 재시도 대기가 오픈소스에 내장되어 있다.
- DNS 챌린지, On-Demand TLS, 내장 ACME 서버는 TLS 자동화에서 Caddy의 강력한 무기다.
- Rate Limit, 캐시, L4 프록시는 플러그인이 필요하며,
xcaddy로 커스텀 빌드한다. - Caddyfile은 JSON의 어댑터이며, Admin API로 무중단 실시간 설정 변경이 가능하다.
다음 편에서는 지금까지 배운 것을 종합해, 같은 실전 아키텍처를 Nginx와 Caddy로 각각 구성하고 나란히 비교해 봅니다.