이채강

읽는 데 12분조회 1

도메인을 구입해 사이트를 옮겼다 — Cloudflare 에서 사고 DNS 붙이고 프록시 뒤에 Caddy 세우기

버전 1.0.0

작성 기준: 2026-09-18

개인 도메인을 샀다. leechis.dev.

그전까지는 leechis.duckdns.org 를 썼다. 공짜 DDNS라 불만은 없었는데, 명함에 적기도 애매하고 무엇보다 남의 이름 밑에 세든 기분이었다.

사는 데 십 분, 붙이는 데 하루가 걸렸다. 사이트 주소를 바꾸는 일은 "DNS 를 새 주소로 돌린다" 가 전부가 아니었다. 인증서가 두 장이 되고, 알림 규칙이 엉뚱하게 맞아떨어지고, 잘 돌던 시험이 흔들리기 시작했다.

이 글은 그 하루의 기록이다.


1. Cloudflare 에서 도메인 사기

등록기관을 Cloudflare 로 골랐다. 이유는 셋이다.

  • 원가로 판다. Cloudflare Registrar 는 도매가에 얹지 않는 것으로 알려져 있다. 갱신할 때 값이 뛰지도 않는다
  • WHOIS 가리기가 공짜다. 개인 도메인이라 이름과 주소가 조회되면 곤란한데, 다른 데서는 이게 유료인 경우가 많다
  • 어차피 DNS 를 여기서 쓸 거였다. 등록과 DNS 가 한 곳이면 손이 준다

사는 건 이렇다.

  1. Cloudflare 대시보드 > Domain Registration > Register Domains
  2. 원하는 이름을 넣고 검색한다
  3. 값과 기간(1년부터)을 고르고 결제한다
  4. 등록자 정보를 채운다 — 공개 조회에는 Cloudflare 의 가림 정보가 대신 나간다

십 분이면 끝난다.

여기서 한 가지 편한 점이 있다. 다른 데서 산 도메인을 Cloudflare 로 가져올 때는 네임서버를 바꾸고 전파를 기다리는 단계가 따로 있는데, Cloudflare 에서 사면 그게 없다. 등록되는 순간 이미 Cloudflare DNS 다.

$ dig +short NS leechis.dev
igor.ns.cloudflare.com.
katja.ns.cloudflare.com.

바꿔 말하면 등록과 DNS 가 묶인다. Cloudflare 에서 산 도메인은 다른 곳의 네임서버를 쓸 수 없다. 나는 어차피 Cloudflare 를 쓸 생각이었으니 상관없었지만, DNS 를 따로 굴리고 싶은 사람에게는 걸리는 대목이다.

.dev 를 고른 이유

.dev 는 구글이 운영하는 TLD 다. 특이한 점이 하나 있다.

HSTS 목록에 TLD 통째로 올라가 있다. 브라우저가 .dev 로 끝나는 주소는 무조건 https 로만 연다. http://leechis.dev 를 쳐도 브라우저가 요청을 보내기 전에 스스로 https 로 바꾼다.

인증서를 빼먹을 수가 없는 도메인이라는 뜻이다. 실수로 http 로 열어 두고 "왜 자물쇠가 없지" 할 일이 없다. 대신 인증서가 깨지면 우회할 방법도 없다. "안전하지 않음" 을 누르고 들어가는 그 화면이 나오지 않는다.

개인 사이트에는 이 강제가 오히려 낫다고 봤다.


2. DNS 붙이기 — A, AAAA, CNAME 그리고 구름

대시보드의 DNS > Records 에서 한다. 우리 서버를 가리키는 줄을 만드는 일이다.

A 레코드부터

가장 기본이다. 이름과 IPv4 주소를 짝지어 준다.

TypeNameIPv4 addressProxyTTL
A@서버 공인 IP주황Auto
Awww서버 공인 IP주황Auto
Adev서버 공인 IP주황Auto

