跳到主内容

LYRA

Lyra 正在加载

用 Cloudflare R2 搭建个人图床

站点运维鬼鬼

本文中的域名、桶名、路径和项目名均为占位示例。请替换成自己的值,但不要把真实账户 ID、R2 API Token、Access Key、Secret Key、S3 Endpoint、内部目录或含隐私的原图贴进文章、代码仓库或截图。

个人站点的图片一多,直接把原图放进静态仓库通常不够理想:构建变慢、仓库膨胀,换图还会触发完整部署。我的做法是把原图放进 Cloudflare R2,用一个独立的媒体域名公开读取;页面再按实际布局请求合适尺寸的图片。

它不是“无限制网盘”。对象能从公开域名访问,它就是公开内容。这套流程只保证两件事:公开的图片先经过审核,写入权限和真实凭据留在受控环境。

先看最终结构

下面的名称全部是示例:站点为 site.example.com,媒体域名为 media.example.com,R2 桶名为 site-media

浏览器经站点 CDN 和媒体域名读取 R2 对象;上传凭据只位于受控环境的架构图

访问链路可以拆成三层:

浏览器
→ https://site.example.com/cdn-cgi/image/.../https://media.example.com/...
→ Cloudflare 缓存与可选图片转换
→ media.example.com(R2 自定义域)
→ site-media(R2 存储桶)

上传链路完全分开:写入只发生在本地命令行或 CI,浏览器拿不到也不应拿到这类凭据。

设计对象路径:先定规则,再上传

R2 没有传统目录,控制台里看到的“文件夹”只是对象键的前缀。路径一旦被引用就很难改,所以先定一个稳定、无隐私的命名规则:

gallery/{album-slug}/original/{file-name}

例如:

gallery/spring-walk/original/photo-01.webp
gallery/spring-walk/original/photo-02.webp

约束可以保持简单:

  • album-slug 只用小写字母、数字和连字符。
  • 文件名使用无空格的英文小写与连字符,例如 photo-01.webp
  • 原图和展示图的职责分开;不要把 cover-final-new-2.jpg 这类临时名称变成长期 URL。
  • 同一文件需要替换时,优先使用新文件名或版本号。这样能避开旧 CDN 缓存,也方便回滚。

如果照片含 GPS、设备序列号、拍摄时间或其他 EXIF 信息,先在本地清理或确认保留范围。尤其是住址、学校、工作场所附近的照片,发布前应单独复核。

从命名、清理元数据到上传、验证和页面引用的五步流程

创建 R2 桶并绑定媒体域名

在 Cloudflare 控制台中创建一个 R2 bucket,例如 site-media。把桶设计成只存放可以公开的站点媒体;备份、私密原片、证件扫描件和其他敏感文件应放在另一个不公开的存储位置。

费用方面,截至 2026 年 8 月,R2 每月免费额度为 10 GB 存储、100 万次写入、1000 万次读取,无出口流量费,个人图床基本用不完。额度可能调整,以官方定价页为准。

接着在该 bucket 的公开访问设置中绑定自定义域名,例如:

media.example.com

自定义域名比临时开发地址更适合作为长期入口:URL 可控、迁移路径清晰,也便于之后统一配置缓存和 CSP。绑定完成后,先用一张不含隐私的测试图验证:

https://media.example.com/gallery/spring-walk/original/photo-01.webp

应确认四件事:

  1. 返回的是图片而不是下载页或错误页。
  2. Content-Type 与文件一致,例如 image/webp
  3. HTTPS 证书正常,没有混合内容。
  4. URL 中没有账户 ID、临时签名、访问密钥或本机目录。

不需要让网页通过 fetch() 读取图片时,不必为了 <img> 标签额外开放宽泛的 CORS。只有图片确实要被跨域脚本或 canvas 读取时,才按实际站点域名配置最小 CORS 规则,避免直接使用 *

上传:先用低风险方式跑通

图片不多时,直接用 R2 控制台上传最稳妥。它适合先验证对象路径、文件类型和公开访问是否正确。

已经使用 Wrangler 的项目可以通过浏览器登录授权后上传。以下命令不包含任何密钥,桶名和本地文件均为示例:

Terminal window
pnpm dlx wrangler login
pnpm dlx wrangler r2 object put site-media/gallery/spring-walk/original/photo-01.webp --file ./photo-01.webp --content-type image/webp

批量上传前,先对一张测试图完成“上传 → 自定义域访问 → 页面显示”的闭环。wrangler login 授权的是整个账户,临时上传可以;写进脚本或长期任务就改用只带 R2 读写权限的 API Token。若以后改用 S3 兼容工具或 CI,把 Access Key、Secret Key 和 endpoint 放在平台受控变量或本机凭据存储中,并且:

  • Token 只授予目标 bucket 所需的读写范围。
  • 设定合理的有效期,迁移或临时任务结束后撤销。
  • 不把 .env、终端历史、构建日志和含凭据的配置文件提交进 Git。
  • 截图前检查地址栏、网络面板和命令输出,避免把账户信息一起截进去。

在代码中统一派生图片 URL

不要在 Markdown、组件和数据文件里到处拼接媒体地址。把域名和对象键的校验集中到一个模块,以后换域名或改编码规则只需要改一处。

下面是一个精简的 TypeScript 思路。它只接受约定格式的对象键,拒绝完整 URL、路径穿越和任意查询字符串:

