이채강

읽는 데 8분조회 13

Caddy 하나로 여러 서비스 관리하기

버전 1.0.0

작성 기준: 2026-09-08

서버 한 대에 서비스가 하나씩 늘어난다. 홈페이지, 개발용 인스턴스, 방문자 통계, Grafana, Prometheus, 집에 있는 책 서재까지. 처음에는 포트 번호를 외워서 들어갔다. :53000, :59090, :40003. 어느 게 뭔지 헷갈리기 시작하고, 어떤 건 비밀번호가 걸려 있고 어떤 건 없다.

그러다 문제가 하나 터졌다. 감시 도구 세 개가 인증도 없이 인터넷에 열려 있었다.

이 글은 그걸 정리한 과정이다. 원칙은 한 줄이다.

바깥으로 나가는 문은 Caddy 하나뿐이다.

인터넷 ──443──> Caddy ──> 127.0.0.1:포트 ──> 서비스

서비스들은 안쪽에만 열려 있고, 사람이 웹으로 들어오는 길은 전부 Caddy를 지난다. 인증도 Caddy가 한다.


1. 왜 포트를 밖에 열면 안 되는가

먼저 사고 이야기부터.

Prometheus와 Grafana를 docker compose로 띄워 두고 있었다. compose 파일에는 이렇게 적혀 있었다.

  prometheus:
    ports:
      - '59090:9090'

문제는 이 한 줄이다. 포트 앞에 IP를 안 붙이면 도커가 0.0.0.0에 연다. 즉 인터넷 전체에 열린다.

공인 IP로 밖에서 찔러 봤더니 이렇게 나왔다.

:59100  node-exporter  200
:59090  prometheus     302
:58080  cadvisor       307

무엇이 보이는지도 확인했다.

  • node-exporter — 커널 버전, 호스트 이름, 부팅 시각, 디스크와 네트워크 구성
  • prometheus — 질의가 된다. 저장된 모든 지표를 아무나 조회할 수 있다
  • cadvisor — 컨테이너 75개의 이름, 이미지, 자원 사용량

Grafana만 제대로 되어 있었다.

  grafana:
    ports:
      - '127.0.0.1:53000:3000'   # 앞에 IP가 붙어 있다

고치는 건 한 글자씩 붙이는 일이었다.

      - '127.0.0.1:59090:9090'
      - '127.0.0.1:59100:9100'
      - '127.0.0.1:58080:8080'

이렇게 해도 아무 기능이 죽지 않는다. Prometheus는 도커 네트워크 안에서 컨테이너 이름으로 긁는다.

scrape_configs:
  - job_name: 'cadvisor'
    static_configs:
      - targets: ['cadvisor:8080']    # 컨테이너 이름

발행 포트는 애초에 필요가 없었다. 사람이 브라우저로 보려고 열어 둔 것이고, 그건 Caddy로 하면 된다.


2. 주소 하나에 서비스 하나

Caddy 설정은 도메인 이름으로 시작하는 블록의 나열이다. 읽기 쉽다.

leechis.duckdns.org {
	encode zstd gzip
	reverse_proxy 127.0.0.1:40000
}

umami.leechis.duckdns.org {
	encode zstd gzip
	reverse_proxy 127.0.0.1:40003
}

이것만으로 https가 붙는다. Let's Encrypt 인증서를 Caddy가 알아서 받아 오고 알아서 갱신한다. 설정에 인증서 이야기가 한 줄도 없다는 게 이 서버의 가장 좋은 점이다.

DuckDNS 같은 무료 DDNS는 하위 이름이 전부 같은 IP로 풀린다. 그래서 umami.leechis.duckdns.org를 따로 등록할 필요도 없다. Caddy 블록만 만들면 그 주소로 인증서까지 받아 온다.

