How We Leverage Cloudflare Infrastructure to Process Videos

Video processing is compute-heavy. FFmpeg needs CPU, disk space, and direct access to files - clearly a backend server job. Instead of having dedicated machine - we found cloudflare containers now support ffmpeg and when idle, don't incurr cost. You can have multiple containers running so we can process mutliple requests parallely. We use Cloudflare R2 buckets - they are S3 comatible so all S3 compatible librareis work seemlessly with R2.

Cloudflare containers and workers can access r2 buckets seemlessly.

When Cloudflare announced support for containers - we were excited. The infrastructure is working out great so far.


The Architecture

Web AppR2WorkerFFmpeg ProcessorUpload videoPOST /processForward job202 AcceptedRead via FUSE (/mnt/r2)File dataRun FFmpegWrite output via FUSEPOST webhook (completion)Web AppR2WorkerFFmpeg Processor

Three components work together:

  • Web App — The Next.js application where users upload files and trigger processing
  • Cloudflare Worker — A lightweight router that authenticates requests and starts the container
  • FFmpeg Processor — A Cloudflare Container running Node.js and FFmpeg with R2 mounted as a local filesystem

How R2 Becomes a Local Filesystem

This is the part that makes the architecture work. Cloudflare Containers support FUSE mounts via tigrisfs, which lets you mount an R2 bucket as a directory inside the container. From FFmpeg's perspective, it is reading and writing to /mnt/r2/ — regular file I/O. Under the hood, tigrisfs translates those operations into R2 API calls.

The container startup script mounts the bucket before the Node.js server starts:

# Mount R2 bucket as local filesystem
R2_ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
tigrisfs --endpoint "${R2_ENDPOINT}" -f "${R2_BUCKET_NAME}" /mnt/r2 &

# Wait for mount to stabilize
sleep 5

# Start the processing server
node src/index.js

Once mounted, the processor reads input files and writes output files using standard Node.js fs operations:

import { copyFileSync } from 'fs';

// Read from R2 mount
const input = `/mnt/r2/uploads/audio.mp3`;

// Copy to local temp for FFmpeg processing
copyFileSync(input, `/tmp/${jobId}/audio.mp3`);

// ... run FFmpeg (audio → video) ...

// Write output back to R2 mount
copyFileSync(`/tmp/${jobId}/output.mp4`, `/mnt/r2/output/${jobId}/output.mp4`);

We copy files to /tmp before processing rather than running FFmpeg directly against the FUSE mount. FFmpeg does many small random reads and seeks during encoding — running it against a network filesystem would add latency to every I/O operation. A local copy eliminates that.

The Job Flow

1. User Triggers a Job

The web app posts an FFmpeg processing job to the Cloudflare Worker:

POST /process
{
  "jobType": "audio_to_video",
  "jobId": "job_123",
  "inputFile": "audio.mp3"
}

The Worker validates the API key, starts the container if it is not already running, and forwards the request. The response comes back immediately:

{ "status": "accepted", "jobId": "job_123" }

The web app is never blocked. The user can navigate away, close the tab, or start another job.

2. Container Processes the Job

Inside the container, the processor:

  1. Reads the input file from /mnt/r2/
  2. Copies it to local /tmp/ storage
  3. Runs FFmpeg to convert audio to video
  4. Copies the output back to /mnt/r2/

3. Real-Time Progress via WebSocket

The web app can optionally open a WebSocket connection to receive progress updates. The container parses FFmpeg's stderr output for timestamp data and forwards it:

// Container parses FFmpeg progress
// stderr line: "frame= 1234 fps= 30 time=00:05:30.25 ..."
// Sends to client:
{ "jobId": "job_123", "type": "progress", "time": "00:05:30.25" }

This gives users a real-time progress indicator without polling.

4. Webhook on Completion

When the job finishes, the container sends a webhook to the web app:

POST /api/webhook
Headers:
  x-webhook-secret: <shared-secret>
  x-job-type: audio_to_video

Body:
{
  "status": "complete",
  "jobId": "job_123",
  "outputFile": "output.mp4"
}

The web app validates the shared secret, updates the job status in the database, creates an artifact record pointing to the output file in R2, and notifies the user.

Handling Multiple Requests

Each incoming job spins up its own container instance. If 5 users trigger video processing at the same time, 5 containers run in parallel — each with its own CPU, memory, and FUSE mount. We cap concurrent instances at 10 via the max_instances setting, though Cloudflare's account limit for our instance type allows up to ~1,500. When all instances are busy, new requests wait until one frees up. When there are no jobs, containers scale to zero — no idle cost.

Container Configuration

The Cloudflare Container is configured in the Wrangler config file:

{
  "containers": [{
    "class_name": "FFmpegProcessor",
    "image": "./Dockerfile",
    "instance_type": "standard-1",
    "max_instances": 10
  }]
}
SettingValueWhy
instance_typestandard-10.5 vCPU, 4 GiB RAM — enough for 1080p encoding
max_instances10Self-imposed cap; account limit allows ~1,500

The standard-1 instance type costs less than running a dedicated VM and scales to zero when there are no jobs. Containers sleep after 30 minutes of inactivity and wake on the next request.

Since user files are stored in private R2 buckets, running FFmpeg inside Cloudflare Containers is a natural fit — the containers access R2 directly over Cloudflare's internal network without exposing files to the public internet or routing them through external services.

What We Learned

FUSE mounts need a warmup. The startup script waits 5 seconds after mounting before starting the Node server. Without this delay, early requests occasionally fail with ENOENT errors because the mount point is not yet ready.

Copy to /tmp before processing. Our first version ran FFmpeg directly against the FUSE mount. It worked but was 3-4x slower due to network I/O latency on every read. Copying the input to local storage first eliminated the bottleneck.

Keep the Worker alive with waitUntil. This one bit us. The Worker forwards the job to the container and returns a 202 response. Once the response is sent, Cloudflare considers the Worker done and may terminate the container along with it — even if FFmpeg is mid-encode. The fix is ctx.waitUntil(container.monitor()), which tells the Worker runtime to keep the execution context alive until the container finishes:

// Forward job to container
const response = await container.fetch("http://container/process", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(job),
});

// Keep worker alive until container finishes
c.executionCtx.waitUntil(container.monitor());

return response; // 202 Accepted — client is unblocked

Without this, long-running jobs would randomly fail. sleepAfter (which controls idle timeout between requests) does not help here — the issue is the Worker's execution context ending, not the container going idle.

Fire-and-forget is fine at small scale. If the container crashes mid-job, the job is lost. We accept this tradeoff because it happens rarely, and users can retry. At higher scale, a queue with retry semantics would be worth the added complexity.

WebSocket progress is optional but valuable. Not every client connects via WebSocket. The webhook is the reliable notification path. WebSocket progress is a UX enhancement, not a requirement.


Built for Reliability

Every video you upload to VideoToBe is processed on Cloudflare's global infrastructure — the same network that powers millions of websites and handles trillions of requests per month. Your files are stored in R2 with built-in redundancy. Each processing job runs in its own isolated container with dedicated CPU and memory, so one job never impacts another. Containers scale automatically to handle demand and scale to zero when idle, meaning you only pay for actual processing time. From upload to download, your video never leaves Cloudflare's network — fast, secure, and reliable.