@ 는 도메인 자기 자신을 뜻한다. leechis.dev 로 들어오는 것을 받는 줄이다. 서브도메인은 앞부분만 적는다 — dev 라고 쓰면 dev.leechis.dev 가 된다. 전체를 적어도 알아서 잘라 준다.

나는 이렇게 일곱 줄을 만들었다.

@  www  dev  grafana  books  prometheus  umami

AAAA 는 안 만들어도 된다

IPv6 주소를 다는 줄이 AAAA 다. 내 서버는 IPv4 밖에 없어서 만들 수가 없었다.

그런데 바깥에서 조회해 보면 IPv6 가 나온다.

$ dig +short AAAA leechis.dev
2606:4700:3034::6815:243c
2606:4700:3034::ac43:ba65

Cloudflare 가 자기 IPv6 로 답해 주고 있는 것이다. 프록시를 켜면 방문자는 Cloudflare 와 이야기하므로, 뒤에 있는 내 서버가 IPv6 를 몰라도 IPv6 만 쓰는 방문자가 들어올 수 있다. 공짜로 얻는 것이라 그냥 두면 된다.

회색으로 둔 줄에는 이게 없다. 거기는 내 서버 주소가 그대로 나가기 때문이다.

CNAME 은 언제 쓰나

주소 대신 다른 이름을 가리킬 때 쓴다. 가리키는 쪽이 주소를 바꾸면 따라 움직이므로, 내가 관리하지 않는 곳을 붙일 때 편하다.

TypeNameTarget
CNAMEblogsomething.pages.dev

원래 DNS 규칙으로는 최상위(@)에 CNAME 을 걸 수 없는데, Cloudflare 는 된다. 조회가 오면 뒤를 대신 찾아서 주소로 답해 준다(CNAME flattening). 다만 나는 서버 주소가 고정이라 A 레코드로 충분했다.

구름을 켤지 말지

이게 이 화면에서 가장 중요한 선택이다.

구름바깥에서 보이는 것
주황 (Proxied)Cloudflare 가 중간에 선다Cloudflare 주소
회색 (DNS only)주소만 알려 준다내 서버 주소

주황으로 켜면 원본 주소가 가려지고, 인증서와 캐시도 Cloudflare 가 맡는다. 대신 80·443 이 아닌 포트는 넘겨 주지 않는다.

그래서 내 구성은 이렇게 갈렸다.

leechis.dev 와 서브도메인 여섯 개   주황   웹으로 들어오는 자리
ubuntu-micro.leechis.dev            회색   지표를 59100 으로 긁는다
oracle-micro.leechis.dev            회색   같음

뒤의 둘을 주황으로 켰다면 그 자리의 감시가 조용히 끊겼을 것이다. 표준 포트가 아니기 때문이다.

TTL 은 주황으로 켜면 Auto 로 고정된다. 어차피 방문자는 Cloudflare 주소를 받아 가므로 내가 정할 것이 없다. 회색일 때만 값을 고를 수 있다.

다 했으면 확인

레코드는 대개 몇 초 안에 퍼진다. 바깥에서 조회해 본다.

dig +short A leechis.dev        # 주황이면 Cloudflare 주소가 나온다
dig +short A ubuntu-micro.leechis.dev   # 회색이면 내 서버 주소가 나온다

주황인데 내 서버 주소가 나오면 구름이 안 켜진 것이다.


3. 프록시를 켜자 사이트가 525 를 줬다

Cloudflare 에 도메인을 올리면 DNS 레코드마다 구름 아이콘이 붙는다. 회색이면 주소만 알려 주는 것이고, 주황이면 Cloudflare 가 중간에 선다.

주황으로 켰다. 바깥에서 dig 를 쳐 보면 이렇게 나온다.

leechis.dev.  300  IN  A  104.21.x.x
leechis.dev.  300  IN  A  172.67.x.x

내 서버 주소가 안 보인다. 원하던 그림이다.

그리고 사이트를 열었더니 이랬다.

$ curl -sI https://leechis.dev
HTTP/2 525

525 는 "Cloudflare 와 오리진 사이에 SSL 악수가 깨졌다" 는 뜻이다.

까닭은 간단했다. Cloudflare 는 우리 서버에 https 로 붙으려 하는데, 내 Caddy 에는 leechis.dev 라는 이름으로 된 설정이 아직 없었다. 이름이 없으니 내밀 인증서도 없고, 악수가 될 리가 없다.


4. 인증서가 두 장이라는 것

여기서 한 번 헷갈렸다. "Caddy 가 Let's Encrypt 로 알아서 받아 오니까 블록만 만들면 되겠지" 하고 생각했는데, 그게 안 된다.

구간이 둘로 나뉘기 때문이다.

브라우저 ──[인증서 A]── Cloudflare ──[인증서 B]── 내 Caddy

A 는 Cloudflare 가 자기 이름으로 받아서 자기가 갱신한다. 브라우저에서 자물쇠를 눌러 보면 이게 나온다.

issuer  Let's Encrypt
기한    2026-09-17 ~ 2026-12-16   (3개월)

내가 손댈 일이 없다. 만료도 저쪽이 알아서 넘긴다.

B 가 내 몫이다. 그런데 이 구간에서는 Let's Encrypt 자동 발급이 통하지 않는다. Caddy 가 쓰는 두 가지 확인 방법이 둘 다 막힌다.

  • TLS-ALPN — Cloudflare 가 TLS 를 자기가 끊어 버리니 확인 요청이 나에게 오지 않는다
  • HTTP-01 — 80 포트로 확인하러 오는데, Cloudflare 의 "Always Use HTTPS" 가 켜져 있으면 그 요청이 엣지에서 https 로 튕겨 나가 서버까지 못 온다

방법이 셋 있었다.

방법평가
Cloudflare Origin Certificate갱신이 없다. 이 상황을 위해 있는 물건
DNS-01 자동 발급플러그인을 넣어 Caddy 를 다시 빌드해야 한다
프록시 껐다 켜기90일마다 같은 일을 반복하다 언젠가 잊는다

첫 번째로 갔다.

Cloudflare 대시보드 > SSL/TLS > Origin Server > Create Certificate 에서 호스트 이름에 leechis.dev*.leechis.dev 를 둘 다 넣는다. 와일드카드 하나로 서브도메인까지 다 덮는다.

subject  CloudFlare Origin Certificate
기한     2026-09-17 ~ 2041-09-13   (15년)
이름     *.leechis.dev, leechis.dev

15년이다. 공개적으로 신뢰받는 인증서가 아니라 Cloudflare 와 나 사이의 약속일 뿐이라, 수명을 길게 줘도 된다. 덕분에 갱신이라는 일 자체가 사라진다.

받은 것을 서버에 둔다.

