Cloudflare Pages 미리보기 배포에서 링크를 누르면 운영 사이트로 넘어갑니다
Cloudflare Pages는 운영이 아닌 브랜치로 올리면 미리보기 배포(운영 주소와 별개로 열어보는 시험용 배포)를 만들어 줍니다.
그런데 템플릿이 링크를 절대 주소로 찍으면, 미리보기 첫 화면의 글 카드가 birchholt.com을 가리키고 그 주소는 404입니다.
미리보기에만 있는 새 글이라 운영 사이트에는 그 페이지가 아직 없기 때문입니다.
저는 절대 주소를 쓰도록 바꾼 템플릿 복사본을 미리보기에 올려 이 증상을 그대로 만들어 봤습니다.

원인은 Hugo의 baseURL(사이트 주소의 뿌리로 쓰는 설정값)입니다.
설정 파일에 운영 주소가 적혀 있으니 어디에 배포하든 절대 주소는 운영을 가리킵니다.
이 글은 2026-10-06에 운영 저장소의 복사본으로 재현하고, 미리보기 배포에 실제로 올려서 잰 결과입니다.
Hugo는 v0.165.0 extended, 배포 도구는 wrangler 4.147.0입니다. 실험한 미리보기 배포는 끝나고 모두 지웠습니다.
미리보기에서 누른 링크가 운영으로 넘어갔습니다

증상을 먼저 그대로 만들었습니다.
Hugo 테마에 따라 메뉴와 글 링크, CSS 주소를 absURL이나 .Permalink로 찍기도 합니다.
둘 다 baseURL을 앞에 붙인 절대 주소를 냅니다.
복사본 템플릿을 그렇게 바꾸고 CSS에 한 줄을 더해 파일 지문(내용이 바뀌면 파일 이름도 바뀌게 붙는 해시)이 달라지게 했습니다.
미리보기에서만 보일 글도 하나 넣었습니다.
$ npx --yes wrangler@latest pages deploy pubA --project-name birchholt \
--branch "Lab/BaseURL_Test" --commit-dirty=true
✨ Deployment complete! Take a peek over at https://d22cd58e.birchholt.pages.dev
✨ Deployment alias URL: https://lab-baseurl-test.birchholt.pages.dev
미리보기 첫 화면을 받아서 링크가 어디를 가리키는지 찍었습니다.
<link rel=stylesheet href=https://birchholt.com/css/main.d63e9d5a….css
css on prod: 404
same css path on preview: 200
lab post href: href=https://birchholt.com/posts/lab-preview-only/
lab post on prod: 404
lab post on preview: 200
CSS도 새 글도 미리보기에는 있는데 링크는 운영으로 나갑니다. 운영에는 아직 그 파일이 없으니 404입니다. CSS가 404면 화면은 스타일 없이 맨 글자로 뜹니다. CSS에 지문을 붙이지 않는 사이트라면 404 대신 운영의 예전 CSS가 붙을 수 있습니다. 그쪽이 더 알아채기 어렵겠지만 이 경우는 이번에 재보지 않았습니다.
제 사이트는 이미 대부분 상대 주소였습니다

