Cloudflare에 배포

이 페이지

이 가이드는 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는 예약 게시, 플러그인 작업, 백업 및 유지보수를 실행합니다. 사이트가 샌드박스 플러그인을 사용하는 경우 플러그인 샌드박스를 참조하세요.

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(), // 필수 — 관리 UI는 React 앱입니다
		emdash({
			database: d1({ binding: "DB" }),
			storage: r2({ binding: "MEDIA" }),
			sandboxRunner: sandbox(),
		}),
	],
});

사이트가 마켓플레이스, 레지스트리 또는 sandboxed 플러그인을 사용하지 않는 경우 sandboxRunnerLOADER 바인딩을 생략하세요.

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에 전달하세요. 차이가 있으면 핸들러는 예상치 못한 트리거를 기록하고 무시합니다.

빌드 및 배포

사이트를 빌드하고 한 번 배포하여 Wrangler가 이름이 지정된 D1 데이터베이스와 R2 버킷을 프로비저닝하도록 합니다. Wrangler는 pnpm wrangler login으로 생성된 로컬 로그인을 사용합니다.

pnpm build
pnpm wrangler deploy

기본 auto 마이그레이션 모드에서 EmDash는 배포된 Worker가 첫 요청을 받을 때 보류 중인 코어 마이그레이션을 적용합니다. 배포 파이프라인이 새 코드가 트래픽을 받기 전에 마이그레이션을 적용해야 하거나 마이그레이션을 검사, 확인 또는 복구해야 하는 경우 코어 데이터베이스 마이그레이션 관리를 사용하세요.

데이터베이스가 비어 있고(컬렉션 없음) 설정 마법사가 완료되지 않은 경우, EmDash는 첫 부팅 시 시드 파일도 적용합니다. 시드는 빌드 시 .emdash/seed.json, package.json#emdash.seed의 경로, 또는 seed/seed.json에서 읽히며 — 먼저 찾은 것이 사용됩니다 — 번들에 인라인됩니다. 없는 경우 내장된 기본 시드가 사용됩니다. 기존 데이터베이스에 대한 후속 배포는 내용을 그대로 유지합니다.

이미 배포된 사이트의 스키마 또는 콘텐츠 모델을 변경하려면 배포된 사이트 진화를 참조하세요.

Worker를 D1 근처에 배치

Cloudflare는 기본적으로 방문자 근처에서 Worker를 실행합니다. EmDash 서버 렌더링 요청은 여러 D1 왕복을 수행하므로 Targeted Placement를 사용하여 Worker를 D1 프라이머리 근처에서 실행하고 이러한 요청을 더 빠르게 만드세요.

Wrangler는 정확히 하나의 셀렉터(region, host 또는 hostname)와 함께 placement.mode: "targeted"를 수용합니다. D1 프라이머리 위치를 대상으로 하는 값을 선택하고 결과 placement 객체를 wrangler.jsonc에 추가하세요. Targeted Placement에서 D1 읽기 레플리카를 활성화하지 마세요. EmDash의 session 설정을 기본값 "disabled"로 유지하여 읽기와 쓰기가 가까운 프라이머리를 사용하도록 하세요.

객체 캐시

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를 실행하지 않고 제공됩니다.

활성화

  1. 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 },
       // 다른 퍼블릭 라우트는 다른 캐시 수명을 사용할 수 있습니다.
     },
    });

    @astrojs/cloudflare 어댑터는 cacheCloudflare()를 감지하고 생성된 배포 구성에서 Workers Cache를 활성화합니다.

  2. 플랫폼 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를 통해 자체 캐싱을 제어합니다.

