Cloudflare × EmDash でブログを構築した記録

Cloudflare 製のオープンソース CMS「EmDash」を使い、Workers、D1、R2 でブログを立ち上げるまでの手順と、つまずいた点の記録です。

Cloudflare が公開しているオープンソースの CMS「EmDash」で、ブログを Cloudflare Workers 上に立ち上げました。そのときの手順と、途中で気づいた点を記録しておきます。

この記事の画像には、個人情報が写らないようにしています。ブラウザの画面はローカル環境でダミーデータを使って撮り、ターミナルの画面はメールアドレスやアカウント ID をダミーの値に置き換えました。やり方は記事の最後に書いています。

EmDash とは

EmDash は、Cloudflare が 2026 年 4 月に発表したオープンソースのフルスタック CMS です。「WordPress の現代的な後継」を掲げています。

Astro をベースにしていて、管理画面(/_emdash/admin)も含めて 1 つの Astro プロジェクトに収まっています。Cloudflare では、アプリ本体を Workers、データベースを D1、メディアの保存先を R2 で動かします。Node.js と SQLite の組み合わせでも動きます。

Sandboxed Plugins を有効にすると、プラグインは隔離された Worker(Worker Loader)の中で動きます。WordPress では 1 つのプラグインの不具合がサイト全体に及ぶことがありますが、その心配が小さくなります。

今回の構成

役割

サービス

バインディング名

アプリ本体(SSR と管理画面)

Cloudflare Workers

なし

コンテンツの DB

D1

DB

画像などのメディア

R2

MEDIA

セッション

Workers KV

SESSION(自動で作成)

画像の変換

Cloudflare Images

IMAGES(自動で追加)

予約投稿などの定期処理

Cron Triggers

* * * * *

使用したバージョン

  • Node.js v26.11.1(要件は 22.16 以上)
  • pnpm 11.9
  • Wrangler 4.149.0
  • EmDash 1.2.0
  • Astro 7.3.8

1. プロジェクトを作る

create-emdash でプロジェクトを作ります。対話形式でも進められますが、今回はオプションで全部指定しました。

npm create emdash@latest emdash-blog -- --template cloudflare:blog --pm pnpm
create-emdash の実行結果

主なオプションは次のとおりです。

オプション

意味

--template

blog / starter / marketing / portfolio から選ぶ。cloudflare:blog のように書くと、プラットフォームも同時に指定できる

--platform

cloudflare または node

--sandboxed-plugins

Sandboxed Plugins を有効にする。Workers の有料プランが必要(デフォルトはオフ)

create-emdash は、プラグインのシークレットを暗号化するための EMDASH_ENCRYPTION_KEY を作り、.env に書き込みます。.env は .gitignore に入っていますが、このキーは別にバックアップしておきましょう。キーをなくすと、DB を復元しても、暗号化された設定を読めなくなります。

生成された主なファイル

astro.config.mjs では、D1 と R2 を EmDash に接続しています。

import cloudflare from "@astrojs/cloudflare";
import react from "@astrojs/react";
import { d1, r2 } from "@emdash-cms/cloudflare";
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";

export default defineConfig({
	output: "server",
	adapter: cloudflare(),
	integrations: [
		react(),
		emdash({
			database: d1({ binding: "DB", session: "auto" }),
			storage: r2({ binding: "MEDIA" }),
		}),
	],
});

src/worker.ts は Worker のエントリーポイントです。Cron から呼ばれる scheduled ハンドラーもここで定義しています。

import handler, { createScheduledHandler, PluginBridge } from "@emdash-cms/cloudflare/worker";

export { PluginBridge };

export default {
	...handler,
	scheduled: createScheduledHandler(),
} satisfies ExportedHandler;

2. リソース名を変える

生成された wrangler.jsonc には、Worker、D1、R2 の名前として my-emdash-site と my-emdash-media が入っています。

以前に EmDash を試したことがあると、同じ名前のリソースがアカウントに残っていることがあります。私のアカウントにも残っていたので、デプロイの前にプロジェクト固有の名前に変えました。

