用 Cloudflare R2 搭建个人图床:上传、压缩、访问与成本
从只用 R2 控制台上传,到基于 Cloudflare Workers、D1、Pages 和 TinyPNG 搭建一个可用的个人图床应用。
我最早用 Cloudflare R2 做图床时,只是把图片丢到 R2 控制台,再自己拼 URL。这个方式能用,但不适合长期写博客:上传麻烦、图片不好找、无法一键复制地址,也没有压缩流程。
后来补了一版可视化图床,又加了 TinyPNG 自动压缩。现在回头看,这三篇内容其实应该合成一篇完整方案:R2 负责存储,Workers 负责上传/查询/删除,D1 记录图片元数据,Pages 托管前端页面,TinyPNG 在上传前压缩图片。
English version: Build a Personal Image Hosting Service with Cloudflare R2
本文记录的是个人博客图床的完整方案。它不追求做成公开 SaaS,只解决个人写作时的几个核心需求:
- 上传图片。
- 自动压缩图片。
- 生成稳定可访问的图片 URL。
- 查看图片列表。
- 复制 Markdown 图片地址。
- 删除不再需要的图片。
- 尽量少花钱,最好在个人用量下接近免费。
为什么选 R2
个人博客如果是静态生成,正文用 Markdown 管理很舒服,但图片会变成一个麻烦点。
图片放在博客仓库里,优点是简单,缺点是仓库越来越大,迁移和构建都不舒服。图片放在云服务器上,也能用,但个人服务器带宽通常很小,不值得把图片流量压到服务器上。
对象存储更适合做图床。Cloudflare R2 的好处是:
- 对象存储模型简单,适合存图片。
- 可以绑定自定义域名。
- 不收取出口流量费,个人博客这类读多写少场景很友好。
- 可以和 Workers、D1、Pages 放在同一个平台里组合使用。
Cloudflare 的价格和免费额度以官方页面为准,本文只讨论适合个人图床的计费结构和使用取舍:Cloudflare R2 Pricing。
R2 不是唯一选择。阿里云 OSS、腾讯云 COS、AWS S3 都能做类似事情。选 R2 主要是因为个人博客访问以静态图片为主,R2 的出口流量策略和 Cloudflare 生态比较适合这个场景。
整体架构
最终架构是这样:

涉及的 Cloudflare 服务:
- R2:存储图片文件。
- D1:存储图片元数据,比如文件名、访问地址、创建时间、大小。
- Workers:提供上传、查询、删除 API。
- Pages:托管前端页面。
额外服务:
- GitHub:保存前端和 Worker 代码。
- TinyPNG/Tinify:压缩图片。
- 自定义域名:提供可长期使用的图片访问地址。
最终成品大概是这样:


准备 R2
在 Cloudflare 控制台创建一个 R2 bucket,例如:
image-storage
创建完成后,先解决公开访问问题。R2 默认不公开,图床需要能通过 URL 访问图片。
Cloudflare 提供两种方式:
- 使用 R2 的公开访问能力。
- 绑定自己的自定义域名。
个人博客更适合绑定自己的域名,比如:
https://aipaint.lihuanyu.com
Cloudflare 关于公开桶和自定义域名的说明见:Public buckets and custom domains。
长期依赖 Cloudflare 分配的 r2.dev 预览域名风险较高。一方面它不适合正式生产使用,另一方面国内访问也不一定稳定。自己的域名更适合放进历史文章里长期使用。
准备 D1
R2 只负责存对象,不适合承担图片列表、搜索、分页等功能。这里需要一张表记录图片元数据。
创建 D1 数据库,例如:
image-storage-record
建表 SQL:
CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
object_key TEXT NOT NULL UNIQUE,
original_name TEXT NOT NULL,
image_url TEXT NOT NULL,
content_type TEXT,
size INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_images_created_at ON images(created_at);
字段含义:
object_key:R2 里的对象 key,例如2026-05-03/uuid.png。original_name:用户上传时的原始文件名。image_url:公开访问地址。content_type:图片 MIME 类型。size:最终写入 R2 的文件大小。created_at:创建时间戳。
个人场景下,D1 足够承担这类元数据存储。引入 Postgres 或 MySQL 反而会把简单问题复杂化。
Worker 绑定配置
Worker 通过 binding 访问 R2 和 D1。wrangler.toml 可以这样写:
name = "image-storage-worker"
main = "src/index.ts"
compatibility_date = "2026-05-03"
[vars]
PUBLIC_IMAGE_BASE_URL = "https://aipaint.lihuanyu.com"
[[r2_buckets]]
binding = "IMAGE_BUCKET"
bucket_name = "image-storage"
[[d1_databases]]
binding = "DB"
database_name = "image-storage-record"
database_id = "替换为 D1 database id"
这里容易漏掉的是 database_id。用 wrangler d1 create image-storage-record 创建数据库时,命令行会返回这个值;如果是在控制台创建,也可以在数据库详情里找到。
如果要接入 TinyPNG,API key 应通过 secret 管理,而不是写进 wrangler.toml:
wrangler secret put TINIFY_API_KEY
如果图床页面不希望公开上传,还应增加一个管理 token:
wrangler secret put ADMIN_TOKEN
Worker binding 的完整配置方式见:Wrangler configuration。
Worker API
下面是一份简化但完整的 Worker 逻辑,包含:
OPTIONS:处理 CORS 预检。POST /upload:上传图片,支持 TinyPNG 压缩。GET /query:分页查询图片列表。DELETE /delete?id=1:删除图片和元数据。
interface Env {
IMAGE_BUCKET: R2Bucket;
DB: D1Database;
PUBLIC_IMAGE_BASE_URL: string;
TINIFY_API_KEY?: string;
ADMIN_TOKEN?: string;
}
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET,POST,DELETE,OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type,Authorization',
};
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
if (request.method === 'GET' && url.pathname === '/query') {
return handleQuery(request, env);
}
if (!isAuthorized(request, env)) {
return json({ success: false, message: 'Unauthorized' }, 401);
}
if (request.method === 'POST' && url.pathname === '/upload') {
return handleUpload(request, env);
}
if (request.method === 'DELETE' && url.pathname === '/delete') {
return handleDelete(request, env);
}
return json({ success: false, message: 'Not found' }, 404);
},
};
function isAuthorized(request: Request, env: Env) {
if (!env.ADMIN_TOKEN) {
return true;
}
return request.headers.get('Authorization') === `Bearer ${env.ADMIN_TOKEN}`;
}
async function handleUpload(request: Request, env: Env) {
const formData = await request.formData();
const file = formData.get('file');
if (!(file instanceof File)) {
return json({ success: false, message: 'Missing file' }, 400);
}
if (!file.type.startsWith('image/')) {
return json({ success: false, message: 'Only image files are allowed' }, 400);
}
const objectKey = createObjectKey(file.name);
const image = env.TINIFY_API_KEY
? await compressWithTinify(file, env.TINIFY_API_KEY)
: {
body: await file.arrayBuffer(),
contentType: file.type || 'application/octet-stream',
size: file.size,
};
await env.IMAGE_BUCKET.put(objectKey, image.body, {
httpMetadata: {
contentType: image.contentType,
},
});
const baseUrl = env.PUBLIC_IMAGE_BASE_URL.replace(/\/$/, '');
const imageUrl = `${baseUrl}/${objectKey}`;
const createdAt = Date.now();
await env.DB.prepare(
`INSERT INTO images
(object_key, original_name, image_url, content_type, size, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.bind(objectKey, file.name, imageUrl, image.contentType, image.size, createdAt)
.run();
return json({
success: true,
url: imageUrl,
markdown: ``,
});
}
async function handleQuery(request: Request, env: Env) {
const url = new URL(request.url);
const pageNum = Math.max(Number(url.searchParams.get('pageNum')) || 1, 1);
const pageSize = Math.min(Math.max(Number(url.searchParams.get('pageSize')) || 20, 1), 50);
const offset = (pageNum - 1) * pageSize;
const list = await env.DB.prepare(
`SELECT id, object_key, original_name, image_url, content_type, size, created_at
FROM images
ORDER BY id DESC
LIMIT ? OFFSET ?`,
)
.bind(pageSize, offset)
.all();
const count = await env.DB.prepare(`SELECT COUNT(*) AS total FROM images`).first<{
total: number;
}>();
return json({
success: true,
results: list.results,
total: count?.total || 0,
});
}
async function handleDelete(request: Request, env: Env) {
const url = new URL(request.url);
const id = Number(url.searchParams.get('id'));
if (!Number.isInteger(id) || id <= 0) {
return json({ success: false, message: 'Invalid id' }, 400);
}
const row = await env.DB.prepare(`SELECT object_key FROM images WHERE id = ?`)
.bind(id)
.first<{ object_key: string }>();
if (!row) {
return json({ success: false, message: 'Image not found' }, 404);
}
await env.IMAGE_BUCKET.delete(row.object_key);
await env.DB.prepare(`DELETE FROM images WHERE id = ?`).bind(id).run();
return json({ success: true });
}
async function compressWithTinify(file: File, apiKey: string) {
const source = await file.arrayBuffer();
const auth = `Basic ${btoa(`api:${apiKey}`)}`;
const shrink = await fetch('https://api.tinify.com/shrink', {
method: 'POST',
headers: {
Authorization: auth,
'Content-Type': file.type || 'application/octet-stream',
},
body: source,
});
if (!shrink.ok) {
const message = await shrink.text();
throw new Error(`TinyPNG shrink failed: ${shrink.status} ${message}`);
}
const outputUrl = shrink.headers.get('Location');
if (!outputUrl) {
throw new Error('TinyPNG did not return output location');
}
const optimized = await fetch(outputUrl, {
headers: {
Authorization: auth,
},
});
if (!optimized.ok) {
const message = await optimized.text();
throw new Error(`TinyPNG download failed: ${optimized.status} ${message}`);
}
const body = await optimized.arrayBuffer();
return {
body,
contentType: optimized.headers.get('Content-Type') || file.type || 'application/octet-stream',
size: Number(optimized.headers.get('Content-Length')) || body.byteLength,
};
}
function createObjectKey(filename: string) {
const extension = filename.includes('.') ? filename.split('.').pop() : 'bin';
const date = new Date().toISOString().slice(0, 10);
return `${date}/${crypto.randomUUID()}.${extension}`;
}
function json(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: {
...corsHeaders,
'Content-Type': 'application/json; charset=utf-8',
},
});
}
这里有几个细节值得强调。
第一,上传接口要校验 image/*,否则图床很容易变成任意文件存储。
第二,个人使用也应加 ADMIN_TOKEN。前端请求时带上:
Authorization: Bearer 管理 token
第三,TinyPNG 的 API 不是从 JSON 里拿 output.url。压缩请求成功后,应从响应头的 Location 获取压缩结果地址,再请求这个地址下载压缩后的图片。接口细节见:Tinify API reference。
第四,object_key 直接使用原始文件名会带来中文、空格、同名覆盖等问题。按日期加 UUID 更稳。
前端页面
前端可以用任何框架。示例版本使用的是 SolidJS,换成 React、Vue、Svelte 也没有本质差异,图床不是复杂应用。
核心功能其实只有三个。
上传:
async function uploadImage(file: File) {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${apiBaseUrl}/upload`, {
method: 'POST',
headers: {
Authorization: `Bearer ${adminToken}`,
},
body: formData,
});
if (!response.ok) {
throw new Error(await response.text());
}
return response.json();
}
查询:
async function queryImages(pageNum = 1, pageSize = 20) {
const response = await fetch(
`${apiBaseUrl}/query?pageNum=${pageNum}&pageSize=${pageSize}`,
);
if (!response.ok) {
throw new Error(await response.text());
}
return response.json();
}
删除:
async function deleteImage(id: number) {
const response = await fetch(`${apiBaseUrl}/delete?id=${id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${adminToken}`,
},
});
if (!response.ok) {
throw new Error(await response.text());
}
return response.json();
}
页面上至少需要这些交互:
- 文件选择或拖拽上传。
- 上传成功后展示图片 URL 和 Markdown。
- 图片列表展示缩略图、原始文件名、创建时间、大小。
- 复制 URL。
- 复制 Markdown。
- 删除图片。
前端部署到 Cloudflare Pages 即可。它和 Worker API 可以分开部署,也可以共用一个仓库。个人项目里分开维护更清晰:前端页面出问题不影响图片访问,Worker API 也更容易单独迭代。
自定义域名与缓存
图床最重要的是 URL 稳定。只要图片 URL 被写进文章,就不应该轻易变化。
推荐做法:
- 单独给图片服务一个子域名,例如
aipaint.lihuanyu.com。 - R2 bucket 绑定这个子域名。
- 文章里只使用这个子域名下的图片地址。
- 避免把 Worker 预览域名、Pages 预览域名写进文章。
图片属于静态资源,缓存可以激进一点。个人博客图片一旦上传,通常不会用同一个 URL 替换内容。如果确实要替换,最简单的方式是上传新图片,生成新 URL。
成本
这套方案的成本主要来自四块:
- R2 存储和请求。
- D1 读写。
- Workers 请求。
- TinyPNG 压缩次数。
个人博客的图片访问通常是读多写少,R2 和 Workers 的压力都很小。真正需要单独评估的是 TinyPNG:它不是 Cloudflare 服务,免费额度和计费规则要看 Tinify 官方说明。如果图片很多,压缩可以改成本地脚本处理,或者只在上传大图时启用。
实践后的取舍是:
- R2 适合长期存图片。
- D1 只存元数据,成本可以忽略。
- Workers 很适合做这类轻量 API。
- TinyPNG 是锦上添花,不是必需项。
如果只是写博客,第一版可以先不做 TinyPNG,把上传、查询、复制 Markdown 跑通。等图片变多、加载速度开始成为问题,再接压缩。
这套方案的边界
它适合个人图床,不适合直接做公开平台。
如果要开放给其他人用,至少还要补:
- 用户系统。
- 权限隔离。
- 上传频率限制。
- 文件大小限制。
- 内容安全审核。
- 存储配额。
- 删除后的审计日志。
- 防盗链或访问控制策略。
个人使用时,最重要的是不要暴露上传接口。否则上传接口可能被滥用为公开文件存储。
结论
Cloudflare R2 做个人图床是合适的,但不要停留在“控制台上传 + 手拼 URL”的阶段。真正好用的图床,需要把上传、压缩、列表、复制、删除串起来。
落地顺序可以是:
- 先创建 R2,并绑定自己的图片域名。
- 再用 Worker 写上传接口。
- 用 D1 记录图片元数据。
- 做一个很简单的前端页面。
- 最后再接 TinyPNG 或其他压缩方案。
这样既保留了对象存储的稳定性,又能让写博客时贴图这件事变得足够顺手。