읽는 데 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 가 한 곳이면 손이 준다
사는 건 이렇다.
- Cloudflare 대시보드 > Domain Registration > Register Domains
- 원하는 이름을 넣고 검색한다
- 값과 기간(1년부터)을 고르고 결제한다
- 등록자 정보를 채운다 — 공개 조회에는 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 주소를 짝지어 준다.
| Type | Name | IPv4 address | Proxy | TTL |
|---|---|---|---|---|
| A | @ | 서버 공인 IP | 주황 | Auto |
| A | www | 서버 공인 IP | 주황 | Auto |
| A | dev | 서버 공인 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 은 언제 쓰나
주소 대신 다른 이름을 가리킬 때 쓴다. 가리키는 쪽이 주소를 바꾸면 따라 움직이므로, 내가 관리하지 않는 곳을 붙일 때 편하다.
| Type | Name | Target |
|---|---|---|
| CNAME | blog | something.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.ts의allowedDevOrigins— 안 고치면 개발 서버에서 하이드레이션이 안 끝난다- 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 대역에서 온 것만 받도록 막아야 한다. 프록시를 켜는 것만으로는 반쪽이다.
정리
하루치 작업이었지만 순서가 있었다.
- 도메인을 산다 — Cloudflare 에서 사면 네임서버를 따로 돌릴 일이 없다
- A 레코드를 만들고 구름을 켠다 — 포트가 80·443 이 아닌 이름은 회색으로
- 프록시를 켠 직후 사이트는 525 다 — 정상이다. 인증서가 아직 없다
- Origin Certificate 를 받아 서버에 두고 Caddy 가 가리키게 한다 — SSL/TLS 모드는 Full (strict)
- 옛 주소는 301 로 넘긴다 — 지우는 건 검색 결과가 넘어간 뒤에
- 주소가 박힌 곳을 전부 찾는다 — 설정, 감시, 문서, 시험
- 주소를 문자열로 다루는 곳을 다시 읽는다 —
.dev같은 함정이 있다 - 주소에 기대는 기능을 실제로 돌려 본다 — 패스키가 그랬다
그리고 배운 것 하나.
도메인을 바꾸는 일은 DNS 를 바꾸는 일이 아니다. 주소는 설정 파일에, 알림 규칙에, 시험 코드에, 남의 검색 결과에, 그리고 브라우저가 기억하는 열쇠에까지 스며 있다. 그 스물아홉 군데를 찾는 것이 일의 대부분이었다.
댓글
첫 댓글을 남겨보세요.