Caddy로 웹 서버 띄우기: 자동 HTTPS, 리버스 프록시, SPA·PHP 설정을 Caddyfile 몇 줄로

이 글의 핵심

Caddy는 "자동 HTTPS를 기본값으로" 한 Go 기반 웹 서버입니다. Nginx가 수십 줄의 설정으로 SSL·proxy_pass를 구성하는 것을 Caddy는 2-3줄로 끝냅니다. Let's Encrypt 통합·자동 갱신·HTTP/2·HTTP/3(QUIC)·단일 바이너리 배포로 설정 부담을 크게 줄여 줍니다. 대신 플러그인은 재빌드가 필요하고, 인증서 저장소와 포트·DNS 조건을 이해해야 문제를 빨리 찾을 수 있습니다.

Caddy는 Go로 작성된 웹 서버로, 가장 큰 특징은 HTTPS가 기본값이라는 점입니다. Caddyfile에 도메인 이름만 적으면 인증서 발급, 갱신, HTTP→HTTPS 리다이렉트, OCSP 스테이플링까지 알아서 처리합니다. Nginx에서 Certbot을 설치하고 크론으로 갱신을 걸고 ssl_certificate 경로를 맞추던 과정이 통째로 사라지는 셈입니다. 이 글은 설치부터 리버스 프록시, SPA·PHP, 플러그인, 운영(systemd·Docker·로그)까지 Caddy v2 기준으로 정리하고, 각 설정에서 실제로 막히는 지점을 함께 설명합니다.

미리 알아 둘 트레이드오프도 있습니다. Caddy는 “알아서 해 주는” 부분이 많아서, 무엇을 자동으로 하는지 모르면 문제가 생겼을 때 원인을 찾기 어렵습니다. 또 Nginx에 비해 서드파티 모듈 생태계가 작고, 레이트 리밋이나 DNS 챌린지 같은 기능은 플러그인을 넣어 바이너리를 다시 빌드해야 합니다. 설정이 짧다는 장점과 이런 제약을 함께 놓고 판단하는 것이 좋습니다.

설치

바이너리 다운로드

# Linux (아키텍처를 지정해야 하며, -o로 파일명을 caddy로 저장)
curl -o caddy -L "https://caddyserver.com/api/download?os=linux&arch=amd64"
chmod +x caddy
sudo mv caddy /usr/local/bin/

# macOS
brew install caddy

# Windows
scoop install caddy

운영 서버라면 수동 다운로드보다 공식 패키지 저장소(Debian/Ubuntu는 apt, Fedora/RHEL은 dnf)로 설치하는 편을 권합니다. 패키지는 caddy 사용자, systemd 유닛, /etc/caddy/Caddyfile 기본 파일, 80/443 포트 바인딩 권한까지 한 번에 설정해 줍니다. 수동 바이너리는 이 모든 것을 직접 챙겨야 하는데, 특히 일반 사용자로 실행한 바이너리는 1024 미만 포트에 바인딩할 수 없어서 listen tcp :443: bind: permission denied 에러가 납니다. 이때는 sudo setcap cap_net_bind_service=+ep /usr/local/bin/caddy로 권한을 주거나 아래 systemd 절처럼 유닛에 capability를 지정합니다.

Docker

docker run -d -p 80:80 -p 443:443 \
  -v $PWD/Caddyfile:/etc/caddy/Caddyfile \
  -v caddy_data:/data \
  caddy:latest

여기서 -v caddy_data:/data는 선택이 아니라 필수입니다. Caddy는 발급받은 인증서와 ACME 계정 키를 /data에 저장하는데, 이 볼륨 없이 컨테이너를 재생성하면 매번 인증서를 새로 발급받습니다. Let’s Encrypt는 같은 도메인 조합에 대해 주당 발급 횟수 제한(동일 인증서 기준 주 5회)이 있어서, 배포 스크립트가 컨테이너를 몇 번 다시 만들다 보면 too many certificates already issued 에러로 한동안 HTTPS가 막힙니다. 제가 Caddy를 Docker로 처음 올릴 때 가장 흔히 보는 실수가 바로 이 볼륨 누락입니다. 설정 테스트 중이라면 전역 옵션에 acme_ca https://acme-staging-v02.api.letsencrypt.org/directory를 넣어 스테이징 CA로 먼저 확인하는 방법도 있습니다.

첫 Caddyfile

# Caddyfile
example.com {
    respond "Hello, World!"
}
caddy run
# 또는
caddy start  # 백그라운드
  • example.com에 자동 HTTPS 활성화
  • Let’s Encrypt 인증서 자동 발급