보안 헤더는 블록마다

	header {
		Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
		X-Frame-Options "DENY"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

-Server는 응답에서 서버 종류를 지운다. 알려 줄 이유가 없다.


3. 인증도 Caddy가 한다

Grafana와 Prometheus는 사람만 보는 화면이다. 여기에 비밀번호를 건다.

grafana.leechis.duckdns.org {
	basic_auth {
		**** $2a$14$...(bcrypt 해시)
	}
	reverse_proxy 127.0.0.1:53000
}

계정 이름은 **** 로 가렸다. 비밀번호의 절반이라 공개할 이유가 없다. 집 서버 주소도 같은 이유로 가렸다.

해시는 이렇게 만든다.

caddy hash-password

설정 파일에 평문을 적지 않는다. bcrypt 해시만 들어간다.

서비스 자체 로그인은 껐다

여기서 판단이 하나 필요했다. Grafana에는 자체 로그인이 있다. Caddy에서도 막고 Grafana에서도 막으면 두 번 물어본다.

Grafana 쪽을 껐다.

    environment:
      - GF_AUTH_ANONYMOUS_ENABLED=true
      - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
      - GF_AUTH_DISABLE_LOGIN_FORM=true
      - GF_AUTH_DISABLE_SIGNOUT_MENU=true

Caddy를 통과하면 바로 관리자다. 안쪽에서 127.0.0.1:53000으로 직접 들어오면 비밀번호 없이 들어간다. 서버에 접속할 수 있는 사람은 이미 무엇이든 할 수 있으니 그 선까지만 막는 것이다.

덤으로 얻은 것

Grafana 자체 로그인에는 5분에 5번 틀리면 잠기는 보호 장치가 있다. 맞는 비밀번호를 넣어도 막힌다.

비밀번호를 시험하다가 실제로 걸렸다. 바꾼 비밀번호가 안 통해서 한참 헤맸는데, 로그를 보니 이렇게 적혀 있었다.

error="[password-auth.failed] too many consecutive incorrect login attempts"

비밀번호가 틀린 게 아니라 잠긴 것이었다. 5분 기다리니 풀렸다.

Caddy의 basic_auth에는 그런 잠금이 없다. 무차별 대입이 걱정되면 앞단에서 따로 막으면 된다. 혼자 쓰는 관리 화면에서는 잠금이 오히려 방해가 된다.


4. 안 되던 것 두 가지

iptables가 받는 포트 범위

Caddy는 요청 수와 응답 시간을 지표로 내보낼 수 있다. Prometheus가 그걸 긁게 하려고 했다.

Caddy의 관리 API(127.0.0.1:2019)에도 /metrics가 있지만 그걸 쓰면 안 된다. 그 API로는 설정을 바꿀 수 있다. 지표만 따로 내보내는 게 맞다.

:2020 {
	metrics /metrics
}

그리고 Prometheus 컨테이너에서 불러 봤더니 이렇게 나왔다.

wget: can't connect to remote host (172.18.0.1): No route to host

iptables -L INPUT을 보니 이유가 있었다.

ACCEPT  tcp  dpts:40000:59999
ACCEPT  tcp  dpt:80
ACCEPT  tcp  dpt:443
REJECT  all  reject-with icmp-host-prohibited

이 서버는 40000~59999만 받는다. 2020은 그 밖이라 컨테이너에서 호스트로 가는 것도 막혔다. 포트를 59200으로 옮겼다.

그런데 그 대역은 바깥에서도 닿는다. 그래서 사설 대역에서 온 것만 답하게 했다.

:59200 {
	@docker remote_ip 172.16.0.0/12 192.168.0.0/16 10.0.0.0/8 127.0.0.1/32
	handle @docker {
		metrics /metrics
	}
	respond 403
}

컨테이너에서는 200, 공인 IP에서는 403이 나온다. 포트는 열려 있지만 지표는 나가지 않는다.

컨테이너에서 호스트를 부르는 이름

Caddy는 컨테이너가 아니라 호스트에서 systemd로 돈다. Prometheus는 컨테이너 안에 있다. 서로 부를 이름이 필요하다.

172.18.0.1을 직접 적으면 도커 네트워크를 다시 만들 때 대역이 바뀌어 어긋난다. 도커가 알아서 맞춰 주는 이름을 쓴다.

  prometheus:
    extra_hosts:
      - 'host.docker.internal:host-gateway'
  - job_name: 'caddy'
    static_configs:
      - targets: ['host.docker.internal:59200']

5. http만 하는 서비스에 https 씌우기

집에 있는 서버에서 calibre(전자책 서재)가 돌고 있다. http 전용이고 포트가 붙어 있다. http://****.iptime.org:40001.

Caddy가 앞에 서면 https가 된다.

books.leechis.duckdns.org {
	reverse_proxy http://****.iptime.org:40001 {
		# 집으로 나갔다 오는 길이라 넉넉히 준다
		transport http {
			dial_timeout 10s
			response_header_timeout 30s
		}
		header_up Host {upstream_hostport}
		header_up X-Forwarded-Proto https
	}
}

시간 제한을 늘린 이유가 있다. 같은 서버 안이 아니라 집 인터넷을 거쳐 나갔다 온다. 기본값으로는 느릴 때 끊긴다.

이 블록은 원래 127.0.0.1:40001을 보고 있었다. 그 앱은 이 서버가 아니라 집에 있는데 말이다. 그래서 502만 나오는 주소가 오래 열려 있었다. 넘길 곳이 맞는지 한 번씩 확인해야 한다.

집 인터넷이 끊기면 이 주소도 502가 된다. 그건 어쩔 수 없다.


6. 지표에 도메인 라벨이 없던 문제

Caddy 지표를 켜는 것은 전역 설정 한 줄이다.

{
	metrics
}

켜고 보니 이렇게 나왔다.

caddy_http_requests_total{handler="subroute",server="srv1"} 5

srv1 하나에 다섯 도메인이 다 합쳐져 있었다. 어느 도메인이 느린지, 어디서 5xx가 나는지 나눠 볼 수 없다.

버전 문제였다. 깔려 있던 것은 우분투 저장소의 2.6.2, 2022년 것이다. 우분투는 그 릴리스에 그 버전만 준다.