그럼 운영 템플릿은 어떨까 싶어서 고치지 않은 복사본을 그대로 빌드하고 운영 주소가 박힌 자리를 태그별로 셌습니다.
| 자리 | 개수 |
|---|---|
ld+json(구조화 데이터)의 url·@id 등 | 72 |
canonical | 33 |
og:url | 24 |
og:image | 18 |
alias 페이지의 meta refresh | 10 |
<a href> | 10 — 전부 alias 페이지 안 |
RSS alternate | 1 |
CSS·스크립트·이미지의 href/src | 0 |
이 사이트 템플릿은 메뉴와 글 카드, CSS, 본문 이미지에 relURL과 .RelPermalink를 씁니다.
이 둘은 /posts/…처럼 도메인 없는 주소를 내니 어느 도메인으로 열든 그 도메인 안에서 움직입니다.
그래서 미리보기에서 눌러도 넘어가지 않는 게 정상이었습니다.
운영을 가리키는 건 화면에 보이지 않는 자리입니다.
canonical(검색엔진에 원본 주소를 알려주는 표시), 공유 미리보기용 og: 태그, 사이트맵, RSS가 그렇습니다.
예외로 눈에 보이는 게 하나 있습니다. alias 페이지(옛 주소에서 새 주소로 보내는 리디렉션 전용 페이지)입니다.
Hugo가 알아서 만드는 /posts/page/1/도 alias인데, 미리보기에서 열면 https://birchholt.com/posts/로 넘어갔습니다.
첫 번째 처방은 baseURL이 아니라 템플릿입니다. 링크·CSS·이미지를 relURL, .RelPermalink로 바꾸면 미리보기에서 운영으로 튀는 증상의 대부분이 사라집니다.
nathanv의 2023년 글도 absURL→relURL, .Permalink→.RelPermalink를 먼저 하라고 적습니다.
남는 건 원래 절대 주소여야 하는 자리들입니다.
-b로 미리보기 주소를 주면 전부 따라옵니다

남은 절대 주소까지 맞추려면 빌드할 때 baseURL을 바꿔 넣으면 됩니다.
Hugo의 -b(--baseURL) 옵션입니다.
Hugo 명령 문서에는 「hostname (and path) to the root」라고 적혀 있습니다.
앞에서 절대 주소로 바꿔 둔 복사본을 미리보기 별칭 주소로 다시 빌드했습니다.
$ hugo --gc --minify -b "https://lab-baseurl-test.birchholt.pages.dev/"
| 운영 주소 | 미리보기 주소 | |
|---|---|---|
<a href> | 0 | 94 |
canonical | 0 | 35 |
CSS href | 0 | 26 |
sitemap.xml | 0 | 22 |
index.xml(RSS) | 0 | 67 |
운영 주소는 본문 속 예시 문자열과 개인정보처리방침 설명문, 그리고 robots.txt의 Sitemap: 줄에만 남았습니다.
robots.txt는 static/에 둔 정적 파일이라 -b가 손대지 않습니다. 미리보기에서 사이트맵까지 따라가 보려면 이 줄은 따로 봐야 합니다.
같은 브랜치로 다시 올리고, 미리보기 첫 화면의 절대 링크 11종을 하나씩 찍었더니 전부 200이었습니다.
CSS, RSS, 새 글, 개인정보처리방침까지 미리보기 안에서 열렸습니다.
여기서 하나 판단할 게 있습니다. canonical도 미리보기 주소로 바뀝니다.
미리보기 화면만 보면 문제없습니다.
Cloudflare가 미리보기 배포에 X-Robots-Tag: noindex를 기본으로 붙여서 이번에도 응답 헤더에 그 줄이 있었습니다.
그 동작은 커스텀 도메인을 붙여도 pages.dev 주소가 열려 있던 글에서 따로 확인했습니다.
위험한 건 그 -b가 운영 빌드에 섞이는 경우입니다.
그러면 운영 페이지의 canonical과 사이트맵이 통째로 pages.dev를 가리킵니다.
그래서 아래 스텝은 운영 브랜치에서는 -b를 아예 안 쓰게 갈랐습니다.
Actions에서 직접 올리면 CF_PAGES_URL에 기댈 수 없습니다

