Astro + Cloudflare Pages 全栈实战:D1、R2、Turnstile 完整接入指南
本文基于真实项目
thetakumi整理,该项目为 Cloudflare Monorepo,包含一个 Astro v5 SSR 博客(部署到 Cloudflare Pages)与一个 docs-rag Durable Object Worker(手动wrangler deploy)。
一、通过 GitHub 关联 Cloudflare Pages 实现自动发布
1.1 项目结构
cloudflare/ ← monorepo 根(git 仓库)
├── src/ ← docs-rag Worker 源码
├── wrangler.jsonc ← Worker 配置
├── blog/ ← Astro 博客子项目
│ ├── src/
│ ├── migrations/
│ └── wrangler.jsonc ← Pages 配置(含 D1、R2 绑定)
└── .gitignore
两个子项目共用同一 git 仓库,各自有独立的 package.json 和 wrangler.jsonc,不使用 npm workspaces,保持最简结构。
1.2 Cloudflare Pages Dashboard 配置
在 dash.cloudflare.com → Workers & Pages → Create → Pages → Connect to Git:
| 配置项 | 值 |
|---|---|
| Project name | thetakumi |
| Production branch | main |
| Root directory | blog |
| Build command | npm run build |
| Build output directory | dist |
| NODE_VERSION(环境变量) | 20 |
⚠️ Root directory 是关键:Cloudflare Pages 的 root directory 指定后,所有构建命令都在该目录下执行,相当于
cd blog && npm run build。Monorepo 中只发布子目录时必须填写。
1.3 自动发布流程
flowchart LR
A[git push origin main] --> B[GitHub Webhook 触发]
B --> C[Cloudflare Pages 拉取 blog/ 子目录]
C --> D["npm run build -> dist"]
D --> E[全球 CDN 部署]
E --> F[thetakumi.com 更新]
推送后约 30–60 秒即可在 Pages Dashboard → Deployments 看到新的构建记录。
1.4 加密环境变量(Secrets)
以下变量必须在 Pages Dashboard → Settings → Environment variables 中设置为加密变量,绝不能写入 wrangler.jsonc:
JWT_SECRET # JWT 签名密钥
TURNSTILE_SECRET_KEY # Cloudflare Turnstile 服务端密钥
CONTACT_EMAIL_SECRET # 邮件中继 Worker 共享密钥
本地开发时,在 blog/.dev.vars(已被 .gitignore 忽略)中填写:
JWT_SECRET=any-local-secret
TURNSTILE_SECRET_KEY=<from-Cloudflare-Turnstile-dashboard>
CONTACT_EMAIL_URL=https://api.thetakumi.com
CONTACT_EMAIL_SECRET=<same-as-worker-secret>
1.5 自定义域名
Pages 创建后默认得到 thetakumi.pages.dev。绑定自定义域:
- Pages 项目 → Custom Domains → Set up a custom domain
- 填入
thetakumi.com(Cloudflare DNS 自动创建 CNAME flattening) - 等待 2–5 分钟,状态变为 Active
二、Cloudflare D1 数据库
D1 是 Cloudflare 提供的 SQLite 兼容边缘数据库,零冷启动,支持 SQL 事务。
2.1 创建 D1 数据库
cd blog
npx wrangler d1 create thetakumi-blog
输出中会给出 database_id,复制到 wrangler.jsonc:
// blog/wrangler.jsonc
{
"name": "thetakumi",
"pages_build_output_dir": "./dist",
"compatibility_date": "2025-01-01",
"d1_databases": [
{
"binding": "DB",
"database_name": "thetakumi-blog",
"database_id": "<your-database-id>",
"migrations_dir": "./migrations"
}
]
}
binding是代码中访问数据库的变量名,即env.DBmigrations_dir指定 migration SQL 文件目录
2.2 编写 Migration
在 blog/migrations/0001_init.sql 中定义表结构:
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT NOT NULL,
content TEXT NOT NULL,
tags TEXT DEFAULT '[]', -- JSON 字符串数组,如 '["astro","cloudflare"]'
published INTEGER DEFAULT 1, -- 0 或 1(SQLite 无 BOOLEAN 类型)
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
注意:D1 / SQLite 没有原生布尔类型,用
INTEGER 0/1代替;数组用 JSON 字符串存储。
2.3 应用 Migration
# 应用到本地(开发调试)
npx wrangler d1 migrations apply thetakumi-blog --local
# 应用到远程(生产数据库)
npx wrangler d1 migrations apply thetakumi-blog
2.4 在 Astro 页面中访问 D1
Cloudflare Pages + @astrojs/cloudflare adapter 通过 locals.runtime.env 暴露所有绑定:
// blog/src/env.d.ts
type D1Database = import('@cloudflare/workers-types').D1Database;
type R2Bucket = import('@cloudflare/workers-types').R2Bucket;
interface Env {
DB: D1Database;
IMAGES_BUCKET: R2Bucket;
IMAGES_BASE_URL: string;
CONTACT_EMAIL_URL: string;
JWT_SECRET: string;
TURNSTILE_SECRET_KEY: string;
CONTACT_EMAIL_SECRET: string;
}
declare namespace App {
interface Locals extends Runtime<Env> {
userId?: number;
}
}
在 .astro 页面中:
// blog/src/pages/index.astro
---
import { getPosts } from '../lib/db';
const env = Astro.locals.runtime?.env as Env;
const posts = await getPosts(env.DB, true); // publishedOnly = true
---
在 API Route 中:
// blog/src/pages/api/posts/index.ts
import type { APIRoute } from 'astro';
import { createPost } from '../../../lib/db';
export const POST: APIRoute = async ({ request, locals }) => {
const env = locals.runtime?.env as Env;
const data = await request.json();
const post = await createPost(env.DB, data);
return new Response(JSON.stringify(post), {
headers: { 'Content-Type': 'application/json' },
});
};
2.5 D1 查询封装模式
将所有数据库操作集中在 src/lib/db.ts,不在页面中直接写 SQL:
// blog/src/lib/db.ts(节选关键模式)
// 1. 原始行类型与业务类型分离
interface RawPost {
tags: string; // JSON 字符串
published: number; // 0 或 1
// ...其他字段
}
// 2. 统一转换函数
function parsePost(row: RawPost): Post {
return {
...row,
tags: JSON.parse(row.tags || '[]'),
published: row.published === 1,
};
}
// 3. 标签搜索:用双引号精确匹配,避免前缀误匹配
export async function getPostsByTag(db: D1Database, tag: string) {
const { results } = await db
.prepare(`SELECT * FROM posts WHERE published = 1 AND tags LIKE ?`)
.bind(`%"${tag}"%`) // 注意:双引号包裹,匹配 JSON 数组中的完整元素
.all<RawPost>();
return results.map(parsePost);
}
// 4. 动态 UPDATE:只更新传入的字段
export async function updatePost(db: D1Database, id: number, data: Partial<CreatePostInput>) {
const sets: string[] = [];
const values: unknown[] = [];
if (data.title !== undefined) { sets.push('title = ?'); values.push(data.title); }
// ...其他字段
sets.push('updated_at = CURRENT_TIMESTAMP');
values.push(id);
const result = await db
.prepare(`UPDATE posts SET ${sets.join(', ')} WHERE id = ? RETURNING *`)
.bind(...values)
.first<RawPost>();
if (!result) throw new Error('Post not found');
return parsePost(result);
}
2.6 本地开发查看 D1 数据
# 执行任意 SQL
npx wrangler d1 execute thetakumi-blog --local --command="SELECT * FROM posts"
# 导入 SQL 文件
npx wrangler d1 execute thetakumi-blog --local --file=./seeds/data.sql
三、Cloudflare R2 对象存储
R2 是 S3 兼容的对象存储,零出站流量费(egress free),适合存储图片、附件等静态资源。
3.1 创建 R2 Bucket
npx wrangler r2 bucket create thetakumi-images
3.2 绑定到 Pages 项目
// blog/wrangler.jsonc
{
"r2_buckets": [
{
"binding": "IMAGES_BUCKET",
"bucket_name": "thetakumi-images"
}
],
"vars": {
"IMAGES_BASE_URL": "https://images.thetakumi.com"
}
}
IMAGES_BASE_URL 是 R2 Bucket 的自定义域名(在 Cloudflare Dashboard → R2 → Bucket → Settings → Custom Domains 中绑定)。
3.3 图片上传 API 实现
// blog/src/pages/api/upload.ts
import type { APIRoute } from 'astro';
export const prerender = false;
const MAX_SIZE = 10 * 1024 * 1024; // 10 MB
function buildKey(filename: string): string {
const now = new Date();
const yyyy = now.getFullYear();
const mm = String(now.getMonth() + 1).padStart(2, '0');
const ext = filename.split('.').pop()?.toLowerCase() ?? 'bin';
const base = filename
.replace(/\.[^.]+$/, '')
.replace(/[^a-z0-9_-]/gi, '_')
.slice(0, 40);
// 对象键格式:2026/06/my_image_1719388800000.jpg
return `${yyyy}/${mm}/${base}_${Date.now()}.${ext}`;
}
export const POST: APIRoute = async ({ request, locals }) => {
const env = locals.runtime?.env as Env;
// 检查 R2 绑定是否存在
if (!env?.IMAGES_BUCKET) {
return new Response(JSON.stringify({ error: 'R2 未配置' }), { status: 503 });
}
const formData = await request.formData();
const file = formData.get('file');
if (!(file instanceof File)) {
return new Response(JSON.stringify({ error: '缺少文件字段 file' }), { status: 400 });
}
if (!file.type.startsWith('image/')) {
return new Response(JSON.stringify({ error: '只支持图片文件' }), { status: 415 });
}
if (file.size > MAX_SIZE) {
return new Response(JSON.stringify({ error: '文件超过 10 MB 限制' }), { status: 413 });
}
const key = buildKey(file.name);
const arrayBuffer = await file.arrayBuffer();
// 写入 R2,同时设置 Content-Type
await env.IMAGES_BUCKET.put(key, arrayBuffer, {
httpMetadata: { contentType: file.type },
});
// 返回可公开访问的 CDN URL
const url = `${env.IMAGES_BASE_URL}/${key}`;
return new Response(JSON.stringify({ url }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
3.4 前端上传调用示例
async function uploadImage(file: File): Promise<string> {
const form = new FormData();
form.append('file', file);
const res = await fetch('/api/upload', { method: 'POST', body: form });
if (!res.ok) throw new Error('上传失败');
const { url } = await res.json();
return url; // "https://images.thetakumi.com/2026/06/photo_1719388800000.jpg"
}
3.5 R2 公开访问配置
默认 R2 Bucket 是私有的。要让上传的图片可公开访问,有两种方式:
方式一(推荐):绑定自定义域名
R2 → Bucket → Settings → Custom Domains → Add Domain,填入 images.thetakumi.com,Cloudflare 自动配置 DNS 和 SSL。
方式二:开启 R2.dev 公共访问
R2 → Bucket → Settings → Public Access → Allow Access,得到 pub-xxxx.r2.dev 域名(不推荐用于生产)。
四、Cloudflare Turnstile 人机验证
Turnstile 是 Cloudflare 的 CAPTCHA 替代方案,无图形验证码,对用户体验友好,且对爬虫有效拦截。
4.1 获取密钥
- dash.cloudflare.com → Turnstile → Add site
- 填入域名
thetakumi.com,Widget type 选 Managed - 复制 Site Key(公开,写在前端)和 Secret Key(保密,写在 Pages 加密变量中)
4.2 前端嵌入
在表单页面中嵌入 Turnstile Widget:
<!-- blog/src/pages/contact.astro -->
<form method="POST" action="/api/contact">
<!-- 其他表单字段 -->
<!-- Turnstile Widget:data-sitekey 填入 Site Key -->
<div class="cf-turnstile"
data-sitekey="<your-turnstile-site-key>"
data-theme="light">
</div>
<button type="submit">发送</button>
</form>
<!-- 在 </body> 之前异步加载 Turnstile JS -->
<script is:inline
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
async defer>
</script>
Turnstile 会自动在 Widget 容器中渲染,并在表单提交时自动注入隐藏字段 cf-turnstile-response,值为验证 token。
4.3 服务端验证
// blog/src/pages/api/contact.ts
import type { APIRoute } from 'astro';
async function verifyTurnstile(
token: string,
secret: string,
ip: string
): Promise<boolean> {
const res = await fetch(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
secret, // TURNSTILE_SECRET_KEY
response: token, // 前端提交的 cf-turnstile-response
remoteip: ip, // 用户真实 IP(Cloudflare 自动注入 CF-Connecting-IP)
}),
}
);
const data = await res.json<{ success: boolean }>();
return data.success === true;
}
export const POST: APIRoute = async ({ request, redirect, locals }) => {
const env = locals.runtime?.env as Env;
const form = await request.formData();
const tsToken = form.get('cf-turnstile-response')?.toString() ?? '';
const ip = request.headers.get('CF-Connecting-IP') ?? '';
// 1. 先验证 Turnstile,不通过直接拒绝
const tsOk = await verifyTurnstile(tsToken, env.TURNSTILE_SECRET_KEY, ip);
if (!tsOk) return redirect('/contact?error=captcha');
// 2. 验证通过后再处理业务逻辑
const name = form.get('name')?.toString().trim() ?? '';
const email = form.get('email')?.toString().trim() ?? '';
const message = form.get('message')?.toString().trim() ?? '';
if (!name || !email || !message) return redirect('/contact?error=server');
// 3. 转发给邮件中继 Worker
try {
const res = await fetch(`${env.CONTACT_EMAIL_URL}/api/contact-email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Contact-Secret': env.CONTACT_EMAIL_SECRET,
},
body: JSON.stringify({ name, email, message }),
});
if (!res.ok) return redirect('/contact?error=server');
} catch {
return redirect('/contact?error=server');
}
return redirect('/contact?sent=1');
};
4.4 本地开发绕过
本地开发时 localhost 域名下 Turnstile 会使用测试模式,使用以下测试密钥:
| 类型 | 值 | 行为 |
|---|---|---|
| Site Key(始终通过) | 1x00000000000000000000AA |
验证码始终通过 |
| Site Key(始终阻断) | 2x00000000000000000000AB |
验证码始终失败 |
| Secret Key(测试用) | 1x0000000000000000000000000000000AA |
服务端始终返回 success=true |
在 blog/.dev.vars 中设置测试 Secret Key,前端 data-sitekey 替换为测试 Site Key 即可本地正常开发。
五、架构总结
flowchart TD
User["访客浏览器"] --> CF["Cloudflare Edge"]
CF --> Pages["Cloudflare Pages - Astro SSR"]
Pages --> D1["D1 SQLite - 文章/用户数据"]
Pages --> R2["R2 对象存储 - 图片资源"]
Pages --> Turnstile["Turnstile 验证 - 防垃圾表单"]
Pages --> Worker["docs-rag Worker - 邮件中继/AI Chat"]
Dev["开发者 git push"] --> GitHub["GitHub main branch"]
GitHub --> Build["Pages 自动构建 blog/ 子目录"]
Build --> CF
| 功能 | Cloudflare 产品 | 费用 |
|---|---|---|
| 静态/SSR 托管 | Pages | 免费(100k req/day) |
| 关系数据存储 | D1 | 免费(5 GB / 25M 读/天) |
| 图片/文件存储 | R2 | 免费(10 GB / 1M 操作/月) |
| 人机验证 | Turnstile | 免费 |
| 自定义域名 SSL | Cloudflare DNS | 免费 |
整个博客的基础架构可以在 Cloudflare 免费套餐内运行,适合个人博客、文档站等中小流量场景。
六、常用 Wrangler 命令速查
# D1
npx wrangler d1 migrations apply thetakumi-blog # 应用 migration 到生产
npx wrangler d1 migrations apply thetakumi-blog --local # 应用到本地
npx wrangler d1 execute thetakumi-blog --command="SELECT * FROM posts" --local
# R2
npx wrangler r2 object list thetakumi-images # 列出对象
npx wrangler r2 object put thetakumi-images/test.jpg --file=./test.jpg
# Pages 本地开发(含绑定)
cd blog && npm run dev # http://localhost:4321
# Pages Secret
npx wrangler pages secret put JWT_SECRET
npx wrangler pages secret put TURNSTILE_SECRET_KEY
# Worker Secret(docs-rag)
npx wrangler secret put CONTACT_EMAIL_SECRET