공식 저장소를 등록하고 최신으로 올렸다.

curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
  | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
  | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy=2.11.4

설정 파일이 덮어써지지 않게 조심해야 한다.

sudo apt-get install -o Dpkg::Options::="--force-confold" caddy=2.11.4

올리고 나서 per_host를 켰다.

{
	metrics {
		per_host
	}
}

이제 나뉘어 나온다.

caddy_http_requests_total{host="leechis.duckdns.org",...} 2
caddy_http_requests_total{host="dev.leechis.duckdns.org",...} 1
caddy_http_requests_total{host="umami.leechis.duckdns.org",...} 1
caddy_http_requests_total{host="books.leechis.duckdns.org",...} 1

도메인 수만큼 시계열이 늘어난다. 다섯 개라 부담이 없지만, 도메인이 수백 개면 생각해 볼 문제다.


7. 고치기 전에 검사하고, 고친 뒤에 확인한다

Caddy 설정을 잘못 고치면 사이트 전체가 죽는다. 이 순서를 지킨다.

sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.bak-$(date +%Y%m%d-%H%M%S)
# ... 고친다 ...
sudo -u caddy caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
sudo systemctl reload caddy

validatecaddy 사용자로 돌리는 이유가 있다. 내 계정으로 돌리면 로그 파일을 못 열어서 이런 오류가 난다.

Error: setting up custom log 'log0': ... permission denied

설정이 틀린 게 아니라 파일 권한 때문이다. 처음에 이걸로 한참 헤맸다.

reload는 프로세스를 죽이지 않는다. 접속이 끊기지 않는다.

그리고 고친 뒤에 실제로 되는지 확인한다.

for u in https://leechis.duckdns.org/ https://umami.leechis.duckdns.org/login; do
  printf '%-46s %s\n' "$u" "$(curl -s -o /dev/null -m 15 -w '%{http_code}' "$u")"
done

비밀번호를 걸었으면 세 가지를 본다.

curl -s -o /dev/null -w '%{http_code}\n' https://grafana.leechis.duckdns.org/          # 401
curl -s -o /dev/null -u '****:틀림' -w '%{http_code}\n' https://grafana.leechis...  # 401
curl -s -o /dev/null -u "****:$PW" -w '%{http_code}\n' https://grafana.leechis...   # 200

맞는 비밀번호가 통하는 것만 보면 안 된다. 틀린 비밀번호가 막히는 것도 봐야 한다. 인증 설정을 잘못 써서 모두 통과시키는 경우가 있다.


8. 비밀번호가 두 곳에 있으면 어긋난다

Caddy로 인증을 옮기고 나서 문제가 하나 생겼다. 비밀번호가 두 곳에 있게 됐다.

  • /etc/caddy/Caddyfile — bcrypt 해시. 실제로 막는 것
  • ~/MyHome/.env — 평문. 잊지 않으려고 적어 둔 메모

메모만 고치고 왜 안 바뀌냐고 묻게 된다. 실제로 그랬다.

한 번에 고치는 스크립트를 만들었다. 순서는 이렇다.

  1. 지금 계정을 실제 있는 곳에서 읽어 보여 준다 (메모를 믿지 않는다)
  2. 계정을 물어본다 (엔터면 그대로)
  3. 비밀번호를 두 번 물어본다
  4. 설정을 백업한다
  5. 고친다
  6. caddy validate — 떨어지면 백업으로 되돌린다
  7. reload
  8. 실제로 되는지 확인한다 — 맞는 것 통과, 틀린 것 차단
  9. 확인에 실패하면 되돌린다
  10. 메모도 고친다

비밀번호를 명령줄 인수로 받지 않는다. 인수로 주면 셸 기록과 프로세스 목록에 남는다. 화면에도 찍지 않는다.

read -rs new

정리

Caddy를 문지기 하나로 두면 이렇게 된다.

어디에
어떤 주소가 어디로 가는지Caddyfile 한 파일
https 인증서Caddy가 알아서
비밀번호Caddyfile의 bcrypt 해시
보안 헤더블록마다
요청 지표도메인별로

서비스는 자기 일만 한다. 인증도 인증서도 신경 쓰지 않는다.

새 서비스를 붙일 때 할 일도 정해졌다.

  1. 컨테이너는 127.0.0.1:포트로만 연다. 포트 앞에 IP를 꼭 붙인다
  2. 포트는 방화벽이 받는 범위에서 고른다
  3. Caddy에 블록을 만들고 보안 헤더를 붙인다
  4. 사람이 보는 화면이면 basic_auth를 건다
  5. 고치기 전에 백업하고, validate하고, 고친 뒤에 확인한다

마지막이 제일 중요하다. 이번에 세 번 넘어졌는데 세 번 다 "확인을 눈으로만 하고 넘어간" 것이 원인이었다.

댓글

첫 댓글을 남겨보세요.

이름 20자, 댓글 1000자까지

← 글 목록으로