REMOTION ON CLOUDFLARE. NO LAMBDA. NO S3.
Remotion assumes you’re on AWS. Lambda for rendering, S3 for storage, SQS for coordination. I got it running entirely on Cloudflare. No AWS account required.
I wanted programmatic video on the same stack as everything else I build. One bill, one platform, zero egress fees. Cloudflare Containers made it possible.
Remotion has a Cloudflare demo. It’s explicitly not production-ready. No queuing, no progress tracking. This is.
The architecture
Six Cloudflare services, zero AWS:
| AWS | Cloudflare |
|---|---|
| Lambda | Workers + Containers |
| S3 | R2 (zero egress) |
| SQS | Queues |
| DynamoDB | Durable Objects |
| CloudFront | Workers (built-in edge) |
The flow:
User Request → API Worker → Durable Object (job state)
↓
Queue (chunks)
↓
Renderer Containers (parallel)
↓
R2 (chunks)
↓
Stitcher Container
↓
R2 (final.mp4)
Video gets split into chunks, rendered in parallel across containers, stitched back together, stored in R2. The user gets a URL. Done.
The container wrapper
Cloudflare Containers need a Worker wrapper with a Durable Object that extends Container:
// containers/renderer/src/worker.ts
import { Container } from "@cloudflare/containers";
export class RendererContainer extends Container<Env> {
override defaultPort = 8080;
override sleepAfter = "5m";
override envVars: Record<string, string>;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.envVars = {
R2_ACCOUNT_ID: env.R2_ACCOUNT_ID || "",
R2_ACCESS_KEY_ID: env.R2_ACCESS_KEY_ID || "",
R2_SECRET_ACCESS_KEY: env.R2_SECRET_ACCESS_KEY || "",
R2_BUCKET: env.R2_BUCKET || "cloudflare-remotion-outputs",
PORT: "8080",
};
}
}
The container sleeps after 5 minutes of inactivity. You’re not paying for idle.
Remotion invocation
The renderer container runs Chrome, Remotion, and FFmpeg. The render function caches the bundle to avoid re-bundling on every request:
// containers/renderer/src/render.ts
import { bundle } from "@remotion/bundler";
import { renderMedia, selectComposition } from "@remotion/renderer";
let cachedBundlePath: string | null = null;
async function getBundlePath(): Promise<string> {
if (cachedBundlePath && fs.existsSync(cachedBundlePath)) {
return cachedBundlePath;
}
const bundlePath = await bundle({
entryPoint: path.resolve(process.cwd(), "../../packages/remotion/dist/index.js"),
outDir: path.join(os.tmpdir(), "remotion-bundle"),
enableCaching: true,
});
cachedBundlePath = bundlePath;
return bundlePath;
}
export async function renderVideo(options: RenderOptions): Promise<RenderResult> {
const bundlePath = await getBundlePath();
const composition = await selectComposition({
serveUrl: bundlePath,
id: options.compositionId,
inputProps: options.inputProps,
});
await renderMedia({
composition,
serveUrl: bundlePath,
codec: options.outputSettings.codec ?? "h264",
outputLocation: outputPath,
inputProps: options.inputProps,
frameRange: [options.startFrame, options.endFrame],
crf: options.outputSettings.crf ?? 18,
concurrency: Math.max(1, Math.floor(os.cpus().length / 2)),
chromiumOptions: {
enableMultiProcessOnLinux: true,
},
});
await uploadToR2(outputPath, r2Key);
return { r2Key, metadata };
}
Each container renders a chunk at 30 frames (1 second at 30fps) by default. Parallel rendering across multiple containers means a 30-second video doesn’t take 30x longer than a 1-second video.
Chunk coordination
The API Worker splits the video into chunks and queues them:
// apps/api/src/lib/chunks.ts
const DEFAULT_FRAMES_PER_CHUNK = 30;
export function calculateChunks(
durationInFrames: number,
fps: number = 30,
framesPerChunk: number = DEFAULT_FRAMES_PER_CHUNK
): ChunkInfo[] {
const chunks: ChunkInfo[] = [];
let currentFrame = 0;
let index = 0;
while (currentFrame < durationInFrames) {
chunks.push({
id: createChunkId(),
index,
startFrame: currentFrame,
endFrame: Math.min(currentFrame + framesPerChunk - 1, durationInFrames - 1),
});
currentFrame += framesPerChunk;
index++;
}
return chunks;
}
The queue consumer calls renderer containers via service binding and tracks progress in a Durable Object. When all chunks complete, it triggers the stitcher:
// apps/api/src/queue/consumer.ts
export async function handleChunkQueue(
batch: MessageBatch<ChunkQueueMessage>,
env: Bindings
): Promise<void> {
for (const message of batch.messages) {
const chunk = message.body;
const response = await env.RENDERER.fetch("https://remotion-renderer/render", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
compositionId: chunk.compositionId,
inputProps: chunk.inputProps,
startFrame: chunk.startFrame,
endFrame: chunk.endFrame,
jobId: chunk.jobId,
chunkIndex: chunk.chunkIndex,
}),
});
// Update progress in Durable Object
const jobStub = env.JOB_STATE.get(env.JOB_STATE.idFromName(chunk.jobId));
const jobState = await jobStub.fetch("http://internal/progress", {
method: "POST",
body: JSON.stringify({ chunkId: chunk.chunkId, r2Key: result.r2Key }),
}).then(r => r.json());
// Trigger stitcher when all chunks complete
if (jobState.completedChunks.length >= jobState.totalChunks) {
await triggerStitcher(env, chunk.jobId, jobState.totalChunks);
}
}
}
FFmpeg stitching
The stitcher container downloads chunks from R2, concatenates them with FFmpeg, and uploads the final video:
// containers/stitcher/src/stitch.ts
async function runFFmpegConcat(concatFilePath: string, outputPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const ffmpeg = spawn("ffmpeg", [
"-y",
"-f", "concat",
"-safe", "0",
"-i", concatFilePath,
"-c", "copy", // Stream copy for fast concat
"-movflags", "+faststart",
outputPath,
]);
});
}
Stream copy (-c copy) means no re-encoding during concat. Fast.
What was hard
Container memory limits. Default containers (256MB) can’t run Chrome + Remotion. Had to use standard-2 (6 GiB) for renderer, standard-3 (8 GiB) for stitcher.
# containers/renderer/wrangler.toml
[[containers]]
instance_type = "standard-2" # 1 vCPU, 6 GiB memory
Remotion Chrome detection. PUPPETEER_EXECUTABLE_PATH doesn’t work with Remotion. Need Remotion-specific env vars in the Dockerfile:
ENV REMOTION_CHROME_EXECUTABLE_PATH=/usr/bin/chromium
ENV REMOTION_SKIP_DOWNLOAD_BROWSER=1
Containers don’t have native R2 bindings. Unlike Workers, you can’t use env.BUCKET.get(). Have to use AWS SDK with R2’s S3-compatible endpoint:
const s3Client = new S3Client({
region: "auto",
endpoint: `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: { accessKeyId, secretAccessKey },
});
Platform architecture. Mac M1/M2 builds arm64, Cloudflare needs amd64:
docker buildx build --platform linux/amd64 -t renderer:v1 .
Queue delivery pausing. Queue consumer silently stops processing sometimes. Diagnosis:
npx wrangler queues info remotion-chunks
npx wrangler queues resume-delivery remotion-chunks
Cost
Per-render cost for a 30-second video:
- ~10 Worker requests
- ~20 Durable Object operations
- ~6 Queue messages (5 chunks + stitch)
- ~50MB R2 storage
- ~60 vCPU-seconds container time
Estimated: $0.01-0.05 per render.
Comparison:
| Lambda + S3 | Cloudflare | |
|---|---|---|
| Compute | $0.00001667/GB-s | ~$0.000012/vCPU-s |
| Storage | $0.023/GB + egress | $0.015/GB, zero egress |
| Egress | $0.09/GB | $0.00 |
R2’s zero egress is the killer advantage for video. Every time someone watches your video, AWS charges you. Cloudflare doesn’t.
The point
Remotion’s AWS setup works. But if you’re already on Cloudflare, you don’t need a second cloud. Workers for orchestration, Containers for heavy compute, R2 for storage, Queues for coordination. It all composes.
No AWS account. No Lambda cold starts. No egress fees. One platform.
This handles 100 concurrent jobs. 1000 would need work. Smarter container pooling, chunk prioritization, maybe dedicated stitcher instances. But for my volume, it’s done.