← Home

Obsidian 볼트를 Cloudflare Pages로 웹 발행하기

Key Insight

Obsidian 볼트를 “노트북이 항상 켜져 있어야 접속되는” Tunnel 방식이 아니라, Quartz로 정적 사이트를 빌드해 Cloudflare Pages(CDN)에 배포하면 노트북 상태와 무관하게 항상 접속 가능한 웹사이트가 된다. Basic Auth는 Pages Functions 미들웨어로 재구현한다.


Overview

이 가이드는 04_GuWiki 볼트를 turboairbrain.uk에 비밀번호로 보호된 웹사이트로 발행한 실제 구축 절차를 일반화한 것이다. 최종 아키텍처:

Obsidian Vault (로컬) → robocopy 미러 → Quartz build → public/ (정적 파일)
                                                            ↓
                                              wrangler pages deploy
                                                            ↓
                                    Cloudflare Pages (CDN, 노트북 무관 상시 서빙)
                                                            ↑
                                              turboairbrain.uk (CNAME)

노트북은 6시간마다 “빌드 후 배포”만 담당하고, 사이트 자체는 노트북과 완전히 독립적으로 Cloudflare 엣지에서 서빙된다.


Details

1. Quartz로 정적 사이트 빌드

winget install --id OpenJS.NodeJS.LTS
git clone https://github.com/jackyzha0/quartz.git C:\path\to\quartz-site
cd C:\path\to\quartz-site
npm install
 
# 볼트 내용을 content/로 미러 (하네스 폴더는 제외)
robocopy "<vault-path>" ".\content" /E /MIR `
	/XD ".git" ".obsidian" ".claude" ".codex" ".agents" "node_modules" `
	/XF ".gitignore"
 
npx quartz build   # content/ → public/

quartz.config.default.yaml에서 pageTitle, baseUrl, locale 설정.

2. Cloudflare Pages 프로젝트 생성 + 배포

wrangler(Cloudflare CLI)는 별도 설치 없이 npx로 즉시 사용 가능:

npx wrangler login          # 브라우저에서 OAuth 승인
npx wrangler pages project create <project-name> --production-branch=main
npx wrangler pages deploy public --project-name=<project-name> --branch=main --commit-dirty=true

첫 배포 후 https://<project-name>.pages.dev에서 즉시 확인 가능. Quartz가 만드는 확장자 없는 “클린 URL”을 Pages가 자동으로 처리해준다 (Caddy의 try_files 같은 별도 설정 불필요, .html 요청은 자동 308 리다이렉트).

3. Basic Auth를 Pages Functions로 구현

Pages는 서버 설정 파일이 없으므로, functions/_middleware.js가 모든 요청을 가로채는 미들웨어 역할을 한다:

function timingSafeEqual(a, b) {
	if (a.length !== b.length) return false;
	let result = 0;
	for (let i = 0; i < a.length; i++) result |= a.charCodeAt(i) ^ b.charCodeAt(i);
	return result === 0;
}
 
export async function onRequest(context) {
	const { request, env } = context;
	const expectedUser = (env.BASIC_AUTH_USER || "").replace(/^\ufeff/, "");
	const expectedPass = (env.BASIC_AUTH_PASS || "").replace(/^\ufeff/, "");
 
	const authHeader = request.headers.get("Authorization");
	if (authHeader && authHeader.startsWith("Basic ")) {
		const decoded = atob(authHeader.slice(6));
		const sepIndex = decoded.indexOf(":");
		const user = decoded.slice(0, sepIndex);
		const pass = decoded.slice(sepIndex + 1);
		if (timingSafeEqual(user, expectedUser) && timingSafeEqual(pass, expectedPass)) {
			return context.next();
		}
	}
	return new Response("Authentication required.", {
		status: 401,
		headers: { "WWW-Authenticate": 'Basic realm="example.com"' },
	});
}

자격증명은 코드에 하드코딩하지 않고 Pages Secret으로 분리:

"<username>" | npx wrangler pages secret put BASIC_AUTH_USER --project-name=<project-name>
"<password>" | npx wrangler pages secret put BASIC_AUTH_PASS --project-name=<project-name>

PowerShell 파이프 BOM 함정

"value" | wrangler secret put ... 처럼 PowerShell 파이프로 값을 stdin에 넘기면 UTF-8 BOM(U+FEFF)이 값 앞에 섞여 들어갈 수 있다 — 정확한 비밀번호를 입력해도 계속 인증 실패하는 원인이 된다. 위 코드의 .replace(/^\ufeff/, "")가 이를 방어한다. 상세: PowerShell CLI 파이프의 BOM 오염 문제.

