用 Cloudflare R2 搭建个人图床
本文中的域名、桶名、路径和项目名均为占位示例。请替换成自己的值,但不要把真实账户 ID、R2 API Token、Access Key、Secret Key、S3 Endpoint、内部目录或含隐私的原图贴进文章、代码仓库或截图。
个人站点的图片一多,直接把原图放进静态仓库通常不够理想:构建变慢、仓库膨胀,换图还会触发完整部署。我的做法是把原图放进 Cloudflare R2,用一个独立的媒体域名公开读取;页面再按实际布局请求合适尺寸的图片。
它不是“无限制网盘”。对象能从公开域名访问,它就是公开内容。这套流程只保证两件事:公开的图片先经过审核,写入权限和真实凭据留在受控环境。
先看最终结构
下面的名称全部是示例:站点为 site.example.com,媒体域名为 media.example.com,R2 桶名为 site-media。
访问链路可以拆成三层:
浏览器 → 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.webpgallery/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应确认四件事:
- 返回的是图片而不是下载页或错误页。
Content-Type与文件一致,例如image/webp。- HTTPS 证书正常,没有混合内容。
- URL 中没有账户 ID、临时签名、访问密钥或本机目录。
不需要让网页通过 fetch() 读取图片时,不必为了 <img> 标签额外开放宽泛的 CORS。只有图片确实要被跨域脚本或 canvas 读取时,才按实际站点域名配置最小 CORS 规则,避免直接使用 *。
上传:先用低风险方式跑通
图片不多时,直接用 R2 控制台上传最稳妥。它适合先验证对象路径、文件类型和公开访问是否正确。
已经使用 Wrangler 的项目可以通过浏览器登录授权后上传。以下命令不包含任何密钥,桶名和本地文件均为示例:
pnpm dlx wrangler loginpnpm 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="春日散步时拍下的树影"/>这里有几个容易忽略的点:
width与height填原图真实尺寸,浏览器才能在图片加载前预留位置,避免 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、真实width与height。 -
srcset和sizes与实际响应式布局相符,没有无意义的超大档位。 - 浏览器网络面板中,首屏和列表图片请求的是预期尺寸。
- CSP 仅放行实际用到的图片来源。
- 上传密钥不在仓库、文章、截图、前端环境变量或公开构建产物中。
常见问题
图片 404
先确认对象键大小写与 R2 中完全一致(R2 区分大小写);再检查自定义域是否绑定到正确的 bucket,以及文件是不是传到了另一个环境的 bucket。
图片能打开但页面不显示
检查浏览器控制台和网络面板:常见原因是 CSP 没有允许媒体域名、页面把 URL 拼成了错误的路径,或 Content-Type 与实际文件不一致。
替换图片后仍显示旧版本
这是缓存策略与可变 URL 冲突。优先换用新的对象键;如果必须覆盖旧键,再对对应 URL 清缓存,并用无痕窗口或带版本查询参数的测试请求进行确认。
上传 Token 泄露了怎么办
立即撤销或轮换该 Token,再检查它的权限、有效期和使用范围。随后从仓库、日志、截图和协作平台中移除泄露内容;如果已经提交到 Git 历史,应按团队的秘密泄露流程处理,而不是只删除当前文件。
小结
R2 图床真正需要维护的不是“能上传一张图”,而是几条约定:对象键怎么命名、什么内容允许公开、媒体域名指向哪里、页面下载多大尺寸、凭据放在哪里。这些约定定下来之后,加图、换图、迁移都只是照规则执行。公开 URL 稳定,页面按需加载,凭据也不会跟着教程和代码流传出去。