Cloudflare의 Hugo 가이드는 빌드 명령으로 hugo -b $CF_PAGES_URL을 안내합니다.
CF_PAGES_URL은 빌드 설정 문서에서 「injected by default」라고 하는 시스템 변수이고 값은 「url-of-current-deployment」입니다.
Cloudflare가 대신 빌드해 줄 때 넣어주는 값입니다.
제 사이트는 다릅니다. GitHub Actions 러너에서 빌드하고 wrangler pages deploy로 결과물만 올립니다.
이 방식을 직접 업로드(Direct Upload)라고 합니다.
Cloudflare 빌드 환경을 타지 않으니 이 변수를 넣어줄 주체가 없다고 봅니다. 직접 업로드 문서에서 이걸 못 박은 문장은 찾지 못했고, 제 워크플로 파일에도 정의가 없습니다.
변수가 빈 채로 가이드 명령을 그대로 쓰면 어떻게 되는지 쟀습니다.
$ unset CF_PAGES_URL
$ hugo -b $CF_PAGES_URL --gc --minify # 빌드 성공
<link rel=canonical href=--gc/>
<loc>--gc/posts/hugo-sitemap-lastmod-git/
$ hugo -b "$CF_PAGES_URL" --gc --minify # 빌드 성공
<link rel=canonical href=/>
$ hugo --gc --minify -b $CF_PAGES_URL
ERROR command error: flag needs an argument: 'b' in -b
첫 번째가 제일 나쁩니다.
따옴표 없는 빈 변수는 셸에서 사라지고 -b가 바로 뒤 인자 --gc를 주소로 받아 갑니다.
빌드는 성공하는데 canonical과 사이트맵이 전부 --gc/…가 됩니다.
따옴표를 쳐도 빌드는 성공하고 주소가 /로 떨어집니다. 경고는 둘 다 없었습니다.
제가 해본 세 가지 중에서는 -b가 맨 끝이라 다음 인자가 없을 때만 빌드가 멈췄습니다.
브랜치 이름으로 미리보기 주소를 미리 계산합니다

배포 전에 빌드해야 하니 주소를 배포보다 먼저 알아야 합니다.
해시 주소(d22cd58e.…)는 배포가 끝나야 나옵니다.
대신 브랜치 별칭 주소는 브랜치 이름에서 정해집니다.
미리보기 배포 문서는 이렇게 적습니다.
「Branch name aliases are lowercased and non-alphanumeric characters are replaced with a hyphen」.
실제로 올려서 확인했습니다.
Lab/BaseURL_Test → lab-baseurl-test
feature/Lab_Preview-BaseURL.check-very-long-name → feature-lab-preview-baseurl
두 번째는 문서에 없는 동작이 있었습니다. 길이가 잘렸습니다.
규칙대로 바꾼 문자열의 앞 28자가 feature-lab-preview-baseurl-이고, 끝 하이픈을 떼면 받은 별칭과 같습니다.
표본 한 건이라 28자는 제 추정입니다. 그래서 계산값을 믿지 말고 배포 뒤에 대조하는 줄을 같이 둡니다.
Actions에서는 GITHUB_REF_NAME이 브랜치 이름입니다.
GitHub 문서 원문은 「The short ref name of the branch or tag that triggered the workflow run」이라고 하고 병합 전 PR이면 <pr_number>/merge 꼴이라고 덧붙입니다.
빌드 스텝은 이렇게 갈랐습니다.
set -euo pipefail
PROJECT=birchholt
BRANCH="${GITHUB_REF_NAME:?브랜치 이름이 비었다}"
if [ "$BRANCH" = "main" ]; then
./build.sh # baseURL 은 hugo.toml 값 그대로
else
ALIAS="$(printf '%s' "$BRANCH" | tr '[:upper:]' '[:lower:]' \
| sed -E 's/[^a-z0-9]/-/g' | cut -c1-28 | sed -E 's/-+$//')"
[ -n "$ALIAS" ] || { echo "🔴 별칭을 못 만들었다: $BRANCH" >&2; exit 1; }
BASE="https://${ALIAS}.${PROJECT}.pages.dev/"
echo "미리보기 baseURL: $BASE"
./build.sh -b "$BASE" # 반드시 따옴표
fi
./build.sh는 버전 검사 뒤 hugo --gc --minify "$@"를 실행하는 제 빌드 스크립트라 -b가 그대로 넘어갑니다.
로컬에서 브랜치 값만 바꿔 돌린 결과입니다.
### GITHUB_REF_NAME=main
<link rel=canonical href=https://birchholt.com/>
### GITHUB_REF_NAME=Lab/BaseURL_Test
미리보기 baseURL: https://lab-baseurl-test.birchholt.pages.dev/
<link rel=canonical href=https://lab-baseurl-test.birchholt.pages.dev/>
### (빈 값)
GITHUB_REF_NAME: 브랜치 이름이 비었다
운영 브랜치는 손대지 않고, 이름이 비면 빌드 전에 멈춥니다.
배포 스텝의 --branch에는 꼭 같은 $BRANCH를 넘깁니다.
그리고 wrangler가 출력한 Deployment alias URL이 $BASE와 다르면 실패시킵니다.
OUT="$(npx --yes wrangler@4 pages deploy public --project-name birchholt --branch "$BRANCH" 2>&1)"
echo "$OUT"
echo "$OUT" | grep -qF "alias URL: ${BASE%/}" || { echo "🔴 별칭이 계산과 다르다" >&2; exit 1; }
이 스텝은 로컬에서 값을 바꿔가며 돌려본 것이고, GitHub Actions 러너에서 실제로 돌리지는 않았습니다.
마지막 대조 줄도 이번 실험의 wrangler 출력 로그에 대고 맞는 값과 틀린 값을 넣어 본 것까지만 확인했습니다.
제 운영 워크플로는 main push만 배포해서 미리보기 경로가 없습니다.
해시 주소에서 누른 링크는 별칭으로 갑니다