활성화하기 전에 알아야 할 두 가지:

  1. Cache-Control 헤더가 없는 응답도 캐시됩니다. Workers Cache는 RFC 9111 휴리스틱 신선도를 적용합니다 — 헤더가 없는 200은 2시간 동안 캐시됩니다. 모든 커스텀 라우트에 명시적인 Cache-Control을 지정하세요(세션 의존적인 것에는 private, no-store 사용).
  2. 캐시된 페이지는 로그인한 편집자와 공유됩니다. 캐시는 Worker 전에 실행되므로 요청 쿠키를 기반으로 우회할 수 없습니다. 로그인한 편집자는 항목이 만료될 때까지 퍼블릭 페이지의 캐시된 익명 변형을 받을 수 있습니다 — 시각적 편집 도구 모음 없이. 편집자가 렌더링한 응답 자체는 절대 저장되지 않으므로(private, no-store를 전달) 반대 방향으로 유출되는 것은 없습니다.

@emdash-cms/cloudflarecloudflareCache()와 다릅니다

권장: Workers Caching레거시: cloudflareCache()
구성"cache": { "enabled": true } + @astrojs/cloudflare/cachecacheCloudflare()@emdash-cms/cloudflarecache: { provider: cloudflareCache() }
스토리지Platform Workers CachingCache API (caches.open / put / match)
퍼지cloudflare:workerscache.purge()Zone REST POST /zones/{id}/purge_cache
시크릿퍼지에 필요 없음CF_ZONE_ID + CF_CACHE_PURGE_TOKEN

새 사이트에는 권장 경로를 사용하세요. cloudflareCache()는 이미 그 Cache API 동작에 의존하는 경우에만 유지하세요.

