Article
Cloudflare 全栈部署流程
本文介绍一种通用的 Cloudflare 全栈部署流程,适合前后端分离项目。前端构建成静态文件后部署到 Cloudflare Pages,后端 API 运行在自己的服务器上,通过 Zero Trust Tunnel 暴露到公网,媒体文件存入 R2,并由 Worker 做私有访问网关
这个方案的核心是把入口拆清楚:前端、API、媒体资源分别使用独立域名和独立 Cloudflare 产品承载。这样可以避免直接暴露服务器公网端口,也能让 R2 bucket 保持私有
| 域名 | 角色 | 承载方式 |
|---|---|---|
https://example.com | 前端站点 | Cloudflare Pages |
https://api.example.com | API 服务 | Zero Trust Tunnel 到内网服务 |
https://media.example.com | 私有媒体网关 | Cloudflare Worker + R2 |
架构拆分

前端项目只负责生成静态站点。无论使用 Astro、Vite、Next.js 静态导出,还是其他前端框架,最终只要能输出 dist 或类似目录,就可以交给 Cloudflare Pages 托管
后端项目负责业务接口和数据处理。服务可以运行在 VPS、家用服务器、内网机器或容器平台上,只要本机能监听一个 HTTP 端口,就可以通过 Cloudflare Tunnel 代理出去
媒体文件不直接公开。后端上传文件到 R2 后,只在数据库里保存私有 object key。用户访问媒体时,后端生成短期签名 URL,Worker 校验签名后再读取 R2
请求链路可以简化为:
浏览器
|
| 访问页面
v
Cloudflare Pages: example.com
|
| fetch API
v
Cloudflare Zero Trust Tunnel: api.example.com
|
v
内网后端服务
|
| 生成短期签名媒体 URL
v
Cloudflare Worker: media.example.com
|
| 校验 exp + sig
v
Cloudflare R2 私有 Bucket这个拆分的好处是职责清晰。静态资源走 Pages,API 不需要开放服务器公网端口,R2 也不需要开启公共访问
域名注册
如果只是做个人项目、测试环境或低成本部署,可以先使用免费域名。一个可选来源是 FreeDomain,它提供免费的子域名注册服务。可以通过这个邀请链接注册:FreeDomain 注册入口
域名注册和 Cloudflare 代理是两件事。注册域名只是获得一个可使用的域名;要让 Cloudflare Pages、Workers、Zero Trust Tunnel 和 DNS Proxy 生效,还需要把这个域名接入 Cloudflare
通常流程是:
- 在域名服务中注册可用域名
- 在 Cloudflare 中添加对应 Domain 或 Zone
- 按 Cloudflare 提示配置 nameserver,或在域名服务中把 DNS 记录指向 Cloudflare 要求的目标
- 等待 DNS 生效
- 在 Cloudflare 中配置 Pages、Worker custom domain、Tunnel Public Hostname 或 DNS Records
一旦域名已经由 Cloudflare 托管 DNS,后续很多配置就会自动化。Pages Custom domain、Zero Trust Tunnel Public Hostname、Worker Custom Domain 通常会自动创建或绑定需要的记录,并把流量接入 Cloudflare 网络
如果手动在 DNS -> Records 中添加 A、AAAA 或 CNAME 记录,则需要确认代理状态是橙色云 Proxied。开启橙云后,请求会先进入 Cloudflare,再转发到 Pages、Worker、Tunnel 或源站服务器
灰色云 DNS only 只做 DNS 解析,不经过 Cloudflare 代理层。此时 Cloudflare 的缓存、防护、Worker 路由和隐藏源站 IP 等能力不会完整生效
需要注意,不能只在 Cloudflare 里随便添加一个还没接入的 Domain 就完成代理。Cloudflare 必须能管理这个域名的 DNS,或者至少这个子域名的记录必须按 Cloudflare 的要求正确指向,代理和自动 DNS 创建才会正常工作
DNS Records
Cloudflare 中 DNS Records 的 Add record 不总是需要手动操作。是否自动创建,取决于你使用的是 Pages、Tunnel、Worker custom domain,还是 Worker route
前提是域名已经交给 Cloudflare 托管 DNS。完成托管后,Cloudflare 才能自动创建记录、自动开启代理,或者在 Dashboard 中准确提示你需要手动补哪条记录
Cloudflare Pages 绑定 Custom domain 时,如果域名在同一个 Cloudflare Zone 里,Cloudflare 通常会自动创建或引导创建对应 DNS 记录。你只需要按 Pages 的提示完成绑定和验证
Zero Trust Tunnel 添加 Public Hostname 时,如果域名也在同一个 Cloudflare 账户和 Zone 下,Cloudflare 通常会自动创建一条代理记录,目标类似:
<tunnel-id>.cfargotunnel.com因此 api.example.com -> http://localhost:<backend-port> 这类映射,通常在 Zero Trust 控制台添加 Public Hostname 后就会自动出现在 DNS Records 中
Worker Custom Domain 也通常不需要手动添加普通 DNS 记录。Cloudflare 会把这个 hostname 直接绑定到 Worker
但如果使用 Worker Route,例如:
media.example.com/*就需要确保 media.example.com 已经存在 DNS 记录,并且开启 Cloudflare Proxy。否则 route 没有可匹配的代理流量
可以按下面规则判断:
| 场景 | DNS Record 是否通常自动处理 |
|---|---|
| Pages Custom domain | 通常自动创建或引导创建 |
| Zero Trust Tunnel Public Hostname | 通常自动创建代理 CNAME |
| Worker Custom Domain | 通常自动绑定,不需要手动 Add |
| Worker Route | 通常需要已有 DNS 记录并开启 Proxy |
| 外部 DNS 托管 | 需要按 Cloudflare 提示手动配置 |
如果自动创建失败,或者域名不在当前 Cloudflare Zone 中,就需要手动进入 DNS -> Records -> Add record。常见选择是添加 CNAME,并开启 Proxy
前端部署
先在前端项目中安装依赖并构建:
npm install
npm run build构建完成后通常会生成 dist 目录。使用 Wrangler 部署到 Cloudflare Pages:
npx wrangler pages deploy dist如果是第一次使用 Wrangler,需要先登录 Cloudflare:
npx wrangler login部署时 Wrangler 会提示选择或创建 Pages project。部署完成后,在 Cloudflare Pages 的 Custom domains 中绑定正式前端域名,例如:
example.com前端请求后端时,应使用 API 专用域名,而不是服务器内网地址或裸 IP:
https://api.example.com后端服务
后端服务可以使用 Go、Node.js、Python、Rust 或其他技术栈。部署方式不影响 Cloudflare Tunnel 的基本原理,只要服务在服务器本机监听一个 HTTP 端口即可
典型环境变量如下:
| 变量 | 作用 | 示例 |
|---|---|---|
ALLOWED_ORIGIN | 允许访问 API 的前端来源 | https://example.com |
R2_ACCOUNT_ID | Cloudflare R2 账户 ID | <account-id> |
R2_ACCESS_KEY_ID | R2 Access Key | <access-key> |
R2_SECRET_ACCESS_KEY | R2 Secret Key | <secret-key> |
R2_BUCKET_NAME | R2 Bucket 名称 | app-media |
MEDIA_PUBLIC_BASE_URL | 媒体 Worker 域名 | https://media.example.com |
MEDIA_SIGNING_SECRET | 后端和 Worker 共享的 HMAC 密钥 | <random-secret> |
MEDIA_SIGNING_SECRET 要同时配置在后端服务和 Worker 中。后端用它签名媒体 URL,Worker 用它校验 exp 和 sig
R2 中保存的是私有 object key,例如:
uploads/sha256/<hash>.mp3
images/sha256/<hash>.webp前端拿到的应该是稳定的后端资源地址:
https://api.example.com/api/files/1/download
https://api.example.com/api/files/1/cover浏览器请求这些地址时,后端再重定向到短期有效的 Worker 签名地址:
https://media.example.com/uploads/example.mp3?exp=1717000000&sig=...这种方式可以避免直接暴露 R2 对象地址,也便于在后端统一控制权限、过期时间和访问日志
Zero Trust Tunnel
API 域名通过 Cloudflare Zero Trust Tunnel 指向内网后端服务。这样服务器不需要开放公网端口,只需要运行 cloudflared 主动连接 Cloudflare
映射关系通常是:
api.example.com -> http://localhost:<backend-port>在 Cloudflare Zero Trust 控制台中:
- 进入
Networks->Connectors - 创建或选择一个 Cloudflared Tunnel
- 在 Public Hostname 中添加
api.example.com - Service 指向后端本机监听地址,例如
http://localhost:8080 - 确认 DNS 记录已由 Cloudflare 自动创建并开启代理;如果没有自动创建,就手动添加对应记录
服务器上运行 cloudflared 后,外部访问 https://api.example.com 会进入 Cloudflare,再通过 Tunnel 转发到内网后端服务
如果以后要更换 API 域名,通常只需要修改 Zero Trust 的 Public Hostname、DNS 记录和前端的 API base URL。后端服务仍然监听本机端口,不需要改成公网 IP
Worker 和 R2
Worker 负责做私有媒体网关。它不应该无条件代理整个 R2 bucket,而是要校验签名、过期时间和来源
生成一个后端和 Worker 共享的随机密钥:
openssl rand -hex 32把密钥写入 Worker secret:
npx wrangler secret put MEDIA_SIGNING_SECRET同一个值也要写入后端服务的 MEDIA_SIGNING_SECRET
Worker 的 wrangler.toml 可以这样配置:
name = "media-gateway"
main = "src/index.js"
compatibility_date = "2026-05-30"
workers_dev = false
preview_urls = false
[vars]
MEDIA_ALLOWED_ORIGIN = "https://example.com"
[[r2_buckets]]
binding = "MEDIA_BUCKET"
bucket_name = "app-media"Worker 代码的核心逻辑如下:处理 CORS、限制请求方法、校验签名和过期时间、把 URL path 映射到 R2 object key,然后返回 R2 对象
export default {
async fetch(request, env) {
const allowedOrigin = env.MEDIA_ALLOWED_ORIGIN;
if (request.method === "OPTIONS") {
return cors(null, allowedOrigin);
}
if (request.method !== "GET" && request.method !== "HEAD") {
return cors(new Response("Method Not Allowed", { status: 405 }), allowedOrigin);
}
if (!env.MEDIA_BUCKET || !env.MEDIA_SIGNING_SECRET) {
return cors(new Response("Server configuration error", { status: 500 }), allowedOrigin);
}
const url = new URL(request.url);
const exp = url.searchParams.get("exp");
const sig = url.searchParams.get("sig");
// 签名 URL 必须同时携带过期时间和签名
if (!exp || !sig) {
return cors(new Response("Forbidden", { status: 403 }), allowedOrigin);
}
const expires = Number(exp);
const now = Math.floor(Date.now() / 1000);
// 过期 URL 直接拒绝,避免媒体链接长期有效
if (!Number.isFinite(expires) || now > expires) {
return cors(new Response("Expired", { status: 403 }), allowedOrigin);
}
// 后端和 Worker 使用同一套签名规则:pathname + 换行 + exp
const payload = `${url.pathname}\n${exp}`;
const expected = await hmacHex(env.MEDIA_SIGNING_SECRET, payload);
if (!timingSafeEqual(sig, expected)) {
return cors(new Response("Forbidden", { status: 403 }), allowedOrigin);
}
const key = objectKeyFromPath(url.pathname);
if (!key) {
return cors(new Response("Forbidden", { status: 403 }), allowedOrigin);
}
const object = request.method === "HEAD"
? await env.MEDIA_BUCKET.head(key)
: await env.MEDIA_BUCKET.get(key);
if (!object) {
return cors(new Response("Not found", { status: 404 }), allowedOrigin);
}
const headers = mediaHeaders(object, allowedOrigin);
if (request.method === "HEAD") {
headers.set("content-length", String(object.size));
return new Response(null, { status: 200, headers });
}
return new Response(object.body, { status: 200, headers });
},
};
function objectKeyFromPath(pathname) {
try {
const key = decodeURIComponent(pathname.replace(/^\/+/, ""));
// 防止把 URL path 解析成目录穿越路径
if (!key || key.includes("..") || key.includes("\\")) return "";
return key;
} catch {
return "";
}
}
function mediaHeaders(object, allowedOrigin) {
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set("etag", object.httpEtag);
headers.set("cache-control", "private, max-age=300");
headers.set("access-control-allow-origin", allowedOrigin);
headers.set("access-control-expose-headers", "Content-Length, ETag, Content-Type");
return headers;
}
function cors(response, allowedOrigin) {
const headers = new Headers(response?.headers);
headers.set("access-control-allow-origin", allowedOrigin);
headers.set("access-control-allow-methods", "GET, HEAD, OPTIONS");
headers.set("access-control-allow-headers", "Range, Content-Type");
return new Response(response?.body ?? null, {
status: response?.status ?? 204,
statusText: response?.statusText,
headers,
});
}
async function hmacHex(secret, payload) {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
return [...new Uint8Array(signature)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
function timingSafeEqual(a, b) {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i += 1) {
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return diff === 0;
}如果媒体文件需要支持音频拖动、视频拖动或大文件分段加载,还要继续处理 Range 请求。生产版本通常会读取 Range 头,返回 206 Partial Content,并设置 Content-Range、Content-Length 和 Accept-Ranges
部署 Worker:
npx wrangler deployCloudflare Dashboard 中需要注意:
- 关闭 R2 bucket 的 public development URL
- 不要把媒体域名直接绑定到 R2 custom domain
- 把
media.example.com绑定为 Worker custom domain,或添加media.example.com/*route
Worker custom domain 和 route 选择一种即可。通常小型项目直接使用 Worker custom domain 更直观
CORS 和访问边界
前端、API 和媒体网关应该使用一致的访问边界:
前端来源: https://example.com
API 域名: https://api.example.com
媒体域名: https://media.example.com后端的 ALLOWED_ORIGIN 应设置为前端域名:
ALLOWED_ORIGIN="https://example.com"Worker 的 MEDIA_ALLOWED_ORIGIN 也应设置为同一个前端域名:
MEDIA_ALLOWED_ORIGIN="https://example.com"这样浏览器只能从正式前端站点访问 API 和媒体资源。直接访问 API 或媒体域名时,服务仍应根据请求方法、Origin、Referer、签名和过期时间做校验
部署顺序
建议按下面顺序部署,排查会更简单:
- 在 Cloudflare R2 创建私有 Bucket
- 为后端服务创建 R2 access key
- 生成
MEDIA_SIGNING_SECRET,并同步到后端和 Worker - 部署媒体 Worker,并绑定
media.example.com - 启动后端服务,确认本机接口可访问
- 配置 Zero Trust Tunnel,把
api.example.com指向后端本机端口 - 构建前端静态文件
- 使用
npx wrangler pages deploy dist部署 Cloudflare Pages - 绑定前端域名
example.com - 从正式前端页面测试 API 请求、媒体加载和权限边界
常用命令
前端构建与部署:
npm install
npm run build
npx wrangler pages deploy distWorker 部署:
npm install
npx wrangler secret put MEDIA_SIGNING_SECRET
npx wrangler deploy后端服务启动方式取决于具体技术栈。例如 Go 服务可能是:
go run ./cmd/serverNode.js 服务可能是:
npm install
npm run startCloudflare Tunnel 由 Zero Trust 控制台和服务器上的 cloudflared 共同维护。域名迁移时,优先修改 Public Hostname 和 DNS,避免把服务器真实地址直接暴露出去