4. 커스텀 도메인 연결

wrangler CLI에는 Pages 커스텀 도메인 관리 명령이 없다 (wrangler pages domain add 같은 건 존재하지 않음). Cloudflare REST API를 직접 호출한다:

curl -X POST "https://api.cloudflare.com/client/v4/accounts/{account_id}/pages/projects/{project}/domains" \
  -H "Authorization: Bearer {token}" \
  -H "Content-Type: application/json" \
  -d '{"name":"example.com"}'

토큰은 wrangler login이 저장한 OAuth 토큰(~/.wrangler/config/default.toml 또는 Windows의 AppData\Roaming\xdg.config\.wrangler\config\default.toml)을 재사용할 수 있다. 단, 이 토큰은 보통 dns_records 스코프가 없어서 DNS 레코드 자체는 API로 수정 불가 — Cloudflare 대시보드에서 기존 레코드를 <project>.pages.dev로 가리키는 CNAME(Proxied)으로 직접 수정해야 한다.

5. 재빌드+배포 자동화 (Windows Task Scheduler)

수동 반복 작업을 없애기 위해 “동기화 → 빌드 → 배포” 3단계를 한 스크립트로 묶고, Windows 작업 스케줄러로 주기 실행한다:

robocopy $vaultPath "$quartzPath\content" /E /MIR /XD ".git" ".obsidian" ...
Set-Location $quartzPath
npx quartz build
npx wrangler pages deploy public --project-name=<project-name> --branch=main --commit-dirty=true
$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument '-ExecutionPolicy Bypass -WindowStyle Hidden -File "rebuild-site.ps1"'
$trigger = New-ScheduledTaskTrigger -Once -At (원하는 시작 시각) -RepetitionInterval (New-TimeSpan -Hours 6) -RepetitionDuration (New-TimeSpan -Days 3650)
Register-ScheduledTask -TaskName "Site-Rebuild" -Action $action -Trigger $trigger -RunLevel Limited

npx quartz build는 harmless한 stderr 경고(LaTeX $ 기호 오인식 등)를 낼 수 있으므로, 빌드 구간만 $ErrorActionPreference = "Continue"로 낮추고 $LASTEXITCODE로 직접 성공 여부를 판단하는 것이 안전하다.


6. 확장: 사용자 질문에 답하는 Q&A 봇 추가

이 발행 인프라 위에 “질문하면 컴파일된 Wiki를 근거로 답하는” 페이지(/ask)를 얹을 수 있다 — Cloudflare KV + Pages Function + Claude API + 기존 재빌드 스크립트 확장만으로 충분하며, 새 인프라가 거의 필요 없다. 전체 절차와 트러블슈팅: Cloudflare Pages Function + Claude API로 Wiki 기반 Q&A 봇 만들기.


7. Staging 배포 분리 (2026-08-26, 직원 실사용 이후 도입)

사이트를 직원들이 실사용하기 시작하면서, “새 기능 개발·테스트”와 “실제 배포”를 분리할 필요가 생겼다. 새 Cloudflare 프로젝트 없이 기존 turboairbrain-wiki 프로젝트 안에서 브랜치만 나누는 방식으로 해결 — Cloudflare Pages는 프로젝트에 설정된 Production 브랜치(이 프로젝트는 main)로 배포하면 커스텀 도메인(turboairbrain.uk)이 갱신되고, 그 외의 브랜치 이름으로 배포하면 자동으로 별도 Preview 배포가 되어 고유 URL을 받는다 (wrangler pages deployment list로 실제 Environment: Production/Preview 태그 확인 가능).

rebuild-site.ps1에 -Environment 매개변수를 추가해 이를 그대로 활용한다:

# 새 기능/코드 변경 - 먼저 staging에서 확인
.\rebuild-site.ps1 -Environment staging
 
# 스케줄 작업(TAB-Wiki-Rebuild)은 인자 없이 호출 -> 기본값 production, 지금처럼 자동으로 계속 배포
.\rebuild-site.ps1

-Environment staging은 빌드·데이터 파이프라인(위키 동기화, Sales Data pull, Q&A/피드백 sync-back 등) 전부를 프로덕션과 동일하게 실행하고, Cloudflare 배포 대상만 --branch=staging으로 바꾼다. Cloudflare가 이 브랜치에 대해 매번 고정된 별칭 URL을 자동 부여한다 — https://staging.turboairbrain-wiki.pages.dev (해시 기반 URL과 별개로 항상 최신 staging 배포를 가리킴, 북마크해두면 편함).

