tus-server-r2
TUS resumable upload protocol server for Cloudflare Workers + R2.
Zero dependencies. No KV. No Durable Objects. Just your R2 bucket.
Quickstart
Three files, two commands:
wrangler.toml
name = "my-uploader"
main = "src/index.js"
compatibility_date = "2025-01-01"
[[r2_buckets]]
binding = "BUCKET"
bucket_name = "my-uploads"
src/index.js
import { createTusHandler } from 'tus-server-r2'
export default createTusHandler()
npm install tus-server-r2
npx wrangler deploy
Your TUS endpoint is live at https://my-uploader.<account>.workers.dev.
Setup Guide
1 Cloudflare account
Sign up at cloudflare.com. Workers + R2 are available on the free tier. A credit card is required for R2 even within free limits.
2 Create an R2 bucket
In the Cloudflare dashboard → R2 Object Storage → Create bucket. Name it something like my-uploads.
3 Install Wrangler
npm install -g wrangler
sudo npm install -g wrangler.4 Login to Cloudflare
wrangler login
This opens a browser to authorize Wrangler. In devcontainers where a browser isn't available, use an API token instead:
export CLOUDFLARE_API_TOKEN=your-token-here
Create a token at dash.cloudflare.com/profile/api-tokens using the Edit Cloudflare Workers template.
5 Create the Worker project
mkdir my-uploader
cd my-uploader
npm init -y
npm install tus-server-r2
6 Create wrangler.toml
name = "my-uploader"
main = "src/index.js"
compatibility_date = "2025-01-01"
[[r2_buckets]]
binding = "BUCKET"
bucket_name = "my-uploads"
Replace my-uploads with the bucket name you created in Step 2.
7 Create src/index.js
import { createTusHandler } from 'tus-server-r2'
export default createTusHandler()
8 Test locally
wrangler dev
Your TUS endpoint is available at http://localhost:8787. Paste this into the upload example page to test uploads locally.
9 Deploy
wrangler deploy
Your TUS endpoint is live at https://my-uploader.<your-subdomain>.workers.dev.
10 Test the live deployment
Open the upload example page, paste your Worker URL, and upload a file. Check your R2 bucket in the Cloudflare dashboard — the file should appear under uploads/.
Options
All options are optional. Env vars are read automatically from the Worker environment.
| Option | Default | Description |
|---|---|---|
bucket | env.BUCKET | R2Bucket instance |
statePrefix | __tus | R2 key prefix for upload state objects |
uploadsPrefix | uploads | R2 key prefix for completed files |
maxSize | unlimited | Maximum upload size in bytes |
uploadTTL | 86400000 (24h) | Incomplete upload TTL in milliseconds |
webhookUrl | env.WEBHOOK_URL | URL to POST on upload completion |
webhookBearerToken | env.WEBHOOK_BEARER_TOKEN | Bearer token for webhook Authorization header |
corsAllowOrigin | * | Allowed CORS origins, comma-separated |
onComplete | — | async (key, metadata, bucket) => void |
basePath | '' | URL prefix when TUS is at a sub-path |
Environment Variables
Configure via wrangler.toml [vars] — no code changes needed:
[vars]
WEBHOOK_URL = "https://api.example.com/upload-complete"
WEBHOOK_BEARER_TOKEN = "your-secret-token"
CORS_ALLOW_ORIGIN = "https://app.example.com,https://admin.example.com"
CORS_ALLOW_ORIGIN defaults to * if not set. Set it to restrict uploads to specific domains.
TUS Extensions
- creation POST to create upload before sending data
- creation-with-upload Send first chunk in the POST body
- creation-defer-length Omit Upload-Length at creation, provide later
- termination DELETE to cancel upload and free resources
- expiration Incomplete uploads expire after
uploadTTL
Storage Layout
__tus/{uuid} — upload state JSON (deleted on completion or termination)
uploads/{uuid} — completed file
Both prefixes are configurable via statePrefix and uploadsPrefix.
TUS Upload-Metadata is decoded and mapped to R2 on completion:
| TUS metadata key | R2 field |
|---|---|
type | httpMetadata.contentType |
filename | httpMetadata.contentDisposition |
| other keys | customMetadata[key] |
Webhook
On upload completion, tus-server-r2 POSTs to webhookUrl:
{
"key": "uploads/550e8400-e29b-41d4-a716-446655440000",
"metadata": {
"filename": "video.mp4",
"type": "video/mp4"
}
}
With Authorization: Bearer <token> if WEBHOOK_BEARER_TOKEN is set.
Error Codes
| Status | Condition |
|---|---|
400 | Missing Upload-Length and Upload-Defer-Length |
404 | Upload not found |
409 | Upload-Offset mismatch |
410 | Upload expired |
412 | Missing or wrong Tus-Resumable header |
413 | Upload exceeds maxSize |
415 | Wrong Content-Type on PATCH |
With Auth
Authorization runs before TUS handling in the Worker fetch handler:
import { createTusHandler } from 'tus-server-r2'
const tus = createTusHandler()
export default {
async fetch(request, env, ctx) {
const token = request.headers.get('Authorization')?.replace('Bearer ', '')
if (!token || token !== env.API_TOKEN) {
return new Response('Unauthorized', { status: 401 })
}
return tus.fetch(request, env, ctx)
}
}
With Webhook
Configure via wrangler.toml — no code changes needed:
[vars]
WEBHOOK_URL = "https://api.example.com/webhook"
WEBHOOK_BEARER_TOKEN = "secret"
import { createTusHandler } from 'tus-server-r2'
export default createTusHandler()
Or use onComplete for custom logic:
export default createTusHandler({
onComplete: async (key, metadata, bucket) => {
await fetch('https://api.example.com/notify', {
method: 'POST',
body: JSON.stringify({ key, ...metadata })
})
}
})
Expired Upload Cleanup
Add a cron trigger to clean up expired incomplete uploads:
[triggers]
crons = ["0 * * * *"]
import { createTusHandler } from 'tus-server-r2'
const tus = createTusHandler()
export default {
fetch: tus.fetch.bind(tus),
async scheduled(event, env, ctx) {
const bucket = env.BUCKET
const list = await bucket.list({ prefix: '__tus/' })
for (const obj of list.objects) {
const state = JSON.parse(await (await bucket.get(obj.key)).text())
if (Date.now() > state.expires) {
bucket.resumeMultipartUpload(state.key, state.uploadId).abort()
await bucket.delete(obj.key)
}
}
}
}
Uppy Client
import Uppy from '@uppy/core'
import Tus from '@uppy/tus'
const uppy = new Uppy()
uppy.use(Tus, {
endpoint: 'https://my-uploader.<account>.workers.dev',
headers: {
Authorization: 'Bearer my-token'
}
})
Or try it directly in the browser: interactive upload example →