caddy run은 포그라운드에서 실행되고 현재 디렉터리의 Caddyfile을 읽습니다. 사이트 주소에 공인 도메인을 적으면 Caddy는 시작하자마자 ACME 챌린지를 시도하므로, DNS가 이미 이 서버를 가리키고 80·443 포트가 외부에서 열려 있어야 합니다. 반대로 localhost나 127.0.0.1을 주소로 적으면 Let’s Encrypt 대신 Caddy 내부 CA로 로컬 인증서를 만들고, 브라우저가 신뢰하도록 caddy trust로 루트 인증서를 시스템에 설치할 수 있습니다. 주소를 :8080처럼 포트만 적으면 HTTPS 없이 평문 HTTP로 동작합니다.

설정을 바꾼 뒤에는 서버를 재시작할 필요 없이 caddy reload로 무중단 반영됩니다. 반영 전에 caddy validate --config Caddyfile로 문법을 먼저 검사하고, caddy fmt --overwrite로 들여쓰기를 정리해 두면 리뷰가 편해집니다. Caddyfile은 내부적으로 JSON 설정으로 변환되는데, caddy adapt --config Caddyfile --pretty로 변환 결과를 보면 지시어가 실제로 어떤 순서로 적용되는지 확인할 수 있습니다.

정적 파일 서버

example.com {
    root * /var/www/html
    file_server
}

/var/www/html 디렉터리를 https://example.com으로 서빙.

root * /var/www/html의 *는 “모든 요청에 대해”라는 매처(matcher)입니다. Caddy 지시어는 대부분 첫 인자로 매처를 받을 수 있어서, root /static/* /srv/assets처럼 경로별로 다른 루트를 지정할 수 있습니다. *를 빼고 root /var/www/html로 쓰면 Caddy가 /var/www/html을 경로 매처로 해석해 버리므로, 루트 경로를 쓸 때는 *를 붙이는 습관이 필요합니다.

file_server는 기본적으로 디렉터리 목록을 보여 주지 않습니다(목록이 필요하면 file_server browse). index.html이 없는 디렉터리에 접근하면 404가 납니다. 파일이 있는데도 403이 나면 대부분 권한 문제입니다. systemd로 실행한 Caddy는 caddy 사용자 권한으로 돌기 때문에, 홈 디렉터리 아래(/home/user/site)를 루트로 지정하면 상위 디렉터리의 실행 권한이 없어 읽지 못합니다.

리버스 프록시

api.example.com {
    reverse_proxy localhost:8000
}

FastAPI·Express·Go 서버를 api.example.com으로 프록시.

이 한 줄이 Nginx의 proxy_pass + proxy_set_header 여러 줄을 대신하는 이유는 Caddy가 합리적인 기본값을 넣어 주기 때문입니다. X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host가 자동으로 설정되고, Host 헤더는 클라이언트가 보낸 값이 그대로 전달됩니다. 백엔드가 외부 서비스라서 Host를 백엔드 주소로 바꿔야 한다면 header_up Host {upstream_hostport}를 추가합니다.

Caddy 앞에 Cloudflare나 로드 밸런서가 또 있다면 주의가 필요합니다. Caddy는 기본적으로 들어온 X-Forwarded-* 헤더를 신뢰하지 않고 직접 연결한 클라이언트 IP로 덮어씁니다. 그래서 백엔드 로그에 실제 사용자 IP 대신 Cloudflare IP가 찍힙니다. 전역 옵션의 servers { trusted_proxies static <CIDR 목록> }으로 앞단 프록시 대역을 지정해야 원래 클라이언트 IP가 전달됩니다.

로드 밸런싱

api.example.com {
    reverse_proxy localhost:8000 localhost:8001 localhost:8002 {
        lb_policy round_robin
        health_uri /health
        health_interval 10s
    }
}
  • lb_policy: round_robin, least_conn, ip_hash
  • health_uri: 헬스체크 endpoint

health_uri를 지정하면 능동 헬스체크가 켜져서 Caddy가 10초마다 각 백엔드의 /health를 호출하고, 실패한 백엔드는 복구될 때까지 로테이션에서 뺍니다. 능동 헬스체크만으로는 체크 사이의 10초 동안 죽은 백엔드로 요청이 갈 수 있으므로, fail_duration 30s(수동 헬스체크)와 lb_try_duration 5s(실패 시 다른 백엔드로 재시도)를 함께 두면 배포 중 순간적인 502를 크게 줄일 수 있습니다.

정책 선택 기준도 간단히 정리하면, 요청 처리 시간이 비슷하면 round_robin, 요청마다 처리 시간이 크게 다르면(파일 업로드, 긴 쿼리) least_conn이 낫습니다. ip_hash는 세션을 서버 메모리에 저장하는 레거시 앱을 위한 스티키 세션 용도인데, 앞단에 CDN이 있으면 모든 요청이 CDN IP로 들어와 한 서버로 몰립니다. 이런 경우 cookie 정책이 더 적합합니다.

PHP (FastCGI)

example.com {
    root * /var/www/html
    php_fastcgi unix//run/php/php8.2-fpm.sock
    file_server
}

WordPress·Laravel 배포에 사용.

php_fastcgi는 한 줄짜리 지시어처럼 보이지만 내부적으로 여러 동작을 묶은 것입니다. 디렉터리 요청에 슬래시를 붙여 리다이렉트하고, 존재하지 않는 경로는 index.php로 넘기는 try_files를 적용하며, SCRIPT_FILENAME 같은 FastCGI 변수를 설정한 뒤 PHP-FPM으로 전달합니다. Nginx에서 흔히 복사해 쓰던 location ~ \.php$ 블록을 직접 작성할 필요가 없습니다.

가장 자주 만나는 에러는 소켓 경로와 권한입니다. PHP 버전에 따라 소켓 이름이 php8.1-fpm.sock, php8.3-fpm.sock처럼 달라지므로 ls /run/php/로 실제 이름을 확인해야 하고, 소켓 파일이 www-data 소유라면 caddy 사용자가 접근할 수 없어 502가 납니다. PHP-FPM 풀 설정(www.conf)의 listen.owner/listen.group을 caddy로 맞추거나 caddy 사용자를 www-data 그룹에 넣으면 해결됩니다. Laravel이라면 root를 프로젝트 루트가 아니라 public 디렉터리로 지정해야 .env 같은 파일이 노출되지 않습니다.

SPA (React·Vue)

example.com {
    root * /var/www/dist
    try_files {path} /index.html
    file_server
}

모든 라우트를 index.html로 fallback.

try_files {path} /index.html은 요청 경로에 해당하는 파일이 있으면 그 파일을, 없으면 index.html을 내려 줍니다. React Router나 Vue Router의 히스토리 모드에서 /users/42를 새로고침했을 때 404가 나는 문제를 이 한 줄로 해결합니다.

부작용도 있습니다. 존재하지 않는 /assets/app.abc123.js를 요청해도 404 대신 index.html이 200으로 내려가서, 배포 직후 옛 해시의 청크를 요청한 브라우저가 HTML을 JavaScript로 해석하다 Unexpected token '<' 에러를 냅니다. 정적 자산 경로는 fallback에서 빼는 것이 안전합니다. 또 해시가 붙은 자산에는 긴 캐시를, index.html에는 Cache-Control: no-cache를 주어야 배포 후에도 새 버전을 받아 갑니다.

example.com {
    root * /var/www/dist
    @spa not path /assets/*
    try_files @spa {path} /index.html
    header /assets/* Cache-Control "public, max-age=31536000, immutable"
    header /index.html Cache-Control "no-cache"
    file_server
}

위 예시에서 try_files는 매처를 첫 인자로 받으므로 /assets/* 요청에는 적용되지 않고, 없는 자산은 정상적으로 404가 됩니다.

리다이렉트

www.example.com {
    redir https://example.com{uri} permanent
}

example.com {
    # ...
}

www → 비-www 리다이렉트.

{uri}는 경로와 쿼리 문자열을 모두 포함하므로 www.example.com/post?id=1은 example.com/post?id=1로 이동합니다. permanent는 301 리다이렉트를 의미하는데, 브라우저는 301을 강하게 캐시하므로 설정을 확정하기 전에는 기본값인 302(redir 인자 생략 시)로 먼저 테스트하는 편이 안전합니다. 잘못된 301이 캐시되면 사용자 브라우저에서 한동안 되돌릴 수 없습니다. www.example.com 블록도 별도 사이트이므로 Caddy가 이 도메인의 인증서도 자동으로 발급합니다. DNS에 www 레코드가 없으면 이 인증서 발급이 계속 실패하며 로그에 재시도 메시지가 쌓입니다.

HTTP → HTTPS 강제

Caddy는 기본적으로 HTTP를 HTTPS로 자동 리다이렉트하므로 아래 설정은 대부분 필요 없습니다. 명시적으로 쓰면 다음과 같습니다.

http://example.com {
    redir https://example.com{uri} permanent
}

https://example.com {
    # ...
}

직접 쓸 일이 생기는 경우는 오히려 자동 리다이렉트를 끄고 싶을 때입니다. 예를 들어 Cloudflare Tunnel이나 앞단 로드 밸런서가 TLS를 종료하고 Caddy에는 평문 HTTP로 넘긴다면, Caddy가 HTTPS로 리다이렉트하는 순간 무한 리다이렉트 루프(ERR_TOO_MANY_REDIRECTS)가 생깁니다. 이때는 사이트 주소를 http://example.com으로 적거나 전역 옵션에 auto_https off를 넣어 자동 HTTPS를 명시적으로 끕니다.

환경변수

{
    email [email protected]  # Let's Encrypt 이메일
}

example.com {
    reverse_proxy {$BACKEND_HOST}:{$BACKEND_PORT}
}
export BACKEND_HOST=localhost
export BACKEND_PORT=8000
caddy run

맨 위 중괄호 블록은 전역 옵션으로, 반드시 Caddyfile 첫 블록이어야 합니다. email을 지정해 두면 인증서 만료 경고나 CA 공지를 받을 수 있습니다.

환경변수 문법은 두 가지가 있고 동작 시점이 다릅니다. {$BACKEND_HOST}는 Caddyfile을 파싱할 때 치환되므로 어떤 위치에서도 쓸 수 있지만, 값을 바꾸려면 설정을 다시 로드해야 합니다. {env.BACKEND_HOST}는 요청을 처리할 때 치환되는 플레이스홀더라서 플레이스홀더를 지원하는 지시어 인자에서만 동작합니다. 또 systemd로 실행할 때는 셸에서 export한 값이 전달되지 않으므로, 유닛 파일의 EnvironmentFile=이나 systemctl edit caddy로 환경변수를 지정해야 합니다. “터미널에서 caddy run 하면 되는데 서비스로 돌리면 안 된다”는 문제의 상당수가 이것입니다.

서브 경로 프록시

example.com {
    handle_path /api/* {
        reverse_proxy localhost:8000
    }
    
    handle {
        root * /var/www/html
        file_server
    }
}
  • /api/* → localhost:8000
  • 나머지 → 정적 파일

handle_path와 handle의 차이는 경로 접두사를 제거하느냐입니다. handle_path /api/*는 /api/users를 백엔드에 /users로 넘기고, handle /api/*는 /api/users 그대로 넘깁니다. 백엔드가 /api 접두사를 포함한 라우트를 갖고 있는지에 따라 골라야 하며, 여기서 잘못 고르면 백엔드가 모든 요청에 404를 돌려줍니다.

handle 블록들은 서로 배타적이라서 요청은 처음 매칭된 블록 하나만 실행됩니다. 매처가 없는 마지막 handle은 나머지 모든 요청을 받는 기본 블록 역할을 합니다. Nginx의 location 우선순위 규칙(정확 일치 → 접두사 → 정규식)을 외울 필요 없이 “더 구체적인 경로가 먼저 평가된다”고 이해하면 됩니다. Caddyfile 어댑터는 handle 블록을 매처의 구체성 순으로 정렬합니다.

WebSocket

example.com {
    reverse_proxy /ws* localhost:8000
}

Caddy v2의 reverse_proxy는 WebSocket 업그레이드 요청을 자동으로 인식해 그대로 전달하므로, Nginx처럼 Upgrade/Connection 헤더를 직접 넘기는 설정이 필요 없습니다. 예전 글에서 볼 수 있는 header_up Upgrade ... 줄은 넣어도 해가 되지는 않지만 불필요합니다. 오히려 주의할 점은 경로 매처입니다. /ws는 정확히 /ws에만 매칭되므로 /ws/chat 같은 하위 경로를 쓰는 앱이라면 /ws*로 적어야 합니다. 설정을 리로드하면 기존 WebSocket 연결은 기본적으로 유지되지만, 오래된 연결을 정리하려면 stream_close_delay 옵션을 검토할 수 있습니다.

gRPC

grpc.example.com {
    reverse_proxy h2c://localhost:50051
}

HTTP/2 기반 gRPC 프록시.

h2c://는 “TLS 없는 HTTP/2(cleartext)“로 백엔드에 연결하라는 뜻입니다. gRPC는 HTTP/2가 필수인데, 기본 reverse_proxy는 백엔드와 HTTP/1.1로 통신하므로 스킴을 빼면 gRPC 호출이 unexpected HTTP status code received from server: 502 같은 에러로 실패합니다. 클라이언트와 Caddy 사이는 자동 HTTPS 위에서 HTTP/2로 연결되고, Caddy와 백엔드 사이는 h2c로 연결되는 구조입니다. 스트리밍 RPC를 쓴다면 응답이 버퍼링되지 않도록 flush_interval -1을 함께 지정하는 경우가 많습니다.

압축

example.com {
    encode gzip zstd
    file_server
}

응답을 gzip·zstd로 압축합니다. 클라이언트의 Accept-Encoding에 따라 지정한 순서대로 선택됩니다.

흔히 Brotli도 된다고 오해하는데, Caddy 표준 빌드의 encode는 Brotli 실시간 압축을 지원하지 않습니다. Brotli가 필요하면 빌드 시점에 .br 파일을 미리 만들어 두고 file_server { precompressed br gzip }로 사전 압축 파일을 서빙하는 방식이 권장됩니다. 사전 압축은 요청마다 CPU를 쓰지 않으므로 정적 사이트라면 실시간 압축보다 효율적입니다. 또 encode는 이미 압축된 이미지나 동영상, 너무 작은 응답(기본 512바이트 미만)은 압축하지 않으므로 따로 제외 규칙을 만들 필요는 없습니다.

보안 헤더

example.com {
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "DENY"
        X-XSS-Protection "1; mode=block"
        Referrer-Policy "no-referrer-when-downgrade"
    }
    
    file_server
}

헤더마다 무게가 다르므로 그대로 복사하기 전에 하나씩 확인하는 것이 좋습니다. X-XSS-Protection은 주요 브라우저에서 이미 제거된 기능이라 효과가 없고, 오히려 OWASP는 0으로 두거나 빼라고 권합니다. 실제 XSS 방어는 Content-Security-Policy가 담당합니다. Referrer-Policy는 요즘 브라우저 기본값인 strict-origin-when-cross-origin이 더 안전합니다.

가장 조심해야 할 것은 HSTS의 preload입니다. 이 값을 넣고 hstspreload.org에 등록하면 브라우저에 하드코딩되어, 서브도메인 중 하나라도 HTTPS를 지원하지 않으면 접속이 불가능해지고 목록에서 빠지는 데도 몇 달이 걸립니다. includeSubDomains도 같은 위험이 있으므로, 처음에는 max-age=300 정도로 짧게 시작해 문제가 없는지 확인한 뒤 늘리는 것이 안전합니다. 참고로 -Server처럼 앞에 -를 붙이면 응답 헤더를 제거할 수 있습니다.

Rate Limiting (플러그인)

# xcaddy로 플러그인 빌드
xcaddy build --with github.com/mholt/caddy-ratelimit
example.com {
    rate_limit {
        zone dynamic {
            key {remote_host}
            events 100
            window 1m
        }
    }
    
    reverse_proxy localhost:8000
}

IP당 분당 100 요청 제한.

레이트 리밋은 표준 Caddy에 포함되어 있지 않아서 xcaddy로 플러그인을 포함한 바이너리를 직접 빌드해야 합니다(Docker라면 caddy:builder 이미지로 멀티스테이지 빌드). 여기서 흔히 막히는 에러가 directive 'rate_limit' is not an ordered HTTP handler입니다. 서드파티 지시어는 Caddy가 실행 순서를 모르기 때문에 전역 옵션에 order rate_limit before basic_auth 같은 순서 지정을 넣거나, route { ... } 블록 안에 넣어 작성 순서대로 실행되게 해야 합니다.

key {remote_host}는 Caddy에 직접 연결한 IP를 기준으로 합니다. 앞단에 CDN이 있으면 모든 사용자가 CDN의 몇 개 IP로 합쳐져 정상 사용자까지 차단되므로, 이 경우 trusted_proxies를 설정하고 {client_ip}를 키로 쓰는 편이 맞습니다. 또 이 플러그인은 기본적으로 인스턴스 메모리에 카운터를 두므로, Caddy를 여러 대 띄우면 인스턴스별로 따로 계산됩니다.

Cloudflare DNS Challenge

xcaddy build --with github.com/caddy-dns/cloudflare
{
    email [email protected]
    acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}

example.com {
    tls {
        dns cloudflare {env.CLOUDFLARE_API_TOKEN}
    }
    
    reverse_proxy localhost:8000
}

포트 80·443 안 열어도 인증서 발급 가능.

DNS-01 챌린지는 CA가 서버에 접속하는 대신, Caddy가 DNS에 _acme-challenge TXT 레코드를 만들어 도메인 소유를 증명하는 방식입니다. 그래서 사설망 안의 서버나 방화벽 뒤의 서버도 공인 인증서를 받을 수 있고, 와일드카드 인증서(*.example.com)는 DNS-01로만 발급됩니다. 위 설정에서 전역 acme_dns와 사이트별 tls { dns ... }는 같은 역할이라 둘 중 하나만 있어도 됩니다. 전역에 두면 모든 사이트에 적용됩니다.

Cloudflare API 토큰은 전체 계정 권한을 가진 Global API Key가 아니라 해당 존의 Zone:DNS:Edit 권한만 가진 토큰으로 만드는 것이 원칙입니다. 토큰이 유출되어도 DNS 레코드 조작 범위로 피해가 제한됩니다. 또 {env.CLOUDFLARE_API_TOKEN}은 Caddy 프로세스의 환경변수여야 하므로, systemd 환경에서는 앞의 환경변수 절처럼 EnvironmentFile로 주입해야 합니다.

Docker Compose

version: '3.8'
services:
  caddy:
    image: caddy:latest
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"  # HTTP/3
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
      - caddy_config:/config
  
  app:
    image: myapp:latest
    expose:
      - "8000"

volumes:
  caddy_data:
  caddy_config:
# Caddyfile
example.com {
    reverse_proxy app:8000
}

Compose 네트워크 안에서는 서비스 이름(app)이 DNS 이름이 되므로 localhost:8000이 아니라 app:8000으로 프록시해야 합니다. 컨테이너 안에서 localhost는 Caddy 컨테이너 자신을 가리키기 때문에, localhost로 적으면 dial tcp 127.0.0.1:8000: connect: connection refused와 함께 502가 납니다. app은 ports가 아닌 expose만 두어 외부에 직접 노출하지 않고 Caddy를 통해서만 접근하게 한 점도 의도된 구성입니다.

443:443/udp는 HTTP/3(QUIC)용이며, 이 줄이 없어도 HTTP/1.1·HTTP/2는 정상 동작합니다. 최상단의 version: '3.8' 키는 Docker Compose v2에서 더 이상 쓰이지 않아 무시되며 경고만 출력되므로 지워도 됩니다. Caddyfile을 수정한 뒤에는 컨테이너를 재시작하지 않고 docker compose exec -w /etc/caddy caddy caddy reload로 반영할 수 있습니다. 단, 파일 하나를 바인드 마운트한 경우 에디터가 파일을 새로 만드는 방식으로 저장하면 컨테이너 쪽에서 변경이 보이지 않을 수 있어서, 디렉터리 단위로 마운트하는 편이 안전합니다.

멀티 도메인

example.com {
    reverse_proxy localhost:8000
}

blog.example.com {
    reverse_proxy localhost:8001
}

api.example.com {
    reverse_proxy localhost:8002
}

각 도메인마다 자동 HTTPS.

로그

example.com {
    log {
        output file /var/log/caddy/access.log {
            roll_size 100mb
            roll_keep 5
            roll_keep_for 720h
        }
        format json
    }
    
    reverse_proxy localhost:8000
}

JSON 형식 로그 + 로테이션.

Caddy는 사이트 블록에 log를 넣지 않으면 액세스 로그를 남기지 않습니다. 에러와 인증서 관련 로그(런타임 로그)는 항상 표준 출력으로 나가므로 systemd 환경에서는 journalctl -u caddy로 확인합니다. 로테이션을 Caddy가 직접 처리하므로 logrotate 설정은 필요 없습니다. roll_keep_for 720h는 30일이 지난 로그를 지운다는 뜻입니다.

JSON 로그는 사람이 바로 읽기엔 불편하지만, jq로 필터링하거나 Loki·Elasticsearch 같은 수집기에 그대로 넣기 좋습니다. 예를 들어 jq 'select(.status >= 500)' access.log로 5xx 응답만 뽑아볼 수 있습니다. 로그에는 요청 헤더가 통째로 들어가므로 Authorization이나 Cookie 값이 기록될 수 있다는 점에 주의해야 합니다. Caddy는 기본적으로 이 민감 헤더들을 REDACTED로 가려 주지만, 커스텀 토큰 헤더는 format filter로 직접 지워야 합니다.

systemd 서비스

sudo caddy stop   # 테스트로 띄운 caddy start 프로세스가 있다면 먼저 종료
sudo systemctl enable --now caddy

# 설정 변경 후
sudo systemctl reload caddy
# /etc/systemd/system/caddy.service
[Unit]
Description=Caddy
After=network-online.target
Wants=network-online.target

[Service]
Type=notify
User=caddy
Group=caddy
ExecStart=/usr/local/bin/caddy run --environ --config /etc/caddy/Caddyfile
ExecReload=/usr/local/bin/caddy reload --config /etc/caddy/Caddyfile --force
Restart=on-failure
RestartSec=5s
AmbientCapabilities=CAP_NET_BIND_SERVICE

[Install]
WantedBy=multi-user.target

공식 패키지로 설치했다면 이 유닛 파일은 이미 /lib/systemd/system/caddy.service에 있으므로 직접 만들 필요가 없습니다. 수동 설치라면 위처럼 AmbientCapabilities=CAP_NET_BIND_SERVICE가 반드시 있어야 합니다. User=caddy로 실행하는 프로세스는 일반 사용자라 이 줄이 없으면 443 포트에 바인딩하지 못하고 서비스가 곧바로 실패합니다. caddy 사용자와 홈 디렉터리(/var/lib/caddy)도 미리 만들어야 인증서 저장 위치가 생깁니다.

systemctl reload와 restart는 다릅니다. reload는 Caddy의 관리 API를 통해 새 설정을 적용하는 무중단 반영이고, 새 설정에 문법 오류가 있으면 기존 설정을 유지한 채 에러만 반환합니다. restart는 프로세스를 내렸다 올리므로 설정이 틀리면 서비스가 죽은 채로 남습니다. 운영 중에는 항상 reload를 쓰고, 실패하면 journalctl -u caddy -n 50으로 원인을 확인합니다. 한 가지 주의할 점은 caddy run으로 시작한 뒤 관리 API(localhost:2019)로 설정을 바꾸면 Caddyfile과 실행 중 설정이 달라진다는 것입니다. Caddyfile과 API 중 하나만 진실의 원천으로 정해 두는 편이 혼란이 적습니다.

Kubernetes Ingress

helm repo add caddy https://caddyserver.github.io/ingress
helm install caddy caddy/caddy-ingress-controller
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: example
spec:
  ingressClassName: caddy
  rules:
  - host: example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: myapp
            port:
              number: 8000
  tls:
  - hosts:
    - example.com

자동 Let’s Encrypt 인증서.

Caddy Ingress Controller는 커뮤니티 프로젝트이고, Kubernetes 환경에서는 ingress-nginx나 Traefik, cert-manager 조합이 훨씬 널리 쓰입니다. 파드가 여러 개로 늘어나면 인증서 저장소를 공유해야 같은 인증서를 파드마다 따로 발급받지 않으므로, 저장소 설정을 먼저 확인하는 것이 좋습니다. 클러스터 전체의 인그레스보다는 단일 서비스 앞에 사이드카나 독립 디플로이먼트로 Caddy를 두는 방식이 Caddy의 단순함을 더 잘 살리는 경우가 많습니다.

HTTP/3 (QUIC)

Caddy 2.6부터 HTTP/3는 기본으로 켜져 있습니다. 예전 글에서 볼 수 있는 experimental_http3 전역 옵션은 v2.6에서 제거되어, 지금 버전에서는 오히려 설정 파싱 에러가 납니다. 따로 할 일은 방화벽과 Docker 포트 매핑에서 UDP 443을 여는 것뿐입니다.

example.com {
    reverse_proxy localhost:8000
}

브라우저는 처음에 TCP로 접속한 뒤 응답의 Alt-Svc: h3=":443" 헤더를 보고 다음 요청부터 QUIC으로 전환합니다. 그래서 개발자 도구의 Protocol 열에 첫 요청은 h2, 이후 요청은 h3로 찍히는 것이 정상입니다. UDP 443이 막혀 있으면 브라우저가 조용히 HTTP/2로 되돌아가므로 에러는 나지 않지만 HTTP/3의 이점도 없습니다. 특정 프로토콜만 쓰고 싶다면 전역 옵션의 servers { protocols h1 h2 }처럼 제한할 수 있습니다.

트러블슈팅

Let’s Encrypt 인증서 발급 실패

  • 포트 80·443 방화벽 확인
  • DNS A 레코드 전파 대기 (AAAA 레코드가 있다면 IPv6로도 접속 가능한지 확인)
  • 로그 확인: 포그라운드는 caddy run 출력, 서비스는 journalctl -u caddy

로그에서 볼 수 있는 대표적인 메시지로 원인을 좁힐 수 있습니다. Timeout during connect (likely firewall problem)는 CA가 서버에 접속하지 못한 것이고, DNS problem: NXDOMAIN은 도메인 레코드가 없다는 뜻입니다. 의외로 자주 걸리는 것은 AAAA 레코드입니다. 호스팅 업체가 기본으로 IPv6 레코드를 만들어 두었는데 서버는 IPv6를 받지 않으면, Let’s Encrypt가 IPv6로 접속을 시도하다 실패합니다. Caddy는 Let’s Encrypt가 실패하면 ZeroSSL로 자동 재시도하므로 로그에 두 CA의 에러가 번갈아 보일 수 있습니다. 재시도 간격이 점점 늘어나므로 원인을 고친 뒤에는 caddy reload로 즉시 재시도하게 할 수 있습니다.

리버스 프록시 502 Bad Gateway

  • 백엔드 서버 작동 확인 (curl localhost:8000)
  • reverse_proxy localhost:8000 host 정확히

백엔드가 떠 있는데도 502라면 백엔드가 어느 주소에 바인딩되어 있는지 확인합니다. 백엔드가 IPv6 ::1에만 떠 있는데 Caddy가 127.0.0.1로 접속하거나, 컨테이너 안의 앱이 127.0.0.1에 바인딩되어 다른 컨테이너에서 접근할 수 없는 경우가 흔합니다. 컨테이너 안의 앱은 0.0.0.0에 바인딩해야 합니다. Caddy 로그의 dial tcp ...: connect: connection refused는 포트에 아무것도 없다는 뜻이고, i/o timeout은 네트워크 경로나 방화벽 문제입니다.

인증서 갱신 안 됨

  • 인증서 저장 디렉터리 권한 확인 (Docker는 /data/caddy/certificates, systemd는 /var/lib/caddy/.local/share/caddy)
  • Caddy는 만료 약 30일 전(유효기간의 1/3 남은 시점)부터 갱신을 시도하므로, 이보다 늦게까지 갱신이 안 되면 로그를 확인

Caddy를 sudo caddy run으로 root 권한으로 한 번 실행하면 인증서가 root 소유로 root 홈 디렉터리에 저장됩니다. 그 뒤 서비스로 caddy 사용자 권한으로 실행하면 이 인증서를 읽을 수 없어 새로 발급받으려 하고, 권한이 꼬인 디렉터리에는 쓰지도 못해 갱신이 실패합니다. 저장소 위치가 실행 사용자의 홈 디렉터리 기준이라는 점을 기억해 두면 이런 문제를 빨리 찾을 수 있습니다. caddy trust는 로컬 개발용 내부 CA를 시스템에 신뢰시키는 명령이라 공인 인증서 갱신과는 관계가 없습니다.

Docker에서 자동 HTTPS 안 됨

  • 호스트 네트워크 모드 또는 443 포워딩 확인
  • 도메인이 실제 공인 IP 가리켜야
  • /data 볼륨이 유지되는지 확인 (재생성할 때마다 재발급되면 발급 한도에 걸림)

체크리스트

  • Caddy 설치 (caddy version 확인)
  • 도메인 DNS A 레코드 설정
  • Caddyfile 작성 (리버스 프록시 or 파일 서버)
  • caddy run 실행
  • HTTPS 자동 인증서 확인
  • systemd 서비스 등록
  • 로그·모니터링 설정
  • HTTP/3 활성화 (선택)

마무리

Caddy는 “HTTPS는 선택이 아니라 기본값”이라는 철학을 실현한 웹 서버입니다. 인증서 발급과 갱신, 리다이렉트, 합리적인 프록시 헤더가 기본으로 들어 있어서 소규모 서비스나 사이드 프로젝트, 내부 도구 앞단에서 설정 시간을 크게 줄여 줍니다. Go 단일 바이너리라 배포도 간단합니다.

반대로 이미 Nginx 설정과 운영 노하우가 쌓인 팀, 서드파티 모듈(Lua, 특정 WAF 등)에 의존하는 환경, 세밀한 캐시 튜닝이 핵심인 서비스라면 굳이 옮길 이유가 크지 않습니다. 자동 HTTPS도 “관리할 일이 없다”기보다 “관리할 일이 /data 볼륨, DNS 레코드, 포트 개방으로 줄어든다”고 보는 것이 정확합니다. 새 서비스를 시작할 때 Caddy로 가볍게 시작해 보고, 한계가 보이는 지점에서 판단하는 것을 권합니다.

자주 묻는 질문 (FAQ)

Q. Caddy가 Let’s Encrypt 인증서 발급에 실패하면 무엇부터 확인하나요?

A. 자동 HTTPS는 CA가 도메인으로 접속해 소유권을 검증하는 방식이므로, 먼저 방화벽이나 보안 그룹에서 80·443 포트가 외부에 열려 있는지 확인합니다. 그다음 도메인의 DNS A 레코드가 실제 서버의 공인 IP를 가리키는지, 레코드 변경이 아직 전파 중은 아닌지 확인합니다. Docker로 띄웠다면 443 포트가 호스트로 포워딩되는지도 보고, Caddy 로그에서 검증 단계의 오류 메시지를 확인하면 원인을 좁힐 수 있습니다.


같이 보면 좋은 글