tus-server-r2
TUS resumable upload protocol server for Cloudflare Workers + R2. Zero dependencies, no KV, no Durable Objects — just your R2 bucket.
Documentation & Setup Guide · Live Upload Example · npm · Changelog
Install
npm install tus-server-r2
Quickstart
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()
npx wrangler deploy
TUS endpoint: https://my-uploader.<account>.workers.dev
Options
createTusHandler({
bucket, // R2Bucket instance. Default: env.BUCKET
statePrefix, // R2 key prefix for upload state. Default: '__tus'
uploadsPrefix, // R2 key prefix for completed uploads. Default: 'uploads'
maxSize, // Max upload size in bytes. Default: unlimited
uploadTTL, // Incomplete upload TTL in ms. Default: 86400000 (24h)
webhookUrl, // POST to this URL on completion. Default: env.WEBHOOK_URL
webhookBearerToken, // Bearer token for webhook. Default: env.WEBHOOK_BEARER_TOKEN
corsAllowOrigin, // Allowed CORS origins, comma-separated. Default: env.CORS_ALLOW_ORIGIN or '*'
onComplete, // async (key, metadata, bucket) => void
basePath, // URL prefix if TUS is mounted at a sub-path. Default: ''
})
All options are optional. Called with no arguments, createTusHandler() reads env.BUCKET, env.WEBHOOK_URL, env.WEBHOOK_BEARER_TOKEN, and env.CORS_ALLOW_ORIGIN automatically.
CORS headers are always included on every response. The default Access-Control-Allow-Origin is *. Set corsAllowOrigin (or CORS_ALLOW_ORIGIN) to a comma-separated list to restrict to specific origins.
Storage Layout
__tus/{uuid} — upload state JSON (deleted on completion or termination)
__tus/buf-{uuid} — partial-part buffer (raw bytes, exists only during upload)
uploads/{uuid} — completed file
Both prefixes are configurable via statePrefix and uploadsPrefix.
TUS Metadata → R2 Metadata
Upload-Metadata sent by the client is decoded and mapped to R2 on completion:
| TUS key | R2 field |
|---|---|
type |
httpMetadata.contentType |
filename |
httpMetadata.contentDisposition |
| other | customMetadata[key] |
Supported Extensions
| Extension | Description |
|---|---|
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 |
Examples
Minimal standalone Worker
import { createTusHandler } from 'tus-server-r2'
export default createTusHandler()
Custom bucket binding
import { createTusHandler } from 'tus-server-r2'
export default {
async fetch(request, env, ctx) {
return createTusHandler({ bucket: env.MYUPLOADS }).fetch(request, env, ctx)
}
}
With webhook notification
wrangler.toml:
[vars]
WEBHOOK_URL = "https://api.example.com/upload-complete"
WEBHOOK_BEARER_TOKEN = "secret-token"
import { createTusHandler } from 'tus-server-r2'
export default createTusHandler()
// webhook fires automatically on completion
Webhook payload:
{
"key": "uploads/550e8400-e29b-41d4-a716-446655440000",
"metadata": {
"filename": "video.mp4",
"type": "video/mp4"
}
}
With onComplete hook
import { createTusHandler } from 'tus-server-r2'
export default createTusHandler({
onComplete: async (key, metadata, bucket) => {
// key = "uploads/{uuid}"
// metadata = decoded TUS Upload-Metadata
// bucket = R2Bucket — move, delete, or read the file
console.log('Upload complete:', key, metadata)
}
})
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)
}
}
Mounted at a sub-path (middleware)
import { createTusHandler } from 'tus-server-r2'
const tus = createTusHandler({ basePath: '/files' })
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url)
if (url.pathname.startsWith('/files')) {
return tus.fetch(request, env, ctx)
}
return new Response('Not found', { status: 404 })
}
}
With custom prefixes
import { createTusHandler } from 'tus-server-r2'
export default createTusHandler({
statePrefix: 'tus',
uploadsPrefix: 'media',
})
// state at: tus/{uuid}
// files at: media/{uuid}
Expired upload cleanup (cron)
Add to wrangler.toml:
[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 prefix = '__tus/'
let cursor
do {
const list = await bucket.list({ prefix, cursor })
cursor = list.truncated ? list.cursor : undefined
for (const obj of list.objects) {
// Skip buffer objects — they're cleaned up alongside their state entry
if (obj.key.includes('/buf-')) continue
const state = JSON.parse(await (await bucket.get(obj.key)).text())
if (Date.now() > state.expires) {
const uuid = state.key.split('/').pop()
if (state.uploadId) {
await bucket.resumeMultipartUpload(state.key, state.uploadId).abort()
}
await bucket.delete(`${prefix}buf-${uuid}`)
await bucket.delete(obj.key)
}
}
} while (cursor)
}
}
Client Setup (Uppy)
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'
}
})
CORS and Bot Protection
CORS headers are included on every response (see corsAllowOrigin in Options above).
If your Worker runs on a custom domain with Cloudflare Bot Fight Mode enabled, TUS clients may receive 403 responses. Create a WAF skip rule for your TUS path (Security → WAF → Custom rules → Action: Skip → Bot Fight Mode) or disable Bot Fight Mode for the zone.
On workers.dev the domain is Cloudflare-managed and cannot be configured. Two conditions trigger a 403: sending no User-Agent header, and Python's built-in urllib (Python-urllib/3.x). All other common HTTP client defaults pass through. Fix: set any non-empty User-Agent header:
# Python urllib
req = urllib.request.Request(url, headers={"User-Agent": "my-app/1.0"})
// Node.js fetch (omits User-Agent by default)
fetch(url, { headers: { "User-Agent": "my-app/1.0" } })
// C# HttpClient (omits User-Agent by default)
client.DefaultRequestHeaders.UserAgent.ParseAdd("my-app/1.0");
See the full per-client table in the docs.
Error Responses
| Status | Condition |
|---|---|
| 400 | Missing Upload-Length and Upload-Defer-Length |
| 404 | Upload not found |
| 405 | Method not allowed |
| 409 | Upload-Offset mismatch |
| 410 | Upload expired |
| 412 | Missing or wrong Tus-Resumable header |
| 413 | Upload exceeds maxSize |
| 415 | Wrong Content-Type on PATCH |
License
MIT