sudo mkdir -p /etc/caddy/certs
sudo vi /etc/caddy/certs/leechis.dev.pem   # 인증서
sudo vi /etc/caddy/certs/leechis.dev.key   # 개인키
sudo chmod 600 /etc/caddy/certs/leechis.dev.key
sudo chown caddy:caddy /etc/caddy/certs/*

그리고 Cloudflare 의 SSL/TLS 모드를 Full (strict) 로 둔다. 이게 "오리진 인증서도 제대로 검사하겠다" 는 설정이다.


5. Caddyfile 에 붙이기

사이트 블록이 여섯 개였다. 같은 tls 두 줄을 여섯 번 쓰기 싫어서 조각 (snippet)으로 뺐다.

# Cloudflare 뒤에 선 사이트가 함께 쓰는 인증서.
#
# 바깥에서 보이는 것은 Cloudflare 인증서다. 이건 Cloudflare 와 우리 사이
# 에서만 쓴다(Origin Certificate, *.leechis.dev 까지 덮고 2041년까지).
(cf_origin) {
	tls /etc/caddy/certs/leechis.dev.pem /etc/caddy/certs/leechis.dev.key
}

블록마다 한 줄이면 끝난다.

leechis.dev {
	import cf_origin

	encode zstd gzip
	header { ... }
	reverse_proxy 127.0.0.1:40000
}

반영하는 순서가 중요하다. Caddy 는 이 서버의 모든 도메인을 받는 문이라, 잘못 밀어 넣으면 사이트 하나가 아니라 전부가 같이 죽는다.

sudo cp /etc/caddy/Caddyfile ~/caddyfile.bak       # 되돌릴 것부터
sudo vi /etc/caddy/Caddyfile
sudo caddy validate --config /etc/caddy/Caddyfile  # 문법부터
sudo systemctl reload caddy                        # reload 는 연결을 안 끊는다

validate 를 돌리면 경고가 하나 뜬다.

WARN  tls  stapling OCSP: no OCSP stapling for
      [cloudflare origin certificate *.leechis.dev leechis.dev]:
      no URL to issuing certificate

무시해도 된다. OCSP stapling 은 "이 인증서 취소된 거 아니냐" 는 물음에 미리 받아 둔 답을 붙여 주는 기능인데, 오리진 인증서는 공개 인증서가 아니라 그 창구가 아예 없다. 브라우저가 볼 일도 없는 인증서다.

그리고 로그에서 이 줄들을 확인했다.

skipping automatic certificate management because
one or more matching certificates are already loaded
  leechis.dev, www, dev, grafana, books, prometheus, umami

일곱 개가 전부 "Let's Encrypt 를 시도하지 않겠다" 고 말하고 있다. 프록시 뒤에서는 시도해 봐야 실패할 텐데 아예 안 하니 맞게 붙은 것이다.


6. 옛 주소는 버리지 않는다

새 주소가 열렸으니 duckdns 는 지워도 될 것 같았다. 지우지 않았다.

그동안 쌓인 검색 순위가 옛 주소에 붙어 있기 때문이다. 지금 끊으면 그걸 그냥 버리는 셈이다. 301(영구 이동)로 넘겨 두면 검색엔진이 "이 주소가 저리로 갔다" 를 배우고 순위를 옮겨 준다. 보통 몇 달 걸린다.

leechis.duckdns.org {
	redir https://leechis.dev{uri} permanent
}

dev.leechis.duckdns.org {
	redir https://dev.leechis.dev{uri} permanent
}

{uri} 를 붙이는 게 요점이다. 이게 없으면 duckdns.org/blog/어떤글 로 온 사람이 새 사이트 첫 화면에 떨어진다. 붙이면 경로까지 그대로 따라간다.

$ curl -sI https://leechis.duckdns.org/blog
HTTP/2 301
location: https://leechis.dev/blog

여기서 한 가지. 옛 주소 블록에는 import cf_origin 을 넣으면 안 된다. duckdns 는 Cloudflare 를 거치지 않으니 여태처럼 Let's Encrypt 를 쓰는데, 오리진 인증서에는 duckdns 이름이 들어 있지 않다.

같은 이유로 www.leechis.dev 는 duckdns 와 한 블록에 묶을 수 없었다. 하는 일은 똑같이 "새 주소로 넘기기" 인데 인증서가 다르다.

leechis.duckdns.org {
	redir https://leechis.dev{uri} permanent
}

www.leechis.dev {
	import cf_origin
	redir https://leechis.dev{uri} permanent
}

검색엔진에는 따로 알렸다. Search Console 의 주소 변경 도구가 그 일을 한다. IndexNow 로도 새 주소를 밀어 넣었다(빙·네이버·얀덱스가 받는다. 구글은 IndexNow 에 참여하지 않으므로 301 로 알아서 따라온다).


7. 주소가 박혀 있던 스물아홉 곳

Caddy 만 고치면 끝일 줄 알았는데 아니었다. 저장소를 뒤졌더니 옛 주소가 스물아홉 파일에 흩어져 있었다.

중요한 것만 추리면 이렇다.

  • NEXT_PUBLIC_SITE_URL — canonical, OG 이미지, sitemap, robots 가 전부 이 값을 본다. 이게 옛 주소면 배포해도 화면은 여전히 옛 주소를 가리킨다
  • Grafana 의 GF_SERVER_ROOT_URL — Grafana 는 자기 주소를 하나만 안다. 안 고치면 새 주소로 들어가도 옛 주소로 되돌리려 든다
  • Prometheus 의 감시 대상 목록 — 바깥에서 찔러 보는 주소들
  • next.config.tsallowedDevOrigins — 안 고치면 개발 서버에서 하이드레이션이 안 끝난다
  • IndexNow 열쇠, playwright 기본 주소, 문서들

변경 이력(CHANGELOG)과 로드맵은 일부러 안 고쳤다. 그때는 그 주소가 맞았다. 지난 기록을 소급해서 바꾸면 그게 거짓말이 된다.


8. 도메인이 .dev 라서 생긴 함정

이게 오늘 가장 아슬아슬했던 대목이다.

감시 알림 규칙에 이런 게 있었다. "개발기는 빼고 본다." 개발 인스턴스는 하루에도 몇 번씩 내렸다 올리니까 그걸 장애로 세면 알림이 쓸모없어진다.

expr: probe_success{job="blackbox-http", instance!~".*dev\\..*"}

주소에 dev. 가 들어가면 뺀다는 뜻이다. dev.leechis.duckdns.org 를 거르려고 쓴 것이고, 잘 돌고 있었다.

그런데 도메인이 leechis.dev 가 됐다.

https://leechis.dev 는 저 규칙에 걸릴까? 안 걸린다. dev 뒤에 점이 없기 때문이다. 그러니까 지금은 운이 좋아서 맞아떨어진 것뿐이다. 누군가 운영 주소를 https://leechis.dev. 처럼 적거나 경로를 하나 덧붙이는 순간 운영 장애를 못 잡는 규칙이 된다.

뜻을 그대로 적도록 고쳤다.

# 개발기는 뺀다. 주소가 dev. 으로 시작하는 것만 고른다 —
# 도메인이 leechis.dev 라서 "dev 가 들어간 것" 으로 거르면
# 운영까지 같이 빠진다.
expr: probe_success{job="blackbox-http", instance!~"https://dev\\..*"}

"어쩌다 맞는 규칙" 과 "맞게 쓴 규칙" 은 다르다. 도메인을 바꿀 때는 주소를 문자열로 다루는 곳을 전부 다시 읽어야 한다.


9. 패스키는 프록시 뒤에서도 되는가

바로 전날 패스키(WebAuthn) 로그인을 붙여 둔 참이었다. 이게 제일 걱정됐다.

패스키는 도메인에 묶인다. 브라우저는 "지금 보고 있는 주소" 와 "서버가 말하는 주소(rpID)" 가 같은지 확인하고, 다르면 그냥 거절한다. 가짜 사이트가 진짜 사이트의 패스키를 받아 가지 못하게 하는 장치다.

내 앱은 rpID 를 박아 두지 않고 들어온 요청에서 뽑는다. 운영과 개발이 다른 주소로 뜨기 때문이다. 그런데 앞에 Cloudflare 가 한 겹 더 생겼다. 앱이 보는 주소가 브라우저가 보는 주소와 달라지면 패스키가 통째로 막힌다.

머리로는 "Cloudflare 가 Host 를 그대로 넘겨 주니까 괜찮을 것" 이었지만, 이건 틀리면 조용히 망가지는 종류라 실제로 돌려 봤다. 크롬에 가상 인증기를 꽂고 등록부터 로그인까지 끝까지.

✓ 등록 → 이름 고치기 → 로그아웃 → 패스키로 들어오기 → 지우기

통과했다. Cloudflare 는 브라우저가 친 주소를 Host 그대로 넘기고, Caddy 가 그것을 X-Forwarded-Host 로 앱에 전한다. rpID 가 어긋나지 않는다.

앞단을 하나 더 세울 때는 "주소가 무엇으로 보이는가" 에 기대는 기능을 전부 다시 확인해야 한다. 패스키가 그런 기능이었다.


10. 시험이 흔들리기 시작했다

주소를 옮기고 브라우저 시험을 돌렸더니 넷이 깨졌다. 다시 돌렸더니 이번엔 다른 다섯이 깨졌다.

매번 다른 곳이 깨지는 건 코드가 틀린 게 아니라는 신호다. 같은 시험을 localhost 로 돌려 봤다. 전부 통과했다.

원인은 거리였다. 시험이 개발 사이트를 Cloudflare 너머로 두드리고 있었다. LA 엣지를 거쳐 오가니 한 박자씩 늦고, 최적화하지 않은 개발 서버가 화면을 늦게 채우니 60초를 기다리다 죽었다.

기본 대상을 오리진으로 바꿨다.

// Cloudflare 를 거치지 않고 곧장 두드린다. 앱이 멀쩡한지 보려고 돌리는
// 시험인데 남의 CDN 사정에 흔들릴 이유가 없다.
const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:40002";

127.0.0.1 이 아니라 localhost 인 것도 이유가 있다. WebAuthn 은 rpID 로 도메인만 받고 IP 는 거절한다. 127.0.0.1 로 돌리면 패스키 시험이 등록 단계에서 깨진다. 이건 앞서 CI 에서 한 번 당하고 알았다.

운영이 느려진 건 아닌지도 재 봤다.

Cloudflare 거쳐  첫 바이트 110~150ms

멀쩡했다. 느린 건 개발 서버 쪽이었다.


11. 가려졌다고 믿는 것과 실제로 가려진 것

프록시를 켜면 원본 주소가 안 보인다. 그래서 다 숨은 줄 알았는데 아니었다.

  • 회색으로 둔 이름들 — 지표를 긁어야 해서 ubuntu-micro, oracle-micro 는 회색이다. 그 이름을 조회하면 그 서버 주소가 그대로 나온다
  • 옛 duckdns 주소 — 아직 원본을 가리킨 채 살아 있다. 301 로 넘기려고 남겨 둔 것이라 조회하면 내 서버가 나온다

지금 내 서버 주소는 마음만 먹으면 찾을 수 있다. duckdns 를 지우는 날 함께 정리할 일이고, 그때까지는 "가려졌다" 고 믿지 않는 편이 맞다.

정말 가리려면 방화벽에서 80·443 을 Cloudflare 대역에서 온 것만 받도록 막아야 한다. 프록시를 켜는 것만으로는 반쪽이다.


정리

하루치 작업이었지만 순서가 있었다.

  1. 도메인을 산다 — Cloudflare 에서 사면 네임서버를 따로 돌릴 일이 없다
  2. A 레코드를 만들고 구름을 켠다 — 포트가 80·443 이 아닌 이름은 회색으로
  3. 프록시를 켠 직후 사이트는 525 다 — 정상이다. 인증서가 아직 없다
  4. Origin Certificate 를 받아 서버에 두고 Caddy 가 가리키게 한다 — SSL/TLS 모드는 Full (strict)
  5. 옛 주소는 301 로 넘긴다 — 지우는 건 검색 결과가 넘어간 뒤에
  6. 주소가 박힌 곳을 전부 찾는다 — 설정, 감시, 문서, 시험
  7. 주소를 문자열로 다루는 곳을 다시 읽는다.dev 같은 함정이 있다
  8. 주소에 기대는 기능을 실제로 돌려 본다 — 패스키가 그랬다

그리고 배운 것 하나.

도메인을 바꾸는 일은 DNS 를 바꾸는 일이 아니다. 주소는 설정 파일에, 알림 규칙에, 시험 코드에, 남의 검색 결과에, 그리고 브라우저가 기억하는 열쇠에까지 스며 있다. 그 스물아홉 군데를 찾는 것이 일의 대부분이었다.

댓글

첫 댓글을 남겨보세요.

이름 20자, 댓글 1000자까지

← 글 목록으로