const mediaOrigin = new URL('https://media.example.com/');
const imageKeyPattern = /^gallery\/[a-z0-9]+(?:-[a-z0-9]+)*\/original\/[a-z0-9]+(?:-[a-z0-9]+)*\.(?:avif|jpe?g|png|webp)$/;
export const getPublicImageUrl = (objectKey: string): string => {
if (!imageKeyPattern.test(objectKey)) {
throw new Error(`[media] 非法图片对象键:${objectKey}`);
}
return new URL(objectKey, mediaOrigin).toString();
};

业务数据只保存验证过的 slug、文件名和宽高,完整 URL 由这个函数派生。数据层不会意外接收外部 URL,错误也能在构建期尽早暴露。

让页面只下载需要的尺寸

原图适合存档,不适合所有展示位置直接下载。卡片里只有约 320px 宽时,让浏览器下载 4000px 原图既浪费流量,也拖慢首屏。

如果站点启用了 Cloudflare 图片转换,可按布局提供少量真实会被选中的档位:

---
const originalUrl = getPublicImageUrl('gallery/spring-walk/original/photo-01.webp');
const imageUrl = (width: number) =>
`https://site.example.com/cdn-cgi/image/width=${width},quality=80,format=auto/${originalUrl}`;
---
<img
src={imageUrl(640)}
srcset={`${imageUrl(320)} 320w, ${imageUrl(640)} 640w, ${imageUrl(960)} 960w`}
sizes="(max-width: 767px) 50vw, 33vw"
width="1920"
height="1280"
loading="lazy"
alt="春日散步时拍下的树影"
/>

这里有几个容易忽略的点:

  • widthheight 填原图真实尺寸,浏览器才能在图片加载前预留位置,避免 CLS。
  • sizes 必须匹配实际布局;它告诉浏览器当前槽位多宽,而不是装饰性文字。
  • 宽度档位不必覆盖原图最大尺寸,除非页面确实有相应的大图展示需求。
  • 首屏主视觉通常不应使用 loading="lazy";普通长列表图片才适合懒加载。
  • 截至 2026 年 8 月,/cdn-cgi/image/ 已包含在免费套餐内:每月 5000 次独立转换免费(同图同参数算一次),超出后新转换返回 9422 错误,已缓存的不受影响;更多需 Images 付费套餐,每千次 $0.50。额度可能再调整,以官方文档为准。不想用时,直接用自定义域名原图即可。

无论最后是否启用转换,原则都一样:用真实布局决定档位,用真实请求验证浏览器最终选中的档位。

缓存、CSP 与替换策略

对于文件名不会变化的媒体,可以设置较长缓存;例如静态图片常用:

Cache-Control: public, max-age=31536000, immutable

这项策略要求 URL 真正不可变。若把新图覆盖到同一个对象键,访问者可能继续看到旧缓存;更可靠的方式是发布 photo-01-v2.webp,随后更新数据和引用。确实要覆盖时,应在发布后按实际范围清理 CDN 缓存并复测。

如果页面直接使用媒体域名原图,CSP 至少需要允许该来源:

img-src 'self' https://media.example.com

若页面只通过自身的图片转换入口访问,浏览器看到的请求可能是站点同源;但原图链接、结构化数据或其他组件仍可能需要媒体域名。应根据最终生成的 HTML 和浏览器网络请求来收紧 CSP,而不是盲目复制别人的规则。

一份上线前检查清单

  • bucket 内只保留允许公开的对象;私密文件不与公开图混放。
  • 自定义媒体域名可以通过 HTTPS 直接打开测试图片。
  • 对象键、域名和路径不包含账号、Token、签名或本机路径。
  • 图片已检查 EXIF、GPS 和人像授权范围。
  • 每张页面图片都有合适的 alt、真实 widthheight
  • srcsetsizes 与实际响应式布局相符,没有无意义的超大档位。
  • 浏览器网络面板中,首屏和列表图片请求的是预期尺寸。
  • CSP 仅放行实际用到的图片来源。
  • 上传密钥不在仓库、文章、截图、前端环境变量或公开构建产物中。

常见问题

图片 404

先确认对象键大小写与 R2 中完全一致(R2 区分大小写);再检查自定义域是否绑定到正确的 bucket,以及文件是不是传到了另一个环境的 bucket。

图片能打开但页面不显示

检查浏览器控制台和网络面板:常见原因是 CSP 没有允许媒体域名、页面把 URL 拼成了错误的路径,或 Content-Type 与实际文件不一致。

替换图片后仍显示旧版本

这是缓存策略与可变 URL 冲突。优先换用新的对象键;如果必须覆盖旧键,再对对应 URL 清缓存,并用无痕窗口或带版本查询参数的测试请求进行确认。

上传 Token 泄露了怎么办

立即撤销或轮换该 Token,再检查它的权限、有效期和使用范围。随后从仓库、日志、截图和协作平台中移除泄露内容;如果已经提交到 Git 历史,应按团队的秘密泄露流程处理,而不是只删除当前文件。

小结

R2 图床真正需要维护的不是“能上传一张图”,而是几条约定:对象键怎么命名、什么内容允许公开、媒体域名指向哪里、页面下载多大尺寸、凭据放在哪里。这些约定定下来之后,加图、换图、迁移都只是照规则执行。公开 URL 稳定,页面按需加载,凭据也不会跟着教程和代码流传出去。

版权许可

本文采用 CC BY-SA 4.0 协议授权

保留署名与相同方式共享即可转载、引用或再创作。转载时请注明出处并附上本文链接。

评论

留言系统基于 Disqus,发言前可查看 评论规则。部分网络环境可能无法正常加载。