KV는 프로덕션과 공유됨 — staging에서 /ask·피드백 폼 실제 제출 금지

Cloudflare KV 네임스페이스(Q&A query:*, 피드백 feedback:*, context:wiki-bundle)는 브랜치와 무관하게 프로젝트 전체에서 하나뿐이다. staging 프리뷰에서 실제로 /ask에 질문을 보내거나 피드백 폼을 제출하면 진짜 프로덕션 KV에 기록되고, notify-answer-updates.ps1을 통해 실제 알림메일까지 나갈 수 있다. staging은 UI·레이아웃·정적 콘텐츠 확인 용도로만 쓰고, KV에 쓰는 기능은 직접 만든 KV 테스트 키로 별도 검증할 것.

staging 프리뷰 URL은 Cloudflare Access로 보호되지 않음 (미해결, 2026-08-26 발견)

Cloudflare Pages Function + Claude API로 Wiki 기반 Q&A 봇 만들기 구축 시 설정한 Cloudflare Access(One-time PIN)의 Destination이 turboairbrain.uk 커스텀 도메인 하나만 지정돼 있다 — *.pages.dev 도메인(고정 staging 별칭 포함, 그리고 지금까지의 모든 프로덕션 배포가 남긴 해시 URL들도 마찬가지)은 Access 정책이 전혀 적용되지 않아 누구나 URL만 알면 로그인 없이 접근 가능하다(curl로 직접 확인, HTTP 200 무인증 응답). staging 별칭 URL은 해시 URL보다 예측·공유되기 쉬워서 이 노출이 실질적으로 더 커진다.

해결 방법(사용자 조치 필요): Cloudflare Zero Trust 대시보드 → Access → Applications → 기존 Application(또는 새 Application) 편집 → Destination에 *.turboairbrain-wiki.pages.dev(또는 turboairbrain-wiki.pages.dev) 와일드카드를 추가로 등록해 기존 One-time PIN 정책이 이 도메인에도 적용되도록 한다. 이 조치 전까지는 staging에 민감하지 않은 콘텐츠만 배포한다고 가정할 것.


8. 동시 실행 방지 락 — staging 도입 직후 실제로 프로덕션이 깨진 사고 (2026-08-26)

§7의 staging 워크플로우를 붙인 직후, 수동 -Environment staging 테스트 실행이 마침 정시(매시 00분)에 도는 TAB-Wiki-Rebuild 스케줄 작업과 겹치는 사고가 실제로 발생했다. quartz build는 매번 public/를 지우고 새로 생성하는데, 두 프로세스가 같은 public/·content/를 동시에 건드리면서 한쪽의 rmdir이 다른 쪽이 그 순간 쓰고 있는 디렉터리와 충돌(ENOTEMPTY)해 빌드가 죽었고, 다른 한쪽은 “성공(exit 0)“했지만 실제로는 상대방의 robocopy /MIR가 content/를 동시에 변경하던 중이라 반쪽짜리 스냅샷으로 빌드됐다 — 이 빌드가 그대로 프로덕션에 배포되어, 평소 1,352개 파일이던 배포가 488개 파일(위키 페이지·답변보기 상세 페이지가 통째로 누락)로 축소된 채 turboairbrain.uk에 몇 분간 라이브로 떠 있었다.

Key Insight — "빌드 exit 0"는 "빌드가 옳다"는 뜻이 아니다

동시 실행 시 한쪽 프로세스의 quartz build가 에러 없이 종료돼도, 그 사이 다른 프로세스가 content/를 변경 중이었다면 일부만 반영된 스냅샷으로 조용히 빌드될 수 있다 — exit code만 보고 배포를 신뢰하면 안 되는 사례. 발견 경로는 우연이었다(배포 로그의 업로드 파일 수가 평소 대비 3분의 1 이하로 작았던 것을 수동 점검 중 포착) — 자동 감지 장치가 없었다는 뜻이기도 하다.

수정: rebuild-site.ps1 시작 부분에 OS 레벨 배타적 파일 락([System.IO.File]::Open(..., FileShare.None))을 추가 — rebuild.lock 파일을 독점 오픈할 수 없으면(다른 인스턴스가 실행 중) 10초 간격으로 최대 600초까지 대기 후 재시도한다. 스케줄 작업이든 수동 실행이든 -Environment 값과 무관하게 모든 인스턴스가 이 락을 공유해 절대 동시에 quartz build/robocopy를 돌리지 않는다. 락 파일 자체는 .gitignore에 추가(그렇지 않으면 스크립트가 자기 자신이 독점 오픈 중인 파일을 같은 프로세스의 git add가 읽으려다 실패하는 2차 버그가 남는다 — 실제로 이 수정 직후 재현·확인 후 함께 고쳤다). 두 프로세스를 실제로 동시에 띄워 검증: 두 번째 프로세스가 “Waiting for rebuild.lock” 로그를 남기고 80초 대기한 뒤 정상적으로 이어 실행되는 것을 확인.


Why This Over a Self-Hosted Tunnel

Cloudflare Tunnel(cloudflared)로 로컬 서버(Caddy 등)를 노출하는 방식은 설정이 더 간단해 보이지만, origin 서버가 곧 사용자의 노트북이라는 근본적 한계가 있다 — 노트북이 꺼지거나 tunnel 프로세스가 죽으면 사이트 전체가 다운된다. 재부팅 자동복구(Task Scheduler AtLogOn)로는 “재부팅”에는 대응할 수 있어도 “노트북이 꺼져 있는 동안”에는 대응 불가능하다. 상세 비교: 정적 사이트 호스팅 아키텍처 선택 (Self-Host vs CDN).


첨부파일은 배포 저장소의 git 에 두지 않는다 (2026-09-29)

content/ 는 볼트를 robocopy /MIR 로 복제한 미러다. 그 안의 80. References/Attachments/ 까지 git 이 추적하면 볼트 첨부(수 GB)가 배포 저장소에 통째로 복제된다. 2026-09-29 기준 3,533 MB · 2,101개로, content/ 추적 총량의 99.1% 였다.

비용은 “푸시가 느리다” 로 끝나지 않는다. 푸시 대기 blob 이 3,896 MB 까지 쌓여 푸시가 30분 넘게 끝나지 않았고, 한 번은 git push 가 15분간 rebuild.lock 을 쥔 채 매달려 배포와 질문자 알림 메일이 통째로 막혔다(rebuild-site.ps1 의 auto-commit 이 락 안에서 돈다).

.gitignore 에 넣으면 사이트가 빈 채로 배포된다

Quartz 는 콘텐츠를 globby(gitignore: true) 로 찾는다(quartz/util/glob.ts). 따라서 .gitignore 에 content/ 나 그 하위를 넣으면 git 뿐 아니라 빌드도 무시해서 Found 0 input files 가 되고 빈 사이트가 배포된다 — 2026-08-18 에 실제로 약 한 시간 그렇게 나갔다. 그 경위가 .gitignore 주석에 남아 있으니 지우지 말 것.

해법: .git/info/exclude 에 넣는다. git 만 읽고 globby 는 읽지 않는다.

# .git/info/exclude
content/80. References/Attachments/

그다음 추적만 해제한다(파일은 디스크에 남는다):

git rm -r --cached "content/80. References/Attachments"

적용 후 반드시 확인한다 — 이 전제가 틀리면 위 2026-08-18 사고가 재현된다:

  1. npx quartz build 로 Found N input files from content 가 0 이 아닌지.
  2. 산출물에 첨부가 그대로 포함되는지(find <out> -ipath "*Attachments*" | wc -l).

2026-09-29 실측: 탐색 644개 파일, 첨부 2,345개·4,149 MB 가 산출물에 포함됨 — 사이트 영향 없음. 푸시 대기는 3,896 MB → 38.4 MB 로 줄었다.

데이터 손실은 없다 — 첨부 원본은 볼트 저장소(04_GuWiki)가 이미 전부 추적한다. 배포 저장소의 사본은 두 번째 복사본이었을 뿐이다. 무엇을 추적할지는 “이 파일이 없어지면 복구할 수 있는가” 로 정한다.


Sources


Open Questions

Open Question

Cloudflare Pages custom domain 연결 시 DNS 레코드가 API로 자동 생성되지 않는 이유(같은 계정/zone인데도)가 명확하지 않다 — 매번 대시보드 수동 수정이 필요한지, 아니면 별도 스코프의 API 토큰을 발급하면 되는지 확인 필요.

Bias Check

Counter-argument: 이 가이드는 단일 세션(2026-07-31)의 성공 경로만 기록한 것으로, Cloudflare Pages의 무료 티어 제한(빌드 횟수, 대역폭)이나 다른 CDN 대안(Netlify, Vercel, GitHub Pages)과의 비교는 다루지 않는다. Data gap: 장기 운영 시 재빌드 스케줄러의 실패율, Cloudflare Pages 배포 실패 시 알림 메커니즘 부재.