tus-server-r2

TUS resumable upload protocol server for Cloudflare Workers + R2.
Zero dependencies. No KV. No Durable Objects. Just your R2 bucket.

Try the upload example →

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 StorageCreate bucket. Name it something like my-uploads.

3 Install Wrangler

npm install -g wrangler
On some systems (including devcontainers) you may need 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.

OptionDefaultDescription
bucketenv.BUCKETR2Bucket instance
statePrefix__tusR2 key prefix for upload state objects
uploadsPrefixuploadsR2 key prefix for completed files
maxSizeunlimitedMaximum upload size in bytes
uploadTTL86400000 (24h)Incomplete upload TTL in milliseconds
webhookUrlenv.WEBHOOK_URLURL to POST on upload completion
webhookBearerTokenenv.WEBHOOK_BEARER_TOKENBearer token for webhook Authorization header
corsAllowOrigin*Allowed CORS origins, comma-separated
onCompleteasync (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

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 keyR2 field
typehttpMetadata.contentType
filenamehttpMetadata.contentDisposition
other keyscustomMetadata[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

StatusCondition
400Missing Upload-Length and Upload-Defer-Length
404Upload not found
409Upload-Offset mismatch
410Upload expired
412Missing or wrong Tus-Resumable header
413Upload exceeds maxSize
415Wrong 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 →