零成本自建图床:Cloudflare Pages + D1 + Telegram + R2 完整实战

零成本自建图床:Cloudflare Pages + D1 + Telegram + R2 完整实战
Mote写博客的人都绕不开一个问题:图片存哪。
用第三方图床,怕运营方跑路、怕忽然收费限速;把图片直接提交进 Git 仓库,几十篇文章下来仓库能到几百 MB,克隆一次等半天。而自己买服务器跑图床,为了几张截图付一整台 VPS,怎么算都不划算。
这篇记录一套我实际跑通的方案:Cloudflare Pages 托管 + D1 存元数据 + Telegram 存文件 + R2 兜底,全程只用免费额度,不买服务器、不用备案、不付流量费。
视频版在这里,跟着操作更直观:https://youtu.be/sxYl6IyOlC4
下面把完整的项目地址、全部初始化 SQL、每一处界面路径和踩过的坑都写清楚,照着做就能复刻。
零、先说项目地址
本文用的开源项目是 CloudFlare ImgBed,MIT 协议,本文写作时版本 v2.7.8,约 6.8k star。
| 项目 | 地址 |
|---|---|
| GitHub 仓库 | https://github.com/MarSeventh/CloudFlare-ImgBed |
| 官方文档 | https://cfbed.sanyue.de/ |
| 初始化 SQL 原始文件 | https://github.com/MarSeventh/CloudFlare-ImgBed/blob/main/database/init.sql |
它有四个关键特性,决定了它值得折腾:
- 元数据与文件解耦 —— 数据库只存 file_id、路径、标签,真实文件写进外部存储渠道
- 支持六种存储后端 —— Telegram、Cloudflare R2、S3 兼容、Discord、Hugging Face、WebDAV
- 同构内核 —— 同一套代码可跑在 Pages、Workers、Docker 三种运行时
- 多渠道负载均衡 + 失败自动切换
下面所有配置都以 v2.7.8 为准。版本差异主要在后台菜单位置,参数名基本稳定。
一、为什么自建图床
理想方案得同时满足四个条件:容量足够大、长期不花钱、链接稳定、能自己管。
主流选择其实就三个方向:
| 方案 | 免费额度 | 主要短板 |
|---|---|---|
| Cloudflare R2 | 10 GB 存储,出网全免费 | 需要绑卡,容量是硬上限 |
| Telegram 频道 | 容量近似无限 | 单文件 20 MB,无 SLA,条款灰色 |
| 大厂对象存储 | 新用户试用额度 | 试用期结束按量计费,出网费贵 |
单独用任何一个都有明显缺陷。但这三个的短板恰好能互补——Telegram 出容量,R2 出稳定性,Cloudflare 出访问入口。
二、架构:四层各司其职
1 | 浏览器 / PicGo |
关键设计是元数据与文件解耦:数据库里只存「谁、什么时候、存在哪个渠道的哪个 ID」,真实文件躺在 Telegram 或 R2 里。
两个直接好处:
- 换存储后端不影响已有链接 —— 链接永远是
你的域名/file/xxx,跟后端无关 - 可配多渠道,上传失败自动切换
一个必须提前说清的认知:这套方案的「兜底」发生在上传时,不是读取时,也不做双写。
- ✅ Telegram 上传失败(超 20 MB、限流、抖动)时自动重试并落到 R2
- ❌ 已存进 Telegram 的图不会自动复制到 R2,Telegram 出问题时这部分仍然会挂
理解这一点,后面的配置才有意义。
三、免费额度到底有多少
先把账算清楚,这是整套方案成不成立的前提。
| 资源 | 免费额度 | 折算成实际用量 |
|---|---|---|
| Telegram 频道 | 无官方配额 | 按 300 KB 一张,几十万张量级 |
| Telegram 单文件 | 20 MB(Bot API 下载上限) | 超出自动分片 |
| R2 存储 | 10 GB-month / 月 | 约 3.5 万张 300 KB 图 |
| R2 出网流量 | 无限免费 | 相对 S3 最大的优势 |
| R2 读操作(B 类) | 1000 万次 / 月 | 约 33 万次/天 |
| R2 写操作(A 类) | 100 万次 / 月 | 约 3.3 万次/天 |
| Pages Functions 请求 | 10 万次 / 天 | 未命中缓存时有效 |
| Pages Functions CPU | 10 ms / 次 | 图片处理要注意 |
| D1 存储 | 500 MB / 库 | 元数据每条几百字节,够存几十万条 |
| D1 写入 | 10 万行 / 天 | 每张图 1–2 行 |
| KV(不推荐) | 读 10 万 / 日、写 1000 / 日 | 写入是硬伤 |
结论很清楚:存储不是瓶颈,请求才是。图片一旦被 CDN 缓存命中,就不再消耗 Functions 额度——所以缓存配置得好不好,直接决定这套图床能扛多少访问。
四、前置准备
4.1 账号清单
- Cloudflare 账号,且已绑定支付方式(R2 必须绑卡才能开通,绑卡不等于收费)
- 一个已托管在 Cloudflare 的域名(NS 指向 CF,状态 Active)——图片尺寸处理依赖它,
pages.dev不支持 - GitHub 账号
- Telegram 账号
4.2 Telegram:拿 Bot Token 和 Chat ID
第一步,建 Bot
- Telegram 搜索
@BotFather(认准蓝色勾) - 发送
/newbot - 输入 Bot 显示名(随意,如
MyImgBed) - 输入用户名(必须以
bot结尾,全局唯一,如myimgbed_2026_bot) - 它会回复一段:
1 | Use this token to access the HTTP API: |
那串 数字:字母 就是 Bot Token,只显示一次,立刻存好。
⚠️ Token 等于 Bot 的完全控制权。如果截图发过群、贴过论坛,去 BotFather 发
/revoke换一个,旧 Token 立即作废。
第二步,建私有频道
- Telegram → 新建消息 → 新建频道
- 名称随意,如
图床存储 - 关键:选「私密频道 / Private Channel」,不要选公开
- 建好后进频道 → 点频道名 → 管理员 → 添加管理员 → 搜索你刚建的 Bot 用户名 → 添加
- 权限开关里把 「管理消息 / Manage Messages」打开
Bot 没有这个权限,上传会报
not enough rights,这个报错不会告诉你是权限问题。
第三步,拿 Chat ID
- 在频道里发一条测试消息(打个
test) - 长按该消息 → 转发 → 转发给
@VersaToolsBot - 它返回一串形如
-1001234567890的数字
前面的负号必须保留,少一个负号后面必然失败。
4.3 R2:建桶
- Dashboard → 左侧 R2 对象存储 → 首次进入需同意条款并启用
- 创建存储桶
- Bucket name 全局唯一,如
imgbed-mote2026 - Location 选
Automatic - Default storage class 必须是
Standard
⚠️ 千万别选 Infrequent Access —— 它不享受 10 GB 免费额度,还会收数据取回费。
公开访问(Public access)先不用开。r2.dev 那个默认域名有速率限制,正式用要绑自定义域名,等最后一起处理。
五、Pages 部署
5.1 Fork 仓库
打开 https://github.com/MarSeventh/CloudFlare-ImgBed → 右上角 Fork → Create fork → 分支保持 main。
5.2 创建 Pages 项目
- Cloudflare Dashboard → 计算和 AI(Compute) → Workers & Pages
- 创建 → 页面下拉找到
Looking to deploy Pages?→ Get started - 选 导入现有 Git 存储库
- 首次会让你授权 GitHub,选「Only select repositories」,只授权 fork 的那个仓库
5.3 构建配置(这四项决定成败)
| 配置项 | 必须填 |
|---|---|
| 项目名称 | cloudflare-imgbed(随意) |
| 生产分支 | main |
| 构建命令 | npm install |
| 构建输出目录 | frontend-dist |
| 根目录(Root directory) | 留空 |
坑位一:构建命令不是 npm run build
项目从 v2.0 起改了构建方式。更要命的是,这个仓库根目录的 package.json 里没有任何 dependencies,依赖全部放在 workspace 子包 deploy/profiles/common 里,靠 "workspaces": ["deploy/profiles/*"] 提升上来。
所以只要 npm install 没有真正执行,node_modules 就是空的,Pages 打包 Functions 时会一口气报十个错:
1 | ERROR Could not resolve "@aws-sdk/client-s3" |
aws-sdk 只是第一个被打印出来的。看到这个错,不要去装某个具体的包,回去检查构建命令。
另一个判断依据是时间:从开始构建到报错只用了 7 秒。真正跑完一次 npm install 不可能这么快,说明安装环节被整个跳过了。
三种常见错法,表现完全一样:
- 构建命令留空(Pages 不会替你装 workspace 依赖)
- 填
npm ci(没有 lockfile,直接失败) - 填
npm run build(这仓库根本没有 build 脚本)
补一个保险措施:设置 → 变量和机密 → 添加 NODE_VERSION=22。因为 npm 7 以前不认识 workspaces 字段,会把子包依赖全部忽略,报错一模一样。
5.4 首次部署与预期现象
点 保存并部署,等 2–3 分钟,会给你一个 xxx.pages.dev 域名。
这时候打开它大概率是白页或报错——这是正常的,数据库还没绑。环境变量一个都不用填,v2.0 之后所有业务配置都在后台。
📌 注意:构建成功后会给你两个域名,形如
97b78886.项目名.pages.dev和项目名.pages.dev。前面带哈希的是单次部署的预览域名,每次重新部署都会变。以后一律用不带哈希的生产域名,否则某次部署后旧链接就 404 了。
六、D1 数据库(附完整 SQL)
6.1 建库
Dashboard → 存储和数据库 → D1 SQL 数据库 → 创建数据库 → 名称填 img_d1 → 创建。
6.2 初始化表结构
点进 img_d1 → 上方切 控制台(Console) → 粘贴 SQL → 执行。
坑位二:整段粘贴会提示 No SQL statements detected.
原因是控制台的输入焦点问题——内容看着在框里,但点击执行时读的是空内容。另外从某些编辑器复制时还会丢掉换行,整段挤成一行,一旦某条语句语法错误就很难定位。
**解决办法:拆成三段,按顺序各执行一次。**下面是可以直接复制的完整内容。
第一段:5 张表
1 | CREATE TABLE IF NOT EXISTS files ( |
第二段:12 个索引
1 | CREATE INDEX IF NOT EXISTS idx_files_timestamp ON files(timestamp DESC); |
第三段:4 个触发器
1 | CREATE TRIGGER IF NOT EXISTS update_files_updated_at |
全部带 IF NOT EXISTS,重复执行安全,缺表就再跑一遍。
执行时的三个要点:
- 先单击编辑框,看到光标在里面,再
Ctrl+A→Ctrl+V,确认内容真的在框里 - 用
Ctrl+Enter直接执行,比点按钮更可靠 - 每段成功的标志是出现
Executed N statements之类的提示
验证表是否建好
切到 表(Tables) 标签,或者执行这条查询:
1 | SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name; |
应该返回 5 行:
1 | files |
(可能额外多一个 _cf_KV,那是 Cloudflare 自带的内部表,每个 D1 都有,不影响使用。)
命令行方案(控制台始终不认时的备选)
1 | npx wrangler d1 execute img_d1 --remote --file=./init.sql |
首次会要浏览器登录授权 Cloudflare 账号,之后一条命令跑完全部建表。
6.3 为什么必须用 D1,不用 KV
这是硬性依赖,不能省。图片实体存在 Telegram / R2 里,但文件名、路径、file_id、标签、上传时间这些信息必须存在数据库里。没有数据库,Telegram 返回的 file_id 无处保存,下次访问就找不到图,上传接口直接报 500。
选哪个数据库是可以选的:
| 选项 | 结论 |
|---|---|
| KV | 能用,但每天只有 1000 次写入(等于每天最多传 1000 张),且不支持大文件上传 |
| D1 | 推荐,免费 500 MB,支持大文件,性能略差但对图床无感 |
6.4 绑定(变量名写死,不能错)
Pages 项目 → 设置(Settings) → 绑定(Bindings) → 添加 → 选 D1 数据库:
| 字段 | 值 |
|---|---|
| 变量名称 | img_d1 |
| D1 数据库 | img_d1 |
坑位三:绑定变量名是代码里写死的
| 绑定类型 | 变量名 | 说明 |
|---|---|---|
| D1 数据库 | img_d1 |
必填 |
| R2 存储桶 | img_r2 |
必填 |
| KV 命名空间 | img_url |
用 D1 时不需要 |
写错不会报”变量名不正确”,而是应用启动失败或后台进不去,报错信息毫无指向性。这三个名字建议直接抄。
6.5 必须手动「重试部署」
绑定本身不会触发构建。改完设置回 部署(Deployments) → 最新那条 → ... → 重试部署,等 1–2 分钟。
不重试,代码里读不到新绑定。
七、绑定 R2
Pages 项目 → 设置 → 绑定 → 添加 → R2 存储桶:
| 字段 | 值 |
|---|---|
| 变量名称 | img_r2 |
| R2 存储桶 | imgbed-mote2026(你的桶名) |
保存 → 回 部署 → 重试部署。
绑成功后回后台 系统设置 → 上传设置,应该能看到 Cloudflare R2 这一栏自动出现(显示为 R2_env),说明变量名读对了。
八、后台配置
访问 https://你的域名/dashboard(老版本是 /systemConfig),管理端默认免密。
8.1 第一件事:设密码
现在任何人知道域名就能进后台删你的图。立刻去 系统设置 → 安全设置 → 认证管理,设两套:
| 类型 | 用途 |
|---|---|
| 管理端认证(用户名 + 密码) | 登录 /dashboard 后台 |
| 用户端认证(上传认证码) | 登录 /login、PicGo 和 API 调用 |
这两套是独立的,别搞混:管理员密码管后台,上传认证码管传图。
8.2 配 Telegram 渠道
系统设置 → 上传设置 → Telegram 渠道 → 添加渠道:
| 字段 | 填什么 |
|---|---|
| 渠道名称 | TG-主渠道 |
| Bot Token | 第 4.2 步拿到的 Token |
| Chat ID | -100...(负号保留) |
| 代理 URL | 留空(CF 边缘通常能直连) |
| 启用状态 | 打开 |
8.3 配 R2 渠道
在 Cloudflare R2 区域点 R2_env 卡片上的 编辑:
| 字段 | 值 |
|---|---|
| 容量上限 | 9.5(GB) |
| 公开访问链接 | 留空(不开图片审查就不需要) |
这个上限一定要设。 不设就是无限额开放,某天误传一堆大图就开始计费了。设 9.5 而不是 10,是留 5% 余量——计费按 GB-month 计算,卡边容易踩线。
8.4 设默认渠道
系统设置 → 网页设置 → 客户端设置:
| 字段 | 选 |
|---|---|
| 默认渠道类型 | Telegram |
| 默认渠道名称 | TG-主渠道 |
8.5 负载均衡保持关闭
R2 卡片和 S3 卡片右上角都有 负载均衡 开关,保持关闭。
理由:兜底渠道一旦参与日常分流,就不叫兜底了——R2 那 10 GB 会被日常小图慢慢吃掉。
九、验证:三个必测项
部署完不能只看页面能打开,要验证三条链路。
9.1 上传落点
新开标签打开 https://你的域名/ → 用上传认证码登录 → 传一张小图。
两个验证点:
- 文件卡片渠道显示 Telegram
- 打开 Telegram 私有频道,能看到这张图
第二点是最硬的验证 —— 它同时证明 Bot 有发消息权限、Chat ID 正确、Token 有效。三样错任何一个,上传都会失败并提示 chat not found 或 not enough rights。
9.2 R2 通道本身可用
上传时把渠道下拉手动切到 Cloudflare R2,传一张。
三个验证点:
- 卡片渠道显示 R2
- 点图能打开,复制链接能访问
- CF 控制台 → R2 → 桶 → 对象列表出现该文件,路径形如
2026/10/29/xxx.png
9.3 兜底是否真的兜底
把 TG-主渠道 卡片右上角的开关关掉 → 回上传页,渠道保持默认(Telegram)→ 传一张。
- 显示成功且落在 R2 → 兜底生效
- 报错「无可用渠道」→ 说明这个版本只在单次上传失败时切换,默认渠道被禁用不会改道
测完立刻把 TG 开关打开。
⚠️ 这两张测试图会永久留在 R2(不做双写)。10 GB 额度里这点体积无所谓,想保持干净测完删掉即可。
十、缓存:省额度的关键
图片链接形如 /file/xxx.png,由边缘函数返回。默认每次访问都要跑一次函数,消耗每日 10 万次的额度。
10.1 三类请求的默认行为
| 路径 | 谁处理 | 默认缓存 |
|---|---|---|
/、/dashboard、JS/CSS |
Pages 静态托管 | ✅ 自动缓存,命中率极高 |
/file/xxx.png |
Pages Functions | ⚠️ 取决于源站 Cache-Control |
/upload、/api/*、/login |
Functions | ❌ 不该缓存 |
命中缓存 = 不触发 Function = 不消耗每日额度。
10.2 加一条 Cache Rule
CF Dashboard → 选中域名 → 缓存 → Cache Rules → 创建规则:
| 项 | 值 |
|---|---|
| 规则名称 | cache-images |
| 匹配条件 | URI Path 开头是 /file/ |
| 缓存资格 | 符合条件(Eligible for cache) |
| Edge TTL | 忽略源站,设为 1 年 |
| Browser TTL | 1 天 |
表达式写法:
1 | starts_with(http.request.uri.path, "/file/") |
别对 /api/* 和 /upload 加任何缓存规则,否则会出现”传了图但列表不更新”这种诡异问题。
10.3 验证缓存是否命中
打开任意一张图,F12 → Network → 看响应头 cf-cache-status,刷新两次:
| 第一次 | 第二次 | 结论 |
|---|---|---|
MISS |
HIT |
✅ 规则生效 |
DYNAMIC |
DYNAMIC |
❌ 规则没匹配上 |
MISS |
MISS |
⚠️ 匹配上了但没存住,源站返回了 no-store 或 Set-Cookie |
十一、缓存的副作用:删图不生效
这是唯一让我不适的地方。图删了,但浏览器和 CDN 还存着旧副本,链接照样打开。
三种处理方式:
| 方案 | 操作 | 评价 |
|---|---|---|
| 配 API 凭据自动清 | 后台填 CF_ZONE_ID + CF_EMAIL + CF_API_KEY |
最省事,但要交出全局密钥 |
| 手动清 | CF → 缓存 → 清除缓存 → 输 URL | 多一步,零风险 |
| 等自然过期 | 什么都不做 | 接受延迟 |
如果想手动清,可以用这个 curl:
1 | curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \ |
我选了手动清。 理由:项目要求填的是 CF_API_KEY,也就是全局 API 密钥,权限等于整个 Cloudflare 账号——能改 DNS、能删域名、能看所有账单。个人图床删图频率不高,为省这一步交出一个全账号密钥,风险收益不划算。真要上方案 1,务必定期轮换。
十二、接 PicGo
PicGo 走图床自己的 /upload 接口,这样渠道分配和数据库记录都保留。
12.1 装插件
PicGo → 插件设置 → 搜索安装 picgo-plugin-web-uploader。
12.2 配置图床
PicGo → 图床设置 → Web图床:
| 字段 | 值 |
|---|---|
| API 地址 | 见下方 |
| POST 参数名 | file |
| 自定义 Body | 留空 |
| 图片键名 | 0.src |
API 地址(一行到底,不要换行):
1 | https://img.你的域名.com/upload?authCode=你的上传认证码&uploadChannel=telegram&returnFormat=full |
三个参数的作用:
authCode—— 上传认证码uploadChannel=telegram—— 固定走 TG;想临时存 R2 改成cfr2returnFormat=full—— 必须加,否则返回/file/xxx相对路径,粘到文章里就是死链
12.3 两个容易出错的地方
图片键名必须是
0.src接口返回的是数组
[{"src": "...", "publicUrl": "..."}],不是对象。写成src或data.src都会取不到值,PicGo 提示”上传成功但无法获取图片地址”。域名用自定义域名,不要用
pages.dev以后换域名,所有历史链接不会跟着变。
12.4 排错对照
| 现象 | 原因 |
|---|---|
401 / 认证失败 |
authCode 错,或用户端认证码没保存成功 |
上传成功但链接是 /file/xxx |
returnFormat=full 漏了 |
| 无法获取图片地址 | 图片键名不是 0.src |
chat not found |
Chat ID 错,或 Bot 不在频道里 |
not enough rights |
Bot 不是管理员,或没开「管理消息」权限 |
| 超 20 MB 报错 | 正常,TG 单文件上限;PicGo 里开「上传前压缩」 |
十三、自定义域名
用子域名,别占主站:
- Pages 项目 → 自定义域 → 设置自定义域 → 输入
img.你的域名.com - 同一 CF 账号下会自动加 CNAME,不用手动配 DNS
- 等状态从「正在初始化」变 Active,证书自动签发
然后做两件容易漏的事:
① 把新域名加进白名单
后台 → 安全设置 → 访问管理 → 放行域名:
1 | cloudflare-imgbed-moteblog.pages.dev,img.你的域名.com |
两个都要留 —— 旧的 pages.dev 还在用,删了会导致旧链接全部 403。英文逗号分隔,别加空格。
② 让新链接默认走新域名
系统设置 → 网页设置 → 全局设置 → 默认 URL 前缀:
1 | https://img.你的域名.com/file/ |
已传过的图链接不会变,仍然走旧域名,但内容一样能访问。
可选:CF Dashboard → 选中域名 → Images → 转换 → 启用,即可支持 ?width=800&fit=cover 这类参数。有免费额度,超出按次计费,不开也完全不影响使用。
十四、安全清单
部署完这六项逐条过一遍:
- 管理端账号密码(默认无密码,任何人可进)
- 上传认证码
- 关闭远端遥测 —— 默认开启,会上报你的域名和访问链接给项目开发者
- 开启域名过滤,只放行自己的域名(防盗链)
- 建一个只含
upload权限的 API Token,设 90 天过期 + 过期自动删除 - 白名单模式保持关闭 —— 它是应急手段(被大规模盗链时临时开),日常用会非常麻烦
API Token 创建后只显示一次,弹窗关掉就再也看不到。点完立刻复制到密码管理器。
十五、常见报错速查
| 报错 / 现象 | 原因 | 解法 |
|---|---|---|
Could not resolve "@aws-sdk/client-s3" |
依赖没装上 | 构建命令改 npm install,加 NODE_VERSION=22 |
| 部署成功但访问 404 / 白页 | 输出目录错 | 必须是 frontend-dist |
No SQL statements detected. |
控制台输入焦点问题 | 拆三段执行,或 Ctrl+Enter |
| 后台进不去 / 500 | D1 表没建全,或绑定名写错 | 查 5 张表,核对 img_d1 |
| 绑定后不生效 | 没重试部署 | 部署页手动重试 |
/file/* 一直是 DYNAMIC |
Cache Rule 没匹配 | 检查表达式与规则启用状态 |
| 删图后链接还能打开 | CDN 缓存 | 手动清缓存 |
上传 chat not found |
Chat ID 错 | 重新用 @VersaToolsBot 取,负号保留 |
上传 not enough rights |
Bot 权限不足 | 设为管理员并开「管理消息」 |
| R2 开始计费 | 没设容量上限 / 选了 IA 存储类 | 设 9.5 GB,确认是 Standard |
十六、这套方案适合谁
适合:个人博客、笔记站、文档站,图片以中小尺寸截图为主,日访问量在几万次以内,愿意花 40 分钟做一次性配置。
不适合:
- 需要 SLA 的生产业务 —— Telegram 渠道没有可用性承诺,且其服务条款明确禁止把服务当作文件托管或 CDN
- 单文件经常超过 20 MB —— TG 这层会频繁触发分片
- 日访问量稳定超过 10 万次 —— 要么上 Workers 付费计划,要么让图片直接走 R2 公开域名
十七、最后一句实话
这套方案的本质是用运维复杂度换成本。
它确实不花钱,但换来的是三个长期不确定性:
- Telegram 随时可能变策略,或被判定违规封 Bot —— 所有图瞬间失效且无法申诉
- Cloudflare 免费额度可能调整,10 万次/日不是承诺
- 缓存的副作用要自己兜,删图不即时生效这件事会一直存在
所以真正重要的图片,我还是会往 R2 那 10 GB 里放一份。Telegram 存的是”图”,R2 存的是”底”。
本文记录于 2026-10-29,项目版本 CloudFlare ImgBed v2.7.8。Cloudflare 与 Telegram 的政策和额度可能变化,以官方文档为准。








