Cloudflare Workers는 EmDash를 위한 빠르고 전 세계적으로 분산된 런타임을 제공합니다. 이 가이드는 데이터베이스에 D1, 미디어 스토리지에 R2를 사용한 배포를 다룹니다.
사전 요구 사항
- Cloudflare 계정
- Wrangler CLI 설치 (
npm install -g wrangler) - Cloudflare 인증 완료 (
wrangler login)
바인딩 구성
프로덕션 D1 데이터베이스와 R2 버킷을 프로비저닝한 다음, 프로젝트 루트에 wrangler.jsonc를 생성하여 불변 ID와 이름에 대한 바인딩을 설정합니다. 데이터베이스 프로비저닝은 EmDash의 스키마 마이그레이션 적용과 별개입니다.
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "my-emdash-site",
"compatibility_date": "2025-01-15",
"compatibility_flags": ["nodejs_compat"],
"d1_databases": [
{
"binding": "DB",
"database_name": "emdash-db",
"database_id": "00000000-0000-0000-0000-000000000000",
},
],
"r2_buckets": [
{
"binding": "MEDIA",
"bucket_name": "emdash-media",
},
],
}
이것은 직접 구성하는 바인딩입니다. @astrojs/cloudflare 어댑터는 배포된 Worker 구성을 생성할 때 자체 바인딩을 추가합니다. 그 중 하나가 미디어 변환에 사용되는 IMAGES 바인딩입니다 — 이미지 변환을 참조하세요.
샌드박스 플러그인 — 마켓플레이스 설치와 sandboxed: [] 아래의 플러그인 — 은 worker_loaders 바인딩과 PluginBridge를 내보내는 Worker 진입점이 필요합니다. 플러그인 샌드박스를 참조하세요.
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 } from "@emdash-cms/cloudflare";
export default defineConfig({
output: "server",
adapter: cloudflare(),
integrations: [
react(), // 필수 — 관리 UI는 React 앱입니다
emdash({
database: d1({ binding: "DB" }),
storage: r2({ binding: "MEDIA" }),
}),
],
});
마이그레이션 및 배포
런타임 마이그레이션은 기본적으로 자동으로 유지됩니다. 배포 관리 마이그레이션의 경우, Worker를 빌드하고 계정 및 데이터베이스 UUID를 사용하여 프로비저닝된 D1 대상을 검사합니다.
pnpm build
pnpm exec emdash migrate --status --json \
--account-id "$CLOUDFLARE_ACCOUNT_ID" \
--d1 "$D1_DATABASE_ID"
보고된 대상 핑거프린트를 검토하고 기록한 후, 마이그레이션을 적용하고 동일한 빌드를 배포합니다.
pnpm exec emdash migrate \
--account-id "$CLOUDFLARE_ACCOUNT_ID" \
--d1 "$D1_DATABASE_ID" \
--expected-target-fingerprint "$EMDASH_TARGET_FINGERPRINT"
pnpm exec wrangler deploy
마이그레이션 작업에는 D1 Edit 권한이 있는 CLOUDFLARE_API_TOKEN이 필요합니다. 계정 및 데이터베이스 UUID별로 작업을 직렬화하세요. 프로비저닝, CI 동시성, 런타임 모드, 복구 안내는 코어 데이터베이스 마이그레이션 관리를 참조하세요.
데이터베이스가 비어 있고(컬렉션 없음) 설정 마법사가 완료되지 않은 경우, EmDash는 첫 부팅 시 시드 파일도 적용합니다. 시드는 빌드 시 .emdash/seed.json, package.json#emdash.seed의 경로, 또는 seed/seed.json에서 읽혀집니다 — 먼저 발견되는 것이 사용됩니다 — 그리고 번들에 인라인됩니다. 아무것도 없으면 내장 기본 시드가 사용됩니다. 기존 데이터베이스에 대한 후속 배포는 내용을 그대로 둡니다.
이미 배포된 사이트의 스키마나 콘텐츠 모델을 변경하려면 배포된 사이트 발전시키기를 참조하세요.
예약 작업
Cloudflare는 하나의 Cron Trigger에서 예약 게시, 플러그인 작업, 일반 유지보수를 실행합니다.
표준 Worker 진입점을 사용합니다:
import handler, {
createScheduledHandler,
PluginBridge,
} from "@emdash-cms/cloudflare/worker";
export { PluginBridge };
export default {
...handler,
scheduled: createScheduledHandler(),
} satisfies ExportedHandler;
wrangler.jsonc에서 일반 유지보수용 Cron Trigger를 구성합니다:
{
"triggers": {
"crons": ["* * * * *"],
},
}
다른 일반 유지보수 일정을 사용하려면 createScheduledHandler()에서 generalCron을 설정하고 wrangler.jsonc에서 동일한 표현식을 사용합니다.
배포
Cloudflare Workers에 배포합니다:
wrangler deploy
사이트가 https://my-emdash-site.<your-subdomain>.workers.dev에서 라이브됩니다.
읽기 복제본
전 세계적으로 분산된 사이트의 경우, D1 읽기 복제를 활성화하여 항상 기본 데이터베이스에 접근하는 대신 읽기 쿼리를 가까운 복제본으로 라우팅합니다. 이는 기본 리전에서 먼 방문자의 지연 시간을 크게 줄입니다.
emdash({
database: d1({
binding: "DB",
session: "auto",
}),
storage: r2({ binding: "MEDIA" }),
}),
Cloudflare 대시보드나 REST API를 통해 D1 데이터베이스 자체에서도 읽기 복제를 활성화해야 합니다.
세션 모드와 북마크 기반 일관성 작동 방식은 데이터베이스 옵션 — 읽기 복제본을 참조하세요.
객체 캐시
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 설정, 옵션, 무효화 동작은 객체 캐시를 참조하세요.
Workers Cache
Cloudflare의 Workers Cache는 Worker 앞에 엣지 캐시를 배치합니다: 일치하는 요청은 Worker를 실행하지 않고 제공됩니다.
활성화
wrangler.jsonc에서 플랫폼 캐시를 활성화합니다:
{
"cache": {
"enabled": true,
},
}
- 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 },
// …
},
});
cacheCloudflare()를 사용하면, @astrojs/cloudflare 어댑터는 생성된 Wrangler 구성에 "cache": { "enabled": true }가 없을 때 이를 주입합니다 — 자체 wrangler.jsonc에 명시적으로 나열하면 의도가 명확해집니다.
- 플랫폼 API를 사용하여 Worker에서 퍼지합니다(Cloudflare REST 자격 증명 불필요):
import { cache } from "cloudflare:workers";
await cache.purge({ purgeEverything: true });
// 또는: 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() | |
|---|---|---|
| 구성 | "cache": { "enabled": true } + @astrojs/cloudflare/cache의 cacheCloudflare() | @emdash-cms/cloudflare의 cache: { provider: cloudflareCache() } |
| 스토리지 | 플랫폼 Workers Caching | Cache API (caches.open / put / match) |
| 퍼지 | cloudflare:workers의 cache.purge() | Zone REST POST /zones/{id}/purge_cache |
| 시크릿 | 퍼지에 불필요 | CF_ZONE_ID + CF_CACHE_PURGE_TOKEN |
새 사이트에는 권장 경로를 사용하세요. cloudflareCache()는 이미 Cache API 동작에 의존하는 경우에만 유지하세요.
또한 둘 중 어느 것도 객체 캐시(objectCache: kvCache({ binding: "CACHE" }))와 혼동하지 마세요. 이는 데이터베이스 쿼리 결과를 KV에 캐시하는 것으로 — Worker 아래의 별도 레이어입니다.
커스텀 도메인
Cloudflare 대시보드에서 커스텀 도메인을 추가합니다:
- Workers & Pages > 워커로 이동
- Custom Domains > Add Custom Domain 클릭
- 도메인을 입력하고 DNS 설정 안내를 따릅니다
공개 R2 접근
R2에서 직접 미디어를 제공하려면(성능을 위해 권장):
- Cloudflare 대시보드에서 R2 > 버킷으로 이동
- Settings > Public access 클릭
- 공개 접근을 활성화하고 공개 URL을 메모합니다
- 스토리지 구성을 업데이트합니다:
storage: r2({
binding: "MEDIA",
publicUrl: "https://pub-xxx.r2.dev"
}),
이미지 변환
EmDash는 Cloudflare의 IMAGES 바인딩을 통해 Worker 내에서 R2 미디어를 리사이즈하고 재인코딩합니다. emdash/ui의 Image 컴포넌트와 리치 텍스트의 이미지 모두 EmDash가 Cloudflare 어댑터 아래에 설치하는 이미지 엔드포인트를 통해 렌더링됩니다. 내부 라우트 /_emdash/api/media/file/…의 미디어의 경우, 해당 엔드포인트는 HTTP 페치 없이 R2 바인딩에서 소스 바이트를 직접 읽습니다. 이러한 변환은 Cloudflare Access 뒤에서도 global_fetch_strictly_public과 함께 계속 작동합니다. 버킷 URL에서 제공되는 미디어 — 공개 R2 접근 참조 — 는 대신 어댑터 자체의 변환 엔드포인트를 사용하며, 변환 전에 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로 청구합니다. 소스 이미지와 매개변수의 각 고유한 조합은 월별 1회 청구되며, 해당 월 내 반복 요청은 무료입니다. Images 무료 플랜은 월 5,000개의 고유 변환을 포함합니다. 해당 한도를 초과하면, 캐시된 변환은 계속 제공되지만 새로운 변환은 9422 오류를 반환하고 이미지 요청이 실패합니다.
Cloudflare Access 인증
조직이 Cloudflare Access를 사용하는 경우, 패스키 대신 인증 프로바이더로 사용하여 기존 ID 프로바이더를 통한 싱글 사인온을 제공할 수 있습니다. 다음 구성으로 활성화합니다:
emdash({
database: d1({ binding: "DB" }),
storage: r2({ binding: "MEDIA" }),
auth: access({
teamDomain: "myteam.cloudflareaccess.com",
audience: "your-app-audience-tag",
roleMapping: {
"Admins": 50,
"Editors": 40,
},
}),
}),
전체 구성 옵션은 인증 가이드를 참조하세요.
Cloudflare AI Search
AI Search 플러그인은 게시된 EmDash 콘텐츠를 인덱싱하고 사이트에 스마트 검색 인터페이스를 추가합니다.
-
EmDash에 전달되는
plugins배열에 플러그인을 등록합니다:import { aiSearch } from "@emdash-cms/cloudflare/plugins"; // ... plugins: [ formsPlugin(), aiSearch(), ], -
Worker 구성에 AI Search 네임스페이스 바인딩을 추가합니다:
{ "ai_search_namespaces": [ { "binding": "AI_SEARCH", "namespace": "default", }, ], } -
검색 인터페이스가 사용하는 검색 엔드포인트를 생성합니다:
export { POST, prerender } from "@emdash-cms/cloudflare/plugins/ai-search"; -
사이트 레이아웃에 검색 인터페이스를 추가합니다. 트리거 슬롯에는 사이트 디자인에 맞는 모든 버튼을 넣을 수 있습니다:
--- import AISearchSnippet from "@emdash-cms/cloudflare/plugins/ai-search/astro"; --- <AISearchSnippet apiUrl="/api/ai-search" placeholder="검색..."> <button slot="trigger" type="button">검색</button> </AISearchSnippet> -
사이트를 배포합니다:
pnpm exec wrangler deploy -
EmDash 관리 패널에서 Cloudflare AI Search를 열고, 인덱싱할 컬렉션을 선택하고 Sync All Content를 클릭합니다.
이 초기 동기화는 필수입니다: 플러그인의 콘텐츠 훅은 활성화 후에 생성되거나 업데이트된 콘텐츠에 대해서만 실행되므로, 그 이전에 게시된 것은 전체 동기화를 실행할 때까지 인덱스에서 누락됩니다.
설정 후 게시되거나 업데이트된 콘텐츠는 자동으로 동기화가 유지됩니다. 같은 페이지에서 인덱싱 진행 상황이 표시됩니다.
이메일
Workers에서 유일한 내장 email:deliver 핸들러는 개발 콘솔 스텁이므로, 이메일 의존 흐름 — 매직 링크 로그인, 팀 초대, 댓글 알림 — 은 프로덕션에서 **“Email is not configured”**로 실패합니다. cloudflareEmail() 플러그인은 외부 API 키 없이 네이티브 send_email Worker 바인딩을 사용하여 Cloudflare Email Sending을 통해 실제 이메일을 전달합니다.
1. 발신 도메인 온보딩
Cloudflare 대시보드에서 Email로 이동하고 발신 도메인(또는 주소)을 확인합니다. Email Sending은 미확인 발신자의 메시지를 거부합니다.
2. 바인딩 추가
wrangler.jsonc에 send_email 바인딩을 선언합니다:
{
"send_email": [{ "name": "EMAIL" }],
}
3. 프로바이더 등록
emdash() 통합에 플러그인을 추가합니다:
import { d1, r2 } from "@emdash-cms/cloudflare";
import { cloudflareEmail } from "@emdash-cms/cloudflare/plugins";
emdash({
database: d1({ binding: "DB" }),
storage: r2({ binding: "MEDIA" }),
plugins: [
cloudflareEmail({
from: { email: "[email protected]", name: "My Site CMS" },
replyTo: "[email protected]", // 선택 사항
binding: "EMAIL", // 선택 사항, 기본값 "EMAIL"
}),
],
}),
4. 활성화 및 선택
배포한 다음, Admin → Extensions에서 플러그인을 활성화하고 Settings → Email에서 프로바이더로 선택합니다.
옵션
| 옵션 | 타입 | 기본값 | 설명 |
|---|---|---|---|
from | string | { email, name? } | — (필수) | Email Sending에 온보딩된 도메인의 발신자 주소. |
replyTo | string | — | 선택적 Reply-To, from이 no-reply 하위 도메인 주소일 때 유용. |
binding | string | "EMAIL" | wrangler.jsonc의 send_email 바인딩 이름. |
환경 변수
권장: 암호화 키
EMDASH_ENCRYPTION_KEY는 플러그인 시크릿을 저장 시 암호화하기 위한 키입니다(웹훅 토큰, Turnstile 키 등). 키는 시작 시 검증됩니다. 플러그인 시크릿 암호화는 활성화되면 사용합니다. 모든 배포에서 설정하여 나중 구성 변경 없이 시크릿이 보호되도록 하세요.
키는 사용자가 제공하며 데이터베이스에 저장되지 않습니다. 암호화된 ciphertext만 저장됩니다. 키를 분실하면 해당 키로 암호화된 모든 시크릿을 잃게 됩니다.
다음 명령으로 키를 생성하고 Worker 시크릿으로 저장합니다:
npx emdash secrets generate
wrangler secret put EMDASH_ENCRYPTION_KEY
선택: 안정 값 오버라이드
EmDash는 미리보기 HMAC 시크릿과 댓글 작성자 IP 해시 솔트를 자동 생성하고 첫 사용 시 데이터베이스에 영속화합니다. 아래 환경 변수는 값을 직접 고정해야 하는 경우의 오버라이드입니다 — 예를 들어, 별도 프로세스의 미리보기 Worker가 메인 사이트와 시크릿을 공유해야 하는 경우.
| 변수 | 목적 |
|---|---|
EMDASH_PREVIEW_SECRET | 자동 생성된 미리보기 HMAC 시크릿의 오버라이드. |
EMDASH_IP_SALT | 자동 생성된 댓글 작성자 IP 해시 솔트의 오버라이드. |
EMDASH_AUTH_SECRET | 선택 사항. 설정된 경우 IP 솔트 소스로 사용됩니다(EMDASH_IP_SALT도 설정된 경우 후자가 우선). 이미 의존하는 설치의 댓글 작성자 IP 해시를 안정적으로 유지합니다. 새 배포에서는 미설정으로 두세요. |
구성에서 환경 변수에 접근하려면 import.meta.env 또는 Cloudflare env 바인딩을 사용합니다.
EmDash가 사용하는 모든 시크릿의 전체 목록 — 저장 위치, 교체 단계, 키 분실 시 영향 — 은 시크릿 및 키 관리를 참조하세요.
미리보기 배포
미리보기 브랜치를 배포합니다:
wrangler deploy --env preview
wrangler.jsonc에 환경 섹션을 추가합니다:
{
"env": {
"preview": {
"d1_databases": [
{
"binding": "DB",
"database_name": "emdash-db-preview",
},
],
},
},
}
문제 해결
”D1 binding not found”
wrangler.jsonc의 바인딩 이름이 데이터베이스 구성과 일치하는지 확인합니다:
// 일치해야 합니다: d1({ binding: "DB" })
"binding": "DB"
“R2 binding not found”
R2 버킷이 올바르게 바인딩되었는지 확인합니다:
// 일치해야 합니다: r2({ binding: "MEDIA" })
"binding": "MEDIA"
마이그레이션 오류
스키마 오류가 보이면, Worker 로그를 추적하고(wrangler tail) 오류를 재현하여 기본 메시지를 캡처한 다음 — 해당 출력으로 이슈를 제출하세요.