또한 이들 중 어느 것도 객체 캐시(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의 인증된 미디어 라우트를 통해 제공됩니다. 버킷에 퍼블릭 커스텀 도메인이 있는 경우, 생성되는 미디어 URL이 그것을 사용하도록 해당 원본을 publicUrl로 설정하세요:

storage: r2({
	binding: "MEDIA",
	publicUrl: "https://media.example.com",
}),

퍼블릭 버킷 액세스는 미디어뿐만 아니라 모든 접근 가능한 객체에 적용됩니다. 자동 JSON 백업은 동일한 스토리지 백엔드의 backups/ 접두사를 사용하므로 퍼블릭 도메인을 통해 해당 접두사를 노출하지 마세요. 미디어 스토리지 선택에서 안전한 경계를 설명합니다.

이미지 변환

EmDash는 Cloudflare의 IMAGES 바인딩을 통해 Worker 내에서 R2 미디어의 크기 조정 및 재인코딩을 수행합니다. emdash/uiImage 컴포넌트와 리치 텍스트의 이미지는 EmDash가 Cloudflare 어댑터 아래에 설치하는 이미지 엔드포인트를 통해 렌더링됩니다. 내부 라우트 /_emdash/api/media/file/…의 미디어에 대해, 해당 엔드포인트는 HTTP 페치 없이 R2 바인딩에서 직접 소스 바이트를 읽습니다. 이러한 변환은 Cloudflare Access 뒤에서도 global_fetch_strictly_public에서도 계속 작동합니다. 버킷 URL에서 제공되는 미디어 — 퍼블릭 R2 액세스 참조 — 는 대신 어댑터 자체의 변환 엔드포인트를 사용하며, 변환 전에 HTTP를 통해 파일을 가져옵니다.

바인딩을 선언할 필요가 없습니다. @astrojs/cloudflareastro 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 제공자로 패스키 인증을 대체할 수 있습니다. 오디언스 값은 시크릿 런타임 설정입니다. 환경 변수에 이름을 지정하여 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_AUDIENCECF_ACCESS_AUDIENCE를 설정하세요. 인증 가이드에서 사용자 프로비저닝, 기본 역할 및 역할 동기화를 설명합니다.

이메일

프로덕션 Worker에는 기본 이메일 전달 서비스가 없습니다. 매직 링크 로그인, 팀 초대 및 댓글 알림은 이메일 플러그인이 활성화될 때까지 이메일이 구성되지 않음을 반환합니다.

Cloudflare 이메일 플러그인은 send_email 바인딩을 사용합니다. 먼저 Cloudflare Email Sending으로 발신자 도메인을 온보딩하고 확인하세요. Cloudflare는 발신자 주소가 승인된 발신자가 아닌 메시지를 거부합니다.

바인딩을 추가하고 제공자를 등록합니다:

{
	"send_email": [{ "name": "EMAIL" }],
}
import { cloudflareEmail } from "@emdash-cms/cloudflare/plugins";

emdash({
	plugins: [
		cloudflareEmail({
			from: { email: "[email protected]", name: "My Site CMS" },
			replyTo: "[email protected]",
		}),
	],
}),

배포 후, 확장 프로그램에서 플러그인을 활성화하고 설정 → 이메일에서 선택하세요. 발신자가 승인되고 바인딩이 존재할 때까지 전송이 실패합니다.

플러그인은 binding 옵션이 다른 이름을 지정하지 않는 한 EMAIL이라는 바인딩을 사용합니다. 유일한 활성 이메일 제공자인 경우, EmDash가 자동으로 선택합니다. 둘 이상의 제공자가 활성화된 경우 설정 → 이메일에서 Cloudflare 제공자를 선택하세요. 선택적 replyTo 주소는 승인된 발신자 주소를 변경하지 않고 답장을 받습니다.

AI Search 플러그인은 네이티브 플러그인 등록과 ai_search_namespaces 바인딩이 모두 필요합니다. 배포 후 관리 화면에서 Cloudflare AI Search를 열고, 컬렉션을 선택한 다음 모든 콘텐츠 동기화를 실행하세요. 초기 동기화는 플러그인이 활성화되기 전에 게시된 콘텐츠를 인덱싱합니다. 훅이 이후 변경 사항을 동기화합니다.

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";

레이아웃에 검색 인터페이스를 추가합니다. 트리거 슬롯은 사이트 디자인에 맞는 버튼을 수용합니다:

---
import AISearchSnippet from "@emdash-cms/cloudflare/plugins/ai-search/astro";
---

<AISearchSnippet apiUrl="/api/ai-search" placeholder="콘텐츠 검색">
	<button slot="trigger" type="button">검색</button>
</AISearchSnippet>

Worker 시크릿

pnpm wrangler secret put <NAME>으로 시크릿 값을 저장하세요. wrangler.jsonc에 넣거나 빌드 시간 import.meta.env 값에서 읽지 마세요.

EMDASH_ENCRYPTION_KEY는 현재 플러그인 시크릿이나 기타 저장된 데이터를 암호화하지 않습니다. 설정된 경우, EmDash는 시작 시 형식을 확인합니다. 잘못된 값은 운영자 대상 로그 메시지를 생성하지만 사이트는 요청 처리를 계속합니다. 플러그인 시크릿은 데이터베이스에 평문으로 남아 있습니다.

EmDash는 런타임에 process.env에서 시크릿을 읽습니다. Worker 코드는 cloudflare:workers에서 가져온 env에서 바인딩을 읽습니다. import.meta.env를 통해 시크릿을 읽지 마세요: Vite는 빌드 시 해당 값을 대체하고 서버 번들에 쓸 수 있습니다.

프리뷰 HMAC 시크릿과 댓글 작성자 IP 솔트는 런타임 오버라이드를 제공하지 않는 한 생성되어 데이터베이스에 저장됩니다. 시크릿 및 키 관리에서 정확한 변수, 저장 위치 및 로테이션 효과를 나열합니다.

프리뷰 배포

이름이 지정된 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가 사용하는 모든 바인딩을 반복해야 합니다. Wrangler가 리소스 식별자를 작성한 후 코어 D1, R2 및 샌드박스 바인딩은 이 형태를 갖습니다:

{
	"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의 바인딩 이름이 데이터베이스 구성과 일치하는지 확인하세요:

// 일치해야 합니다: d1({ binding: "DB" })
"binding": "DB"

“R2 binding not found”

R2 버킷이 올바르게 바인딩되었는지 확인하세요:

// 일치해야 합니다: r2({ binding: "MEDIA" })
"binding": "MEDIA"

마이그레이션 오류

스키마 오류가 보이면 Worker 로그를 추적하고(wrangler tail) 오류를 재현하여 기본 메시지를 캡처하세요 — 그 출력으로 이슈를 제출하세요.