-	"name": "my-emdash-site",
+	"name": "emdash-blog",
 	...
-			"database_name": "my-emdash-site",
+			"database_name": "emdash-blog",
 	...
-			"bucket_name": "my-emdash-media",
+			"bucket_name": "emdash-blog-media",

アカウントにあるリソースは、次のコマンドで確認できます。

pnpm wrangler d1 list
pnpm wrangler r2 bucket list

3. ローカルで動かす

cd emdash-blog
pnpm dev

開発サーバーが起動したら、http://localhost:4321 を開きます。止めるときは Ctrl+C を押します。

ローカルでは D1 と R2 を Miniflare がエミュレートするので、Cloudflare に接続しなくてもひととおり試せます。

Claude Code などの AI エージェントから pnpm dev を実行すると、Astro 7 は開発サーバーをバックグラウンドで起動し、ログを JSON で出します。その場合は pnpm astro dev stop で止め、pnpm astro dev logs でログを見ます。

初期セットアップウィザード

http://localhost:4321/_emdash/admin を開くと、セットアップウィザードが始まります。

最初の画面で、サイトのタイトルとキャッチフレーズを入力し、サンプルコンテンツを入れるかどうかを選びます。

サイト情報の入力

次の画面で、管理者のメールアドレスと名前を入力します。

アカウントの作成

最後にパスキーを作ります。EmDash の管理画面にはパスワードがなく、パスキーでログインします。

パスキーの作成
パスキー作成完了

ウィザードが終わるとログイン画面に移るので、「パスキーでログイン」を押し、いま作ったパスキーでログインします。次回からも、この画面から入ります。メールの送信を設定すると、メールのリンクでもログインできるようになります。

ログイン画面
ローカル開発では、http://localhost:4321/_emdash/api/setup/dev-bypass?redirect=/_emdash/admin を開くと、パスキーなしで開発用の管理者としてログインできます。本番環境で開くと 403 が返り、使えないことを確認しました。

管理画面

ダッシュボードには、下書き、メディア、ユーザー数、最近の更新が表示されます。管理画面は日本語で表示されますが、一部の項目は英語のままです。

管理画面のダッシュボード

投稿一覧の画面です。

投稿一覧

エディターで書いた本文は、Portable Text という形式で保存されます。右側のパネルで、公開状態、スラッグ、執筆者などを設定できます。

投稿エディター

公開側の見た目

blog テンプレートには、トップページのヒーロー、記事一覧、カテゴリー、タグ、全文検索、RSS、ダークモードとライトモードの切り替えがそろっています。

トップページ

記事ページは 3 カラムです。左に執筆者と日付、中央に本文、右に目次と検索が並びます。

記事ページ

4. Cloudflare にデプロイする

Wrangler にログインしていない場合は、先にログインします。

pnpm wrangler login

ビルドしてデプロイします。--secrets-file .env を付けると、.env の EMDASH_ENCRYPTION_KEY も Workers の Secret としてアップロードされます。

pnpm astro build
pnpm wrangler deploy --secrets-file .env
wrangler deploy の実行結果

wrangler.jsonc に書いた D1 と R2、それに Astro のセッション用の KV は、初回のデプロイで Wrangler が自動で作りました。wrangler d1 create などを前もって実行する必要はありません。

シークレットが平文の変数ではなく Secret として保存されたかは、次のコマンドで確認できます。

pnpm wrangler secret list
# [{ "name": "EMDASH_ENCRYPTION_KEY", "type": "secret_text" }]

DB のマイグレーションは、最初のリクエストのときに自動で実行されます。

5. 本番の初期セットアップ

デプロイが終わったら、すぐに https://emdash-blog.<your-subdomain>.workers.dev/_emdash/admin を開いて、セットアップウィザードを済ませます。急いだほうがよい理由は 2 つあります。

  1. セットアップが終わるまでは、URL を知っている人なら誰でも最初の管理者になれてしまいます。
  2. パスキーはドメインに結びついています。localhost で作ったパスキーは本番では使えないので、本番のドメインで登録し直す必要があります。

