A serverless image hosting system (图床) that runs entirely on Cloudflare Workers, with a pluggable storage layer:
- Cloudflare R2 — via the native bucket binding, zero egress fees.
- Self-hosted S3-compatible — MinIO, Ceph RGW, Garage, SeaweedFS, Backblaze
B2 (S3 API), AWS S3, … via AWS Signature V4 signed
fetchcalls.
Switch backends with a single config variable — the rest of the app is storage-agnostic.
- 🖥️ Desktop web console built with React + shadcn/ui + Tailwind — drag & drop / click / paste upload, a responsive image library, copy URL / Markdown, and delete with confirmation. Served as static assets by the same Worker.
- 📤 Upload via raw body or
multipart/form-data - 🔗 Returns a clean public URL
- 🖼️ Streams images back with
Cache-Control,ETag, Range and conditional-request (If-None-Match→304) support - 🔑 Token auth on upload / delete / list; public read
- 🧮 Two key strategies:
random(date-prefixed) orhash(content-addressed, auto-deduplicating) - ✅ MIME allow-list and max-size enforcement
- 📦 No runtime dependencies in the Worker — SigV4 is implemented with Web Crypto
src/ # Cloudflare Worker (API + storage + asset routing)
index.ts # Router + request handlers
types.ts # Env bindings + Storage interface
utils.ts # Key generation, MIME helpers, auth compare, responses
sigv4.ts # Dependency-free AWS Signature V4 signer (Web Crypto)
storage/
index.ts # Backend factory (reads STORAGE_BACKEND)
r2.ts # Cloudflare R2 backend
s3.ts # S3-compatible backend
web/ # React + shadcn/ui dashboard (Vite), built to web/dist
src/
App.tsx # Dashboard layout
components/ # Uploader, Gallery + shadcn/ui primitives
lib/api.ts # Typed client for the Worker API
wrangler.toml # Worker config, bindings, static assets, vars
npm install # Worker deps
# Set the auth token (required for uploads)
npx wrangler secret put AUTH_TOKENnpm run deploy builds the dashboard (web/) and deploys the Worker together —
you don't need to build web/ by hand. To build just the UI: npm run build:web.
-
Create a bucket:
npx wrangler r2 bucket create image-host
-
In
wrangler.tomlkeep:[vars] STORAGE_BACKEND = "r2" [[r2_buckets]] binding = "IMAGES" bucket_name = "image-host"
-
Deploy:
npm run deploy
-
In
wrangler.tomlset the backend and endpoint:[vars] STORAGE_BACKEND = "s3" S3_ENDPOINT = "https://s3.example.com" # your MinIO / S3 endpoint S3_REGION = "us-east-1" S3_BUCKET = "images" S3_FORCE_PATH_STYLE = "true" # required for MinIO / most self-hosted
You can comment out the
[[r2_buckets]]block when using S3 only. -
Provide credentials as secrets:
npx wrangler secret put S3_ACCESS_KEY_ID npx wrangler secret put S3_SECRET_ACCESS_KEY
-
Deploy:
npm run deploy
Path-style vs virtual-hosted: self-hosted servers (MinIO, Garage, …) almost always need
S3_FORCE_PATH_STYLE = "true". Set it to"false"only if your endpoint is set up forbucket.hostvirtual-hosted addressing.
Two options:
# 1) Full stack against the real Worker runtime (build UI once, then serve):
npm run build:web
cp .dev.vars.example .dev.vars # add AUTH_TOKEN (and S3 creds if using S3)
npm run dev # http://localhost:8787 (UI + API)
# 2) Hot-reloading UI dev server (proxies API to `wrangler dev` on :8787):
npm run dev # terminal A — the Worker/API
npm --prefix web run dev # terminal B — Vite on http://localhost:5173| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/ |
– | shadcn dashboard (SPA) |
GET |
/health |
– | Health check + active backend |
POST |
/upload |
✅ | Upload an image, returns JSON with public URL |
GET |
/i/<key> |
– | Serve the image (Range / ETag aware) |
HEAD |
/i/<key> |
– | Metadata only |
DELETE |
/i/<key> |
✅ | Delete the image |
GET |
/api/list |
✅ | List objects (?prefix=&limit=&cursor=) |
Auth is a bearer token: Authorization: Bearer <AUTH_TOKEN> (a ?token= query
param is also accepted). Served images live under the /i/ prefix so they never
collide with the dashboard's client-side routes.
Raw body (content-type describes the image):
curl -X POST https://img.example.com/upload \
-H "Authorization: Bearer $AUTH_TOKEN" \
-H "Content-Type: image/png" \
--data-binary @cat.pngMultipart form:
curl -X POST https://img.example.com/upload \
-H "Authorization: Bearer $AUTH_TOKEN" \
-F "file=@cat.png"Response:
{
"ok": true,
"key": "2026/07/x7k2p9qz3ab1.png",
"url": "https://img.example.com/i/2026/07/x7k2p9qz3ab1.png",
"size": 20481,
"contentType": "image/png",
"etag": "\"...\""
}curl -X DELETE "https://img.example.com/i/2026/07/x7k2p9qz3ab1.png" \
-H "Authorization: Bearer $AUTH_TOKEN"| Variable | Backend | Default | Description |
|---|---|---|---|
STORAGE_BACKEND |
both | r2 |
r2 or s3 |
MAX_SIZE |
both | 10485760 (10 MiB) |
Max upload size in bytes |
ALLOWED_TYPES |
both | common image types | Comma-separated MIME allow-list, or * |
KEY_STRATEGY |
both | random |
random (date-prefixed) or hash (content-address) |
CACHE_MAX_AGE |
both | 31536000 |
Cache-Control max-age (seconds) for served images |
PUBLIC_BASE_URL |
both | request origin | Base URL used when building returned links |
AUTH_TOKEN |
both | — (secret) | Bearer token for write operations |
S3_ENDPOINT |
s3 | — | S3 endpoint URL |
S3_REGION |
s3 | us-east-1 |
Signing region |
S3_BUCKET |
s3 | — | Bucket name |
S3_ACCESS_KEY_ID |
s3 | — (secret) | Access key |
S3_SECRET_ACCESS_KEY |
s3 | — (secret) | Secret key |
S3_FORCE_PATH_STYLE |
s3 | true |
Path-style addressing (needed for self-hosted) |
- Object keys are validated against a safe charset, so path traversal is not possible via the serve/delete routes.
- The S3 backend signs the payload (SHA-256), the most broadly compatible SigV4
mode; uploads are buffered in the Worker (bounded by
MAX_SIZE) so a correctContent-Lengthis always sent. - Serving is a signed pass-through for S3 and a native binding read for R2; both
honour
Rangeand conditional requests.
BSD-3-Clause — see LICENSE.