Cloudflare 全栈部署流程

本文介绍一种通用的 Cloudflare 全栈部署流程,适合前后端分离项目。前端构建成静态文件后部署到 Cloudflare Pages,后端 API 运行在自己的服务器上,通过 Zero Trust Tunnel 暴露到公网,媒体文件存入 R2,并由 Worker 做私有访问网关

这个方案的核心是把入口拆清楚:前端、API、媒体资源分别使用独立域名和独立 Cloudflare 产品承载。这样可以避免直接暴露服务器公网端口,也能让 R2 bucket 保持私有

域名角色承载方式
https://example.com前端站点Cloudflare Pages
https://api.example.comAPI 服务Zero Trust Tunnel 到内网服务
https://media.example.com私有媒体网关Cloudflare Worker + R2

架构拆分

fbcloudflare

前端项目只负责生成静态站点。无论使用 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

通常流程是:

  1. 在域名服务中注册可用域名
  2. 在 Cloudflare 中添加对应 Domain 或 Zone
  3. 按 Cloudflare 提示配置 nameserver,或在域名服务中把 DNS 记录指向 Cloudflare 要求的目标
  4. 等待 DNS 生效
  5. 在 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 中添加 AAAAACNAME 记录,则需要确认代理状态是橙色云 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_IDCloudflare R2 账户 ID<account-id>
R2_ACCESS_KEY_IDR2 Access Key<access-key>
R2_SECRET_ACCESS_KEYR2 Secret Key<secret-key>
R2_BUCKET_NAMER2 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 用它校验 expsig

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 控制台中:

  1. 进入 Networks -> Connectors
  2. 创建或选择一个 Cloudflared Tunnel
  3. 在 Public Hostname 中添加 api.example.com
  4. Service 指向后端本机监听地址,例如 http://localhost:8080
  5. 确认 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-RangeContent-LengthAccept-Ranges

部署 Worker:

npx wrangler deploy

Cloudflare Dashboard 中需要注意:

  1. 关闭 R2 bucket 的 public development URL
  2. 不要把媒体域名直接绑定到 R2 custom domain
  3. 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 或媒体域名时,服务仍应根据请求方法、OriginReferer、签名和过期时间做校验

部署顺序

建议按下面顺序部署,排查会更简单:

  1. 在 Cloudflare R2 创建私有 Bucket
  2. 为后端服务创建 R2 access key
  3. 生成 MEDIA_SIGNING_SECRET,并同步到后端和 Worker
  4. 部署媒体 Worker,并绑定 media.example.com
  5. 启动后端服务,确认本机接口可访问
  6. 配置 Zero Trust Tunnel,把 api.example.com 指向后端本机端口
  7. 构建前端静态文件
  8. 使用 npx wrangler pages deploy dist 部署 Cloudflare Pages
  9. 绑定前端域名 example.com
  10. 从正式前端页面测试 API 请求、媒体加载和权限边界

常用命令

前端构建与部署:

npm install
npm run build
npx wrangler pages deploy dist

Worker 部署:

npm install
npx wrangler secret put MEDIA_SIGNING_SECRET
npx wrangler deploy

后端服务启动方式取决于具体技术栈。例如 Go 服务可能是:

go run ./cmd/server

Node.js 服务可能是:

npm install
npm run start

Cloudflare Tunnel 由 Zero Trust 控制台和服务器上的 cloudflared 共同维护。域名迁移时,优先修改 Public Hostname 和 DNS,避免把服务器真实地址直接暴露出去