カスタムドメインで運用する予定なら、先にドメインを設定してから、そのドメインでセットアップするのがおすすめです。workers.dev で登録したパスキーは、カスタムドメインでは使えないためです。あわせて、環境変数 EMDASH_SITE_URL も設定しておきます。

パスキーは 1 ユーザーにつき 10 個まで登録できます。端末をなくしたときのために、予備のパスキーも登録しておくと安心です。

ハマりどころとメモ

  • Sandboxed Plugins は Worker Loader を使うので、Workers の有料プランが必要です。無料プランのままなら、wrangler.jsonc の worker_loaders はコメントアウトしたままにしておきます。
  • デフォルトのリソース名 my-emdash-site が、以前に作ったリソースと重なることがあります(2 章を参照)。
  • blog テンプレートでは、投稿へのコメントが最初から有効になっています。公開前にモデレーションを設定するか、使わないなら無効にしておきましょう。
  • 予約投稿などの定期処理のために、毎分(* * * * *)動く Cron Trigger が設定されています。設定は pnpm emdash doctor で確認できます。
  • Images バインディングが自動で追加されます。Cloudflare Images の画像変換は課金の対象で、料金は「元の画像と変換パラメーターの組み合わせ」の月ごとの数で決まります。使用量に注意します。
  • バックアップでは、D1 のデータ、R2 のメディア、EMDASH_ENCRYPTION_KEY の 3 つをまとめて保管します。

おまけ: 個人情報を写さずにスクリーンショットを撮る

ブログに載せる画像に、メールアドレスやアカウントの情報が写らないようにしたかったので、次の方法で撮りました。

ブラウザの画面: Playwright と WebAuthn の仮想認証器

ローカル環境のヘッドレス Chrome を、Playwright から操作しました。入力するのは admin@example.com などのダミーデータだけです。パスキーの登録には、Chrome DevTools Protocol の WebAuthn 仮想認証器を使いました。こうすると、実際の端末の生体認証や、パスワードマネージャーの中身が画面に出ません。

import { chromium } from "playwright";

const browser = await chromium.launch({ channel: "chrome" });
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 2, locale: "ja-JP" });
const page = await ctx.newPage();

// WebAuthn 仮想認証器(実際の端末や生体情報を使わない)
const cdp = await ctx.newCDPSession(page);
await cdp.send("WebAuthn.enable");
await cdp.send("WebAuthn.addVirtualAuthenticator", {
	options: {
		protocol: "ctap2",
		transport: "internal",
		hasResidentKey: true,
		hasUserVerification: true,
		isUserVerified: true,
		automaticPresenceSimulation: true,
	},
});

await page.goto("http://localhost:4321/_emdash/admin/setup");
await page.getByLabel("サイトタイトル").fill("EmDash Lab");
// ...ダミーデータで入力しながら page.screenshot() で撮影
仮想認証器で作ったパスキーは、ブラウザを閉じると消えます。本番のセットアップでは使わないでください。使うと、二度とログインできなくなります。

ターミナルの画面: 実際のログを置き換えて画像にする

ターミナルの画像には、実際のコマンド出力を使いました。ただし、個人を特定できる値は sed でダミーに置き換えています。

置き換えた値

置き換え後

メールアドレス

you@example.com

Cloudflare のアカウント ID

0123456789abcdef...

workers.dev のサブドメイン

your-subdomain

KV の ID とバージョン ID

xxxxxxxx...

ホームディレクトリのパス

~

置き換えたあと、元の値が残っていないことを grep で確かめてから、ターミナル風の HTML の枠に流し込み、Playwright で画像にしました。

おわりに

npm create emdash@latest から wrangler deploy まで、2〜3 本のコマンドで、CMS 付きのブログを Workers 上で公開できました。次は、カスタムドメインの設定と、src/styles/theme.css でのテーマのカスタマイズを試す予定です。

参考