별칭 주소를 baseURL로 쓰면 하나가 따라옵니다.
문서 표현으로 해시 주소는 「may always be visited in the future」인 고정 주소이고 별칭은 그 브랜치의 최신 배포로 계속 옮겨갑니다.
같은 브랜치로 표식만 V1에서 V2로 바꿔 한 번 더 올렸습니다.
352a36b5 해시 주소의 글 페이지 직접 → LAB-MARKER-V1
352a36b5 해시 주소 홈에서 그 글 링크 따라감 → LAB-MARKER-V2
예전 배포를 열어 놓고 링크를 누르면 최신 배포로 넘어갑니다. 리뷰하던 사람이 이전 버전과 비교하려고 해시 주소를 열었다면 링크 하나로 다른 버전에 섞입니다. 그리고 배포 완료 메시지 5초 뒤에 찍었을 땐 별칭이 아직 V1을 내주고 있었습니다. 배포가 만들어진 시각에서 30초가 안 돼 V2로 바뀌었습니다(한 번 관측한 값입니다). 배포 직후 바로 별칭을 찍는 검사는 앞 버전을 볼 수 있습니다.
해시 주소 그대로 검토하고 싶다면 -b보다 템플릿을 상대 주소로 바꾸는 쪽이 맞습니다.
상대 주소는 열린 도메인 안에서만 움직이기 때문입니다.
미리보기를 올리기 전에 찍어볼 것

- 빌드 결과에서 운영 도메인부터 셉니다.
grep -rho 'https://운영도메인' public | wc -l로 개수를 세고, 그게 어느 태그에 있는지 나눠 보면 어디가 튀는지 보입니다. 화면 링크와 CSS에 남아 있으면relURL·.RelPermalink로 먼저 바꿉니다 -b뒤 변수에는 따옴표를 치고, 비어 있으면 멈추게 합니다. 빈 변수는 오류 없이canonical을 망가뜨립니다- 운영 브랜치 빌드에는
-b가 들어가지 않게 가릅니다. 미리보기에서는 noindex가 막아주지만 운영에는 그런 안전망이 없습니다 - 직접 업로드라면 별칭 주소를 브랜치 이름에서 계산하고 배포 뒤에 대조합니다
- 실험이 끝난 미리보기 배포는 지웁니다. 별칭이 걸린 배포는 API에서
?force=true가 있어야 지워집니다. pages.dev 글에서 그 거절 메시지를 받았습니다(이번에는 해시 4개와 별칭 2개가 지운 뒤 전부 404였습니다)