이 가이드는 데이터베이스에 D1, 미디어에 R2를 사용해 EmDash 사이트를 Cloudflare Workers에 배포합니다. EmDash Cloudflare 템플릿으로 시작하거나 기존 Astro 사이트에 같은 구성을 적용하세요.
사전 요구 사항
- Cloudflare 계정
- 프로젝트 의존성이 설치되어 있을 것
- Wrangler가 Cloudflare에 인증되어 있을 것(
pnpm wrangler login)
바인딩 구성
Cloudflare 템플릿에는 완전한 Worker 진입점과 이름 있는 D1·R2 바인딩이 포함됩니다. 첫 배포 시 구성된 이름의 리소스가 아직 없으면 Wrangler가 만듭니다. wrangler.jsonc의 이름을 유지하세요. Wrangler는 이후 배포를 같은 리소스에 다시 연결합니다.
템플릿은 다음 바인딩을 사용합니다.
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "my-emdash-site",
"main": "./src/worker.ts",
"compatibility_date": "2026-02-24",
"compatibility_flags": ["nodejs_compat"],
"d1_databases": [
{
"binding": "DB",
"database_name": "my-emdash-site",
},
],
"r2_buckets": [
{
"binding": "MEDIA",
"bucket_name": "my-emdash-media",
},
],
"worker_loaders": [{ "binding": "LOADER" }],
"triggers": { "crons": ["* * * * *"] },
}
DB, MEDIA, LOADER 이름은 EmDash 어댑터와 일치해야 합니다. Cron Trigger는 예약 게시, 플러그인 작업, 백업, 유지보수를 실행합니다. 사이트가 샌드박스 플러그인을 쓰면 Plugin sandbox를 보세요.
EmDash 구성
다음 Astro 구성은 D1과 R2 바인딩을 사용합니다.
import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";
import react from "@astrojs/react";
import emdash from "emdash/astro";
import { d1, r2, sandbox } from "@emdash-cms/cloudflare";
export default defineConfig({
output: "server",
adapter: cloudflare(),
integrations: [
react(), // Required — the admin UI is a React app
emdash({
database: d1({ binding: "DB" }),
storage: r2({ binding: "MEDIA" }),
sandboxRunner: sandbox(),
}),
],
});
사이트가 marketplace, registry, 또는 sandboxed 플러그인을 쓰지 않으면 sandboxRunner와 LOADER 바인딩을 생략하세요.
Worker 진입점 추가
Worker 진입점은 Astro를 Cron Trigger에 연결하고 플러그인 브리지를 내보냅니다.
import handler, { createScheduledHandler, PluginBridge } from "@emdash-cms/cloudflare/worker";
export { PluginBridge };
export default {
...handler,
scheduled: createScheduledHandler(),
} satisfies ExportedHandler;
샌드박스 플러그인이 설치되지 않았을 때 PluginBridge 내보내기는 무해합니다. 같은 프로젝트에서 나중에 플러그인을 켤 수 있으면 유지하세요.
일반 유지보수를 매분이 아닌 일정으로 실행하려면 같은 Cron 식을 createScheduledHandler({ generalCron: "..." })와 triggers.crons에 전달하세요. 다르면 핸들러는 예상치 못한 트리거를 로그하고 무시합니다.
빌드와 배포
이름 있는 D1 데이터베이스와 R2 버킷을 Wrangler가 프로비저닝하도록 사이트를 한 번 빌드하고 배포하세요. Wrangler는 pnpm wrangler login으로 만든 로컬 로그인을 사용합니다.
pnpm build
pnpm wrangler deploy
기본 마이그레이션 모드 auto에서는 배포된 Worker가 첫 요청을 받을 때 EmDash가 대기 중인 코어 마이그레이션을 적용합니다. 새 코드가 트래픽을 받기 전에 마이그레이션을 적용해야 하는 배포 파이프라인이거나, 마이그레이션을 검사·확인·복구해야 하면 Manage core database migrations를 사용하세요.
데이터베이스가 비어 있고(컬렉션 없음) 설정 마법사가 완료되지 않았다면 EmDash는 첫 부팅 시 시드 파일도 적용합니다. 시드는 빌드 시 .emdash/seed.json, package.json#emdash.seed 경로, 또는 seed/seed.json 중 먼저 찾은 것에서 읽어 번들에 인라인됩니다. 없으면 내장 기본 시드가 사용됩니다. 기존 데이터베이스에 대한 이후 배포는 그 내용을 그대로 둡니다.
이미 배포된 사이트의 스키마나 콘텐츠 모델을 바꾸려면 Evolving a Deployed Site를 보세요.
Worker를 D1 가까이에 배치
Cloudflare는 기본적으로 방문자 근처에서 Worker를 실행합니다. EmDash 서버 렌더 요청은 D1 왕복을 여러 번 하므로 Targeted Placement로 Worker를 D1 프라이머리 근처에서 실행해 그 요청을 빠르게 하세요.
Wrangler는 셀렉터가 정확히 하나인 placement.mode: "targeted"를 받습니다. region, host, 또는 hostname입니다. D1 프라이머리 위치를 가리키는 값을 고르고 결과 placement 객체를 wrangler.jsonc에 추가하세요. Targeted Placement와 함께 D1 읽기 복제본을 활성화하지 마세요. EmDash의 session 설정은 기본값 "disabled"로 두어 읽기·쓰기가 가까운 프라이머리를 쓰게 하세요.
Object cache
D1 읽기 부하를 줄이려면 콘텐츠와 구성 쿼리 결과를 Cloudflare KV에 캐시하세요. 읽기는 매 요청마다 데이터베이스를 조회하는 대신 KV에서 제공됩니다.
import { d1, r2, kvCache } from "@emdash-cms/cloudflare";
emdash({
database: d1({ binding: "DB" }),
storage: r2({ binding: "MEDIA" }),
objectCache: kvCache({ binding: "CACHE" }),
}),
KV 설정, 옵션, 무효화 동작은 Object Cache를 보세요.
Workers Cache
Cloudflare Workers Cache는 Worker 앞에 엣지 캐시를 둡니다. 일치하는 요청은 Worker를 전혀 실행하지 않고 제공됩니다.
활성화
-
Astro의 Cloudflare 캐시 프로바이더를 사용해 라우트 규칙과
Astro.cache가 캐시 헤더를 설정하고 무효화에cache.purge()를 쓰게 하세요.import { cacheCloudflare } from "@astrojs/cloudflare/cache"; export default defineConfig({ adapter: cloudflare(), cache: { provider: cacheCloudflare(), }, routeRules: { "/": { maxAge: 300, swr: 86400 }, // Other public routes can use different cache lifetimes. }, });@astrojs/cloudflare어댑터는cacheCloudflare()를 감지하고 생성된 배포 구성에서 Workers Cache를 켭니다. -
플랫폼 API로 Worker 코드에서 캐시된 응답을 퍼지하세요. 이 호출에는 Cloudflare REST 자격 증명이 필요 없습니다.
import { cache } from "cloudflare:workers"; await cache.purge({ purgeEverything: true }); // Or purge selected tags: await cache.purge({ tags: ["posts"] });
EmDash 관리·API 응답은 이미 Cache-Control: private, no-store를 보내며 절대 저장되지 않습니다. 공개 페이지는 Cache-Control / routeRules / Astro.cache로 자체 캐시를 제어합니다.
활성화 전에 알 것 두 가지:
Cache-Control헤더가 없는 응답도 캐시됩니다. Workers Cache는 RFC 9111 휴리스틱 신선도를 적용합니다. 헤더 없는200은 2시간 캐시됩니다. 모든 커스텀 라우트에 명시적Cache-Control을 주세요(세션 의존 항목은private, no-store).- 캐시된 페이지는 로그인한 편집자와 공유됩니다. 캐시는 Worker 앞에서 돌아가므로 요청 쿠키로 우회할 수 없습니다. 로그인한 편집자는 항목이 만료될 때까지 공개 페이지의 캐시된 익명 변형(시각 편집 툴바 없음)을 받을 수 있습니다. 편집자용으로 렌더된 응답 자체는 절대 저장되지 않으므로(
private, no-store) 반대 방향 유출은 없습니다.
@emdash-cms/cloudflare의 cloudflareCache()와 다름
| 권장: Workers Caching | 레거시: cloudflareCache() | |
|---|---|---|
| Config | "cache": { "enabled": true } + @astrojs/cloudflare/cache의 cacheCloudflare() | @emdash-cms/cloudflare의 cache: { provider: cloudflareCache() } |
| Storage | Platform Workers Caching | Cache API(caches.open / put / match) |
| Purge | cloudflare:workers의 cache.purge() | Zone REST POST /zones/{id}/purge_cache |
| Secrets | 퍼지용 없음 | CF_ZONE_ID + CF_CACHE_PURGE_TOKEN |
새 사이트는 권장 경로를 쓰세요. Cache API 동작에 이미 의존할 때만 cloudflareCache()를 유지하세요.
또한 둘 다 object cache(objectCache: kvCache({ binding: "CACHE" }))와 혼동하지 마세요. 이는 데이터베이스 쿼리 결과를 KV에 캐시하는 Worker 아래의 별도 계층입니다.
커스텀 도메인
첫 배포는 workers.dev URL을 받습니다. 커스텀 도메인은 Worker와 같은 계정에서 Cloudflare가 관리하는 활성 도메인이어야 합니다. Worker가 workers.dev URL에서 성공적으로 응답한 뒤 프로덕션 도메인을 Wrangler 라우트로 추가하세요.
{
"routes": [{ "pattern": "www.example.com", "custom_domain": true }],
}
다시 배포하고 두 주소를 확인하세요. DNS 테스트 중 workers.dev 주소를 쓸 수 있게 두면 라우팅 문제와 애플리케이션 문제를 구분하는 데 도움이 됩니다.
공개 R2 접근
기본적으로 미디어는 EmDash 인증 미디어 라우트를 통해 제공됩니다. 버킷에 공개 커스텀 도메인이 있으면 그 오리진을 publicUrl로 설정해 생성된 미디어 URL이 쓰게 하세요.
storage: r2({
binding: "MEDIA",
publicUrl: "https://media.example.com",
}),
공개 버킷 접근은 미디어뿐 아니라 도달 가능한 모든 객체에 적용됩니다. 자동 JSON 백업은 같은 스토리지 백엔드의 backups/ 접두사를 쓰므로 공개 도메인으로 그 접두사를 노출하지 마세요. Choose media storage가 안전한 경계를 설명합니다.
이미지 변환
EmDash는 Cloudflare IMAGES 바인딩을 통해 Worker 안에서 R2 미디어를 리사이즈·재인코딩합니다. emdash/ui의 Image 컴포넌트와 리치 텍스트의 이미지는 모두 Cloudflare 어댑터 아래에 EmDash가 설치하는 이미지 엔드포인트를 통해 렌더됩니다. 내부 라우트 /_emdash/api/media/file/…의 미디어는 HTTP 페치 없이 R2 바인딩에서 소스 바이트를 직접 읽습니다. 이 변환은 Cloudflare Access 뒤와 global_fetch_strictly_public에서도 계속 동작합니다. 버킷 URL에서 제공되는 미디어 — Public R2 Access — 는 변환 전 HTTP로 파일을 가져오는 어댑터 자체 변환 엔드포인트를 씁니다.
바인딩을 선언할 필요는 없습니다. @astrojs/cloudflare는 astro build 중 생성하는 Worker 구성에 Workers Caching용 cache를 추가하는 것과 같이 이를 추가합니다. 런타임 이미지 서비스가 cloudflare-binding일 때(imageService 미설정, 그 문자열 자체, 또는 { runtime: "cloudflare-binding" }) 합니다. 다른 값 — "passthrough", "compile", "cloudflare", "custom" — 은 바인딩을 빼둡니다. 자신의 wrangler.jsonc에 적어 두면 의도가 분명해집니다.
{
"images": {
"binding": "IMAGES",
},
}
배포가 실제로 무엇을 받는지 보려면 wrangler.jsonc 대신 생성된 구성을 읽으세요. 빌드는 .wrangler/deploy/config.json을 쓰고 wrangler deploy를 병합된 파일(기본 dist/server/wrangler.json)로 가리킵니다. 거기에서 images 항목을 찾으세요.
Cloudflare는 이 변환을 Images transformations로 과금합니다. 소스 이미지와 매개변수의 각 고유 조합은 달력 월당 한 번 과금되며, 그달 안 반복 요청은 무료입니다. 사이트에 소스 이미지 500개가 있고 각 이미지에 썸네일·히어로 크기를 하나씩 요청하면 그 두 매개변수 집합은 그달 1,000개 변환 이미지로 셉니다. Images Free 플랜은 월 5,000개 고유 변환을 커버합니다. 그 한도를 넘으면 캐시된 변환은 계속 제공되지만 새 것은 9422 오류를 반환하고 이미지 요청이 실패합니다.
Cloudflare Access 인증
Cloudflare Access는 Access 애플리케이션에 연결된 ID 공급자로 패스키 인증을 대체할 수 있습니다. audience 값은 비밀 런타임 설정입니다. 환경 변수 이름을 지정해 astro.config.mjs 밖에 두세요.
import { access } from "@emdash-cms/cloudflare";
emdash({
auth: access({
teamDomain: "myteam.cloudflareaccess.com",
audienceEnvVar: "CF_ACCESS_AUDIENCE",
roleMapping: {
Admins: 50,
Editors: 40,
},
}),
}),
pnpm wrangler secret put CF_ACCESS_AUDIENCE로 CF_ACCESS_AUDIENCE를 설정하세요. authentication guide가 사용자 프로비저닝, 기본 역할, 역할 동기화를 설명합니다.
이메일
프로덕션 Worker에는 기본 이메일 전송 서비스가 없습니다. 매직 링크 로그인, 팀 초대, 댓글 알림은 이메일 플러그인이 활성화될 때까지 Email is not configured를 반환합니다.
Cloudflare 이메일 플러그인은 send_email 바인딩을 사용합니다. 먼저 Cloudflare Email Sending으로 발신자 도메인을 온보딩하고 검증하세요. Cloudflare는 From 주소가 수락된 발신자가 아닌 메시지를 거부합니다.
바인딩을 추가하고 프로바이더를 등록하세요.
{
"send_email": [{ "name": "EMAIL" }],
}
import { cloudflareEmail } from "@emdash-cms/cloudflare/plugins";
emdash({
plugins: [
cloudflareEmail({
from: { email: "cms@mails.example.com", name: "My Site CMS" },
replyTo: "hello@example.com",
}),
],
}),
배포 후 Extensions에서 플러그인을 활성화하고 Settings → Email에서 선택하세요. 발신자가 수락되고 바인딩이 있을 때까지 전송은 실패합니다.
플러그인은 binding 옵션이 다른 것을 지정하지 않으면 EMAIL이라는 바인딩을 사용합니다. 활성 이메일 프로바이더가 그것뿐이면 EmDash가 자동 선택합니다. 둘 이상 활성이면 Settings → Email에서 Cloudflare 프로바이더를 고르세요. 선택적 replyTo 주소는 수락된 From을 바꾸지 않고 회신을 받습니다. 플러그인은 개별 메시지에 replyTo를 설정해 그 메시지에 대해 이 옵션을 덮어쓸 수 있습니다.
Cloudflare AI Search
AI Search 플러그인에는 네이티브 플러그인 등록과 ai_search_namespaces 바인딩이 모두 필요합니다. 배포 후 관리에서 Cloudflare AI Search를 열고 컬렉션을 고른 뒤 Sync All Content를 실행하세요. 초기 동기화는 플러그인 활성화 전에 게시된 콘텐츠를 인덱싱하고, 훅이 이후 변경을 동기화합니다.
import { aiSearch } from "@emdash-cms/cloudflare/plugins";
emdash({
plugins: [aiSearch()],
}),
{
"ai_search_namespaces": [{ "binding": "AI_SEARCH", "namespace": "default" }],
}
사이트에서 검색 라우트를 노출하세요.
export { POST, prerender } from "@emdash-cms/cloudflare/plugins/ai-search";
레이아웃에 검색 UI를 추가하세요. 트리거 슬롯은 사이트 디자인에 맞는 버튼을 받습니다.
---
import AISearchSnippet from "@emdash-cms/cloudflare/plugins/ai-search/astro";
---
<AISearchSnippet apiUrl="/api/ai-search" placeholder="Search content">
<button slot="trigger" type="button">Search</button>
</AISearchSnippet>
Worker 시크릿
시크릿 값은 pnpm wrangler secret put <NAME>으로 저장하세요. wrangler.jsonc에 넣거나 빌드 시 import.meta.env 값에서 읽지 마세요.
EMDASH_ENCRYPTION_KEY는 시크릿으로 선언된 플러그인 설정을 암호화합니다. 플러그인 시크릿을 저장하기 전에 설정하고 D1 백업과 따로 보관하세요. 로테이션 중에는 새 키를 먼저 제공하고 모든 플러그인 시크릿이 다시 저장될 때까지 쉼표로 이전 키를 유지하세요. Secrets and key management가 로테이션과 복구를 설명합니다.
플러그인 브리지는 이 Worker 시크릿 바인딩을 직접 읽습니다. 생성된 관리 설정 라우트는 process.env로 읽습니다. nodejs_compat에서 Cloudflare는 호환 날짜 2025-04-01 이후 기본적으로 process.env를 채웁니다. 그 이전 날짜에 고정된 프로젝트는 암호화 설정을 저장하기 전에 nodejs_compat_populate_process_env도 추가해야 합니다.
EmDash는 런타임에 process.env에서 시크릿을 읽습니다. Worker 코드는 cloudflare:workers에서 가져온 env에서 바인딩을 읽습니다. import.meta.env로 시크릿을 읽지 마세요. Vite가 빌드 시 그 값을 치환해 서버 번들에 쓸 수 있습니다.
미리보기 HMAC 시크릿과 댓글 작성자 IP 솔트는 런타임 재정의를 제공하지 않으면 생성되어 데이터베이스에 저장됩니다. Secrets and key management가 정확한 변수, 저장 위치, 로테이션 효과를 나열합니다.
미리보기 배포
이름 있는 Wrangler 환경은 바인딩을 상속하지 않습니다. 별도 미리보기 리소스를 만들고 빌드 전에 preview 환경에 쓰세요.
pnpm wrangler d1 create my-emdash-site-preview \
--binding DB --env preview --update-config
pnpm wrangler r2 bucket create my-emdash-media-preview \
--binding MEDIA --env preview --update-config
미리보기 환경은 미리보기 Worker가 쓰는 모든 바인딩을 반복해야 합니다. 코어 D1, R2, 샌드박스 바인딩은 Wrangler가 리소스 식별자를 쓴 뒤 다음 형태입니다.
{
"env": {
"preview": {
"d1_databases": [
{
"binding": "DB",
"database_name": "my-emdash-site-preview",
"database_id": "00000000-0000-0000-0000-000000000000",
},
],
"r2_buckets": [
{
"binding": "MEDIA",
"bucket_name": "my-emdash-media-preview",
},
],
"worker_loaders": [{ "binding": "LOADER" }],
},
},
}
Wrangler가 쓴 미리보기 UUID를 사용하세요. 미리보기가 그 기능을 쓰면 선택적 KV, AI Search, 이메일 등 바인딩도 반복하세요. 미리보기 전용 시크릿은 pnpm wrangler secret put <NAME> --env preview로 추가하세요.
미리보기 환경을 빌드하고 배포하세요. 첫 요청이 기본 auto 모드로 대기 중인 코어 마이그레이션을 적용합니다.
pnpm build
pnpm wrangler deploy --env preview
공유하기 전에 미리보기 URL, 관리 로그인, 미디어 업로드, 선택 바인딩을 확인하세요. 미리보기 바인딩을 프로덕션 데이터베이스나 버킷에 가리키지 마세요.
배포 확인
배포 후 공개 페이지를 하나 요청하고, /_emdash/admin에 로그인한 뒤, 테스트 미디어 파일을 업로드·조회하고, 예약 핸들러가 pnpm wrangler tail에 나타나는지 확인하세요.
문제 해결
”D1 binding not found”
wrangler.jsonc의 바인딩 이름이 데이터베이스 구성과 일치하는지 확인하세요.
// Must match: d1({ binding: "DB" })
"binding": "DB"
”R2 binding not found”
R2 버킷이 올바르게 바인딩되었는지 확인하세요.
// Must match: r2({ binding: "MEDIA" })
"binding": "MEDIA"
마이그레이션 오류
스키마 오류가 보이면 Worker 로그를 추적(wrangler tail)하고 오류를 재현해 근본 메시지를 캡처한 뒤, 그 출력과 함께 이슈를 제출하세요.