Caddy: 인증서를 신경 쓰지 않아도 되는 웹 서버

Caddy: 인증서를 신경 쓰지 않아도 되는 웹 서버

사이드 프로젝트를 서버에 처음 올려본 사람이라면 비슷한 밤을 보냈을 겁니다. 앱은 이미 3000번 포트에서 잘 돌아갑니다. 정작 반나절이 날아가는 쪽은 그걸 바깥에서 접속하게 만드는 일이죠. Nginx 설정 파일에서 server 블록과 location 블록의 중괄호를 세어 보고, Let’s Encrypt 인증서를 받으려고 certbot을 깔고, 갱신이 제대로 도는지 확인하려고 cron 로그를 뒤지는 식입니다. 서비스 로직은 한 줄도 건드리지 않았는데 말이죠. 😅

Caddy는 바로 이 지점을 겨냥한 웹 서버입니다. Go로 작성된 오픈소스 서버인데, 가장 큰 특징은 HTTPS가 기본값이라는 점입니다. 설정 파일에 도메인 이름을 적어 두기만 하면 인증서를 알아서 발급받고, 만료되기 전에 알아서 갱신합니다. 이 글에서는 Caddy를 설치해서 정적 파일을 띄우고, 자동 HTTPS가 어떻게 동작하는지 로그로 확인한 다음, 백엔드 앞에 리버스 프록시로 세워 보겠습니다.

Caddy 설치하기

macOS에서는 Homebrew로 설치하는 게 가장 간단합니다.

brew install caddy

설치가 끝나면 46MB 남짓한 실행 파일 하나가 생깁니다.

결과
🍺  /opt/homebrew/Cellar/caddy/2.11.4: 39 files, 46.5MB

Caddy는 Go로 만들어져서 의존성 없는 단일 바이너리로 배포됩니다. 그래서 리눅스 서버에서는 apt나 yum 저장소를 등록해 설치할 수도 있고, 공식 다운로드 페이지에서 바이너리를 받아 실행 권한만 주면 바로 쓸 수 있습니다. Docker 이미지도 caddy라는 이름으로 공식 제공됩니다.

설치가 잘 됐는지 버전을 확인해 봅니다.

caddy version
결과
v2.11.4 h1:XKxkMTgNSizEvKG6QHue6cAsFOteU2qA61w2tKkCWi0=

Caddyfile, 두 줄이면 웹 서버가 뜹니다

Caddy의 설정 파일은 Caddyfile이라고 부릅니다. 확장자도 없고 이름 그대로입니다. 빈 디렉토리를 하나 만들고 HTML 파일과 Caddyfile을 나란히 놓아 보겠습니다.

index.html
<h1>Hello Caddy</h1>
Caddyfile
localhost

file_server browse

첫 줄 localhost는 이 서버가 응답할 주소입니다. 두 번째 줄 file_server는 현재 디렉토리의 파일을 그대로 서빙하라는 지시어(directive)이고, 뒤에 붙은 browse는 인덱스 파일이 없을 때 디렉토리 목록을 보여 주라는 옵션입니다.

이제 같은 디렉토리에서 서버를 띄웁니다.

caddy run

Caddy는 현재 디렉토리에 Caddyfile이 있으면 알아서 찾아 읽습니다. 실행 로그를 보면 이 두 줄이 무슨 일을 벌였는지 드러납니다.

결과
{"level":"info","msg":"using adjacent Caddyfile"}
{"level":"info","msg":"adapted config to JSON","adapter":"caddyfile"}
{"level":"info","logger":"admin","msg":"admin endpoint started","address":"localhost:2019"}
{"level":"info","logger":"http.auto_https","msg":"enabling automatic HTTP->HTTPS redirects","server_name":"srv0"}
{"level":"info","logger":"http","msg":"enabling HTTP/3 listener","addr":":443"}
{"level":"info","logger":"http.log","msg":"server running","name":"srv0","protocols":["h1","h2","h3"]}
{"level":"info","logger":"tls.obtain","msg":"obtaining certificate","identifier":"localhost"}
{"level":"info","logger":"tls.obtain","msg":"certificate obtained successfully","identifier":"localhost","issuer":"local"}

우리가 요청한 적 없는 일들이 줄줄이 벌어졌습니다. HTTP에서 HTTPS로 가는 리디렉션이 켜졌고, HTTP/3 리스너가 열렸고, 인증서가 발급됐습니다. curl로 접속해 보면 실제로 그렇게 동작합니다.

curl -sI http://localhost/
결과
HTTP/1.1 308 Permanent Redirect
Location: https://localhost/
Server: Caddy

HTTP로 들어온 요청은 308 상태 코드로 HTTPS에 넘겨집니다. http://https://로 바꾸는 리디렉션 블록을 따로 쓴 적이 없는데도 말이죠.

자동 HTTPS가 판단하는 방식

앞의 로그에서 가장 눈에 띄는 줄은 인증서 발급자가 local이라는 부분입니다. localhost는 공개 도메인이 아니라서 Let’s Encrypt 같은 공인 인증 기관(Certificate Authority, 이하 CA)이 인증서를 발급해 주지 않는데요. Caddy는 이럴 때 자기 안에 내장된 CA로 직접 인증서를 만들어 씁니다.

curl -skv https://localhost/ 2>&1 | grep -i "issuer:"
결과
*  issuer: CN=Caddy Local Authority - ECC Intermediate

그러니까 Caddy는 사이트 주소를 보고 두 갈래로 나눠 판단합니다.

flowchart TB
    accTitle: Caddy가 인증서 발급 방식을 결정하는 흐름
    accDescr: 사이트 주소를 확인해 공개 도메인이면 ACME 프로토콜로 공인 인증서를 받고, localhost나 IP 주소면 내장 CA로 로컬 인증서를 만들어 사용한다.

    start[/"Caddyfile의 사이트 주소"/] --> check{"공개 도메인인가?"}
    check -- "예" --> acme["ACME 프로토콜로<br/>Let's Encrypt에 요청"]
    check -- "아니요<br/>(localhost, IP 등)" --> internal["내장 CA로<br/>직접 서명"]
    acme --> serve(["HTTPS로 서비스"])
    internal --> trust["로컬 신뢰 저장소에<br/>루트 인증서 설치"]
    trust --> serve

로컬 개발용 인증서는 내 컴퓨터에서만 신뢰되면 충분합니다. 그래서 Caddy는 처음 실행될 때 루트 인증서를 시스템 신뢰 저장소에 심으려고 시도하는데, 이때 관리자 권한이 필요합니다.

sudo caddy trust

이 명령을 한 번 실행해 두면 브라우저에서 https://localhost를 열어도 경고 화면이 뜨지 않습니다. 앞의 예제에서 curl -k 옵션(인증서 검증 건너뛰기)을 쓴 것도 이 과정을 생략했기 때문입니다.

반대로 실제 도메인을 적으면 이야기가 달라집니다.

Caddyfile
example.com

file_server

이렇게 써 두고 서버를 띄우면 Caddy는 ACME 프로토콜로 Let’s Encrypt에 인증서를 요청합니다. 발급받은 인증서는 데이터 디렉토리에 저장해 두고, 만료 시점이 다가오면 백그라운드에서 알아서 갱신합니다. certbot을 따로 설치하고 갱신 타이머가 도는지 확인할 일이 없어지는 셈인데요. TLS 인증서가 어떤 구조로 신뢰를 만드는지가 궁금하다면 인증서 자체를 다룬 글에서 자세히 살펴볼 수 있습니다.

다만 공인 인증서를 받으려면 조건이 있습니다. 해당 도메인의 DNS 레코드가 이 서버를 가리키고 있어야 하고, 80번과 443번 포트가 외부에서 접근 가능해야 합니다. Let’s Encrypt가 “정말 이 도메인의 주인이 맞나”를 확인하려고 그 주소로 접속해 보기 때문입니다.

리버스 프록시로 백엔드 앞에 세우기

정적 파일 서빙만으로 끝나는 프로젝트는 드뭅니다. 대개는 API 서버가 따로 돌고 있고, 그 앞에 리버스 프록시를 세워 도메인과 HTTPS를 붙이게 되는데요. Bun으로 간단한 백엔드를 하나 띄워 보겠습니다.

server.ts
Bun.serve({
  port: 3000,
  fetch(req) {
    const url = new URL(req.url);
    // 프록시가 붙여준 헤더를 그대로 돌려줍니다
    return Response.json({
      path: url.pathname,
      proto: req.headers.get("x-forwarded-proto"),
      host: req.headers.get("x-forwarded-host"),
      ip: req.headers.get("x-forwarded-for"),
    });
  },
});

이제 Caddyfile을 고쳐서 /api/로 시작하는 요청은 이 서버로 넘기고, 나머지는 정적 파일로 응답하게 만듭니다.

Caddyfile
localhost {
	handle /api/* {
		reverse_proxy localhost:3000
	}

	handle {
		root * .
		file_server
	}
}

앞선 예제와 달라진 점이 하나 있습니다. 사이트 주소 뒤에 중괄호를 열어 사이트 블록(site block)을 만들었는데요. 설정이 한 줄로 끝나지 않을 때는 이렇게 블록으로 묶습니다.

handle 안의 /api/*는 매처(matcher)라고 부르는 경로 조건입니다. Caddy는 위에서부터 조건을 확인하다가 처음 맞는 handle 블록 하나만 실행하고 나머지는 건너뜁니다. 그래서 매처가 없는 아래쪽 handle이 자연스럽게 “그 외 전부”를 담당하게 됩니다.

두 경로가 의도대로 갈라지는지 확인해 봅니다.

curl -sk https://localhost/api/hello
curl -sk https://localhost/
결과
{"path":"/api/hello","proto":"https","host":"localhost","ip":"::1"}
<h1>Hello Caddy</h1>

응답에서 눈여겨볼 부분은 proto 값이 https라는 점입니다. 백엔드 Bun 서버는 평범한 HTTP로 3000번 포트에서 돌고 있는데도, 원래 요청이 HTTPS였다는 사실을 알 수 있습니다. reverse_proxy 지시어가 X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-For 헤더를 자동으로 채워 주기 때문인데요. 프록시 뒤에서 리디렉션 URL을 만들거나 클라이언트 IP를 기록할 때 꼭 필요한 정보라, 이걸 손으로 설정하지 않아도 된다는 건 꽤 편합니다.

압축과 접근 로그도 한 줄씩이면 켜집니다.

Caddyfile
localhost {
	encode gzip
	log

	handle /api/* {
		reverse_proxy localhost:3000
	}

	handle {
		root * .
		file_server
	}
}

log를 켜면 요청마다 구조화된 JSON 로그가 남습니다.

결과
{"level":"info","logger":"http.log.access","msg":"handled request",
 "request":{"remote_ip":"::1","proto":"HTTP/2.0","method":"GET","host":"localhost","uri":"/api/test",
 "tls":{"version":772,"cipher_suite":4867,"proto":"h2","server_name":"localhost"}},
 "duration":0.001604208,"size":66,"status":200}

처음 보면 장황하지만, 로그 수집 도구에 그대로 넣기에는 이 편이 훨씬 낫습니다. Nginx의 기본 로그처럼 공백으로 구분된 문자열을 정규식으로 파싱할 필요가 없으니까요.

설정을 바꿔도 서버는 멈추지 않습니다

Caddyfile을 수정했다면 서버를 다시 시작할 필요 없이 리로드하면 됩니다.

caddy reload
결과
{"level":"info","logger":"http.log","msg":"server running","name":"srv0","protocols":["h1","h2","h3"]}
{"level":"info","msg":"servers shutting down with eternal grace period"}
{"level":"info","logger":"admin.api","msg":"load complete"}

로그를 자세히 보면 순서가 재미있습니다. 새 설정으로 서버를 먼저 띄운 다음에 기존 서버를 내립니다. 리로드하는 순간에도 연결이 끊기지 않는 이유입니다. “eternal grace period”라는 표현은 처리 중이던 요청이 끝날 때까지 기다려 준다는 뜻입니다.

이게 가능한 이유는 Caddy가 설정을 파일이 아니라 API로 관리하기 때문입니다. 서버가 뜰 때 로그에 함께 찍혔던 줄을 다시 볼까요?

결과
{"level":"info","logger":"admin","msg":"admin endpoint started","address":"localhost:2019"}

Caddy는 2019번 포트에 관리용 HTTP API를 열어둡니다. caddy reload 명령은 사실 이 API에 새 설정을 POST하는 클라이언트일 뿐입니다. 그래서 배포 스크립트에서 설정을 바꾸고 싶다면 파일을 건드리는 대신 API를 직접 호출해도 됩니다.

Caddyfile은 사실 JSON으로 번역됩니다

Caddy의 진짜 설정 형식은 JSON입니다. Caddyfile은 사람이 쓰기 편하도록 만들어진 어댑터(adapter)이고, 실행 시점에 JSON으로 변환됩니다. caddy adapt 명령으로 변환 결과를 직접 볼 수 있습니다.

caddy adapt --config Caddyfile

맨 처음 썼던 세 줄짜리 Caddyfile을 넣어 보면 이런 JSON이 나옵니다.

결과
{
  "apps": {
    "http": {
      "servers": {
        "srv0": {
          "listen": [":443"],
          "routes": [
            {
              "match": [{ "host": ["localhost"] }],
              "handle": [
                {
                  "handler": "subroute",
                  "routes": [
                    {
                      "handle": [
                        {
                          "browse": {},
                          "handler": "file_server",
                          "hide": ["./Caddyfile"]
                        }
                      ]
                    }
                  ]
                }
              ],
              "terminal": true
            }
          ]
        }
      }
    }
  }
}

여기서 hide 항목을 눈여겨볼 만합니다. 우리는 이런 설정을 쓴 적이 없는데 어댑터가 알아서 넣었는데요. 정적 파일 서버를 켜면 같은 디렉토리에 있는 Caddyfile까지 노출될 수 있으니, 설정 파일만큼은 서빙하지 말라고 자동으로 막아준 겁니다. 이런 식으로 흔히 저지르는 실수를 기본값에서 걷어내는 게 Caddy의 성격을 잘 보여줍니다.

문법 검사와 포맷 정리

설정 파일에 오타가 있으면 서버를 띄우기 전에 잡아내는 편이 낫습니다. caddy validate가 그 역할을 합니다.

caddy validate --config Caddyfile

지시어 이름을 reverse_prox로 잘못 쓴 파일을 넣어 보면 몇 번째 줄이 문제인지 알려줍니다.

결과
Error: adapting config using caddyfile: bad.Caddyfile:2: unrecognized directive: reverse_prox

들여쓰기가 제각각인 파일은 caddy fmt로 정리합니다.

caddy fmt messy.Caddyfile
결과
localhost {
	reverse_proxy localhost:3000
	encode gzip
}

기본 동작은 정리된 결과를 화면에 보여 주기만 하고 파일은 건드리지 않습니다. 실제로 덮어쓰려면 --overwrite 플래그를 붙여야 하는데요. 그리고 포맷이 어긋난 파일을 만나면 종료 코드 1로 끝나기 때문에, CI 파이프라인에 그대로 넣어 설정 파일 스타일을 강제할 수도 있습니다.

설정 파일 없이 한 줄로 띄우기

잠깐 파일을 공유하거나 동작을 확인하는 정도라면 Caddyfile을 만들 필요도 없습니다. Caddy는 자주 쓰는 용도를 하위 명령으로 제공합니다.

caddy file-server --listen :8080 --browse
결과
{"level":"info","msg":"Caddy serving static files on :8080"}

리버스 프록시도 마찬가지입니다.

caddy reverse-proxy --from :8080 --to localhost:3000

고정된 응답만 돌려주는 서버도 만들 수 있는데, 프론트엔드 개발 중에 목(mock) 엔드포인트가 필요할 때 쓸 만합니다.

caddy respond --listen :8080 "OK"

이 하위 명령들은 관리 API를 열지 않습니다. 설정이 명령줄 인자로만 결정되니 나중에 바꿀 여지가 없기 때문인데요. 그래서 일회성으로 쓰기에는 오히려 안전합니다.

Nginx와 무엇이 다른가

Caddy를 이야기하면 결국 Nginx와의 비교로 넘어가게 됩니다. 정리하면 이렇습니다.

  • 자동 HTTPS: Caddy의 가장 큰 차별점입니다. Nginx에서는 certbot을 설치하고 인증서 경로를 설정에 적고 갱신 타이머를 확인해야 하는 일이, Caddy에서는 도메인 한 줄로 끝납니다.
  • 설정 문법: 같은 구성을 만들 때 Caddyfile이 훨씬 짧습니다. HTTPS 리디렉션, 보안 헤더, 프록시 헤더처럼 관례적으로 반복하던 설정이 기본값에 들어 있기 때문입니다.
  • 배포 형태: Go 바이너리 하나라서 의존성 설치가 없습니다. 반대로 Nginx는 대부분의 리눅스 배포판에 패키지로 준비되어 있고 운영 사례도 훨씬 많습니다.
  • 플러그인: Caddy의 모듈은 컴파일 시점에 정적으로 포함됩니다. DNS 검증용 플러그인 하나를 추가하려 해도 xcaddy로 바이너리를 다시 빌드해야 하는데, 런타임에 모듈을 로드하는 방식에 익숙하다면 번거롭게 느껴질 수 있습니다.
  • 성능: 정적 파일을 극단적으로 많이 뿌리는 상황에서는 여전히 Nginx가 유리하다는 벤치마크가 많습니다. 다만 대부분의 서비스에서 병목은 프록시가 아니라 뒤쪽 애플리케이션에 있습니다.

재미있는 건 Caddy가 Nginx 설정 파일을 읽을 수도 있다는 점입니다. 앞서 Caddyfile이 어댑터라고 했는데, nginx 어댑터도 같은 자리에 끼워 넣을 수 있습니다.

caddy run --config nginx.conf --adapter nginx

완벽하게 변환되지는 않지만, 기존 설정을 옮기는 출발점으로는 쓸 만합니다.

정리하자면 트래픽 규모가 크고 세밀한 튜닝이 필요한 인프라라면 Nginx의 축적된 사례가 든든하고, 개인 프로젝트나 내부 서비스처럼 “빨리 띄우고 인증서에 신경 쓰고 싶지 않은” 쪽이라면 Caddy가 압도적으로 편합니다. 어느 쪽이든 서버를 외부에 노출하지 않고 접근을 통제하고 싶다면 Cloudflare Tunnel 같은 선택지를 함께 검토해 볼 수 있습니다.

마치며

지금까지 Caddy를 설치해서 정적 파일을 띄우고, 자동 HTTPS가 로컬과 공개 도메인을 어떻게 다르게 처리하는지 확인하고, 백엔드 앞에 리버스 프록시로 세워 봤습니다. 전체 과정에서 인증서 파일 경로를 적은 적이 한 번도 없다는 점이 이 도구의 성격을 요약해 줍니다.

여기서 다루지 않은 것 중에는 여러 백엔드로 요청을 분산하는 로드 밸런싱과 헬스 체크가 있습니다. reverse_proxy 뒤에 주소를 여러 개 나열하기만 해도 기본 로드 밸런싱이 켜지고, lb_policyhealth_uri 같은 옵션으로 분산 방식과 장애 감지를 조정할 수 있으니 서비스를 여러 대로 늘릴 때 찾아보시면 좋겠습니다.

더 자세한 내용은 Caddy 공식 문서를 참고하세요.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord