R RenderComp
remotion docker self-hosting rendering devops

How to Self-Host Remotion Video Rendering with Docker

How to Self-Host Remotion Video Rendering with Docker

TL;DR

Self-hosting Remotion means running the render engine — headless Chromium plus FFmpeg — inside a Docker container you deploy anywhere: a VPS, Cloud Run, Azure Container Apps, or your own cluster. It wins on data residency, predictable cost at steady volume, and zero vendor lock-in, but a fixed container pool is weak for bursty load — that spiky shape is Lambda’s job. The core is three calls, bundle()selectComposition()renderMedia(), wrapped in a Dockerfile that bakes in the browser via npx remotion browser ensure.

Remotion’s rendering engine is a Node.js program. It drives a headless Chromium browser to capture frames and uses FFmpeg to encode them into a video file. Because it is an ordinary Node process with browser and FFmpeg dependencies, you can put the whole thing inside a Docker container and run it wherever you run containers — a VPS, Google Cloud Run, Azure Container Apps, Cloudflare Containers, or your own Kubernetes cluster.

The payoff of doing this is control. A self-hosted Docker render service keeps your assets and your rendered output inside infrastructure you own, gives you a predictable cost profile at steady volume, and removes any dependency on a specific vendor’s rendering platform. You bundle your project, select a composition, and call renderMedia() — the same server-side rendering flow Remotion documents at remotion.dev/docs/ssr, just wrapped in a container image you deploy.

This guide covers the whole path: when self-hosting is the right call (and when AWS Lambda fits better), the Dockerfile, a render.mjs entry script built on @remotion/renderer, how to pass inputProps, how to expose the container as an HTTP render service, and the operational details — Chrome dependencies, memory, and concurrency — that decide whether the setup holds up in production.


What “Self-Hosting Remotion” Actually Means

Self-hosting Remotion means running the render on a machine you provision, inside a container image you build, rather than sending the job to a managed service. The core is the @remotion/renderer package, which performs the render locally to the process. Docker is simply the packaging: it pins the Node version, installs the OS libraries headless Chrome needs, and produces a portable image that renders identically on your laptop, in CI, and in production.

Concretely, a self-hosted render is three operations, always in this order:

  1. Bundle the project — turn your React compositions into a servable web bundle with bundle() from @remotion/bundler.
  2. Select the composition — resolve the composition’s real dimensions, duration, and props with selectComposition() from @remotion/renderer.
  3. Render — capture every frame and encode the file with renderMedia() from @remotion/renderer.

Everything else here — the Dockerfile, the HTTP wrapper, the scaling notes — exists to run those three calls reliably. The official references are remotion.dev/docs/docker for the container and remotion.dev/docs/ssr for the rendering API.


Why Self-Host?

Self-hosting is not the default choice for every team, but it is the correct one for a specific set of requirements.

Data residency and privacy. When you self-host, your source assets and rendered videos never leave infrastructure you control. For regulated industries, contractual data-residency obligations, or any pipeline where you cannot hand asset URLs to a third-party platform, running the render inside your own account is often a hard requirement rather than a preference.

Cost control at steady volume. A container running around the clock has a flat, predictable price. If your render volume is consistent — a steady stream of reports, receipts, or scheduled social clips — a single well-sized instance (or a small pool) can carry that load at a cost you can forecast, with no per-invocation charges to model.

Full infrastructure control. You choose the machine size, region, networking, and monitoring stack. You can put the render service behind your existing API gateway, share a VPC with your database, and reuse the observability tooling you already run. Nothing about the render is a black box.

No vendor lock-in. The render logic is a plain Node script and a Dockerfile. The same image runs on a VPS today and on Cloud Run or Azure Container Apps tomorrow with no code change. You are committing to a container, not to a proprietary rendering API.


When NOT to Self-Host

Self-hosting has an honest weakness: elasticity. A single container renders a fixed number of jobs at a time. If your workload is bursty — a marketing campaign that needs 5,000 personalized clips generated in an hour, then near-zero traffic for the rest of the week — a fixed pool of containers either sits idle or forms a long queue.

That spiky, parallelism-hungry shape is exactly what Remotion Lambda is built for. It splits each render across many concurrent AWS Lambda functions and scales the fleet up and down automatically, so a burst finishes in seconds and you pay only for the seconds used. For unpredictable spikes, Lambda’s automatic parallelism is a genuine advantage that a fixed container pool cannot easily match.

The two approaches are not mutually exclusive. A common pattern is a self-hosted container handling the steady baseline, with Lambda absorbing occasional bursts. Choose based on the shape of your load, not on any single tool being universally better.


Step 1 — The Dockerfile

Remotion documents node:22-bookworm-slim as the base image. The slim Debian base is small, and you add the operating-system libraries that headless Chrome requires on top of it. After dependencies are installed, npx remotion browser ensure downloads a Chrome Headless Shell whose version is compatible with your installed Remotion — so the container is fully self-contained and does not rely on a system Chrome being present.

FROM node:22-bookworm-slim

# Install the OS libraries that headless Chrome needs on Debian.
# Keep this list in sync with the official Dockerfile at remotion.dev/docs/docker.
RUN apt-get update && apt-get install -y \
  libnss3 libdbus-1-3 libatk1.0-0 libgbm-dev \
  libasound2 libxrandr2 libxkbcommon-dev libxfixes3 \
  libxcomposite1 libxdamage1 libatk-bridge2.0-0 \
  libpango-1.0-0 libcairo2 libcups2 \
  && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Install dependencies first so this layer stays cached across code changes.
COPY package.json package-lock.json* ./
RUN npm ci

# Copy the rest of the project: compositions, public assets, render script.
COPY . .

# Download a Chrome Headless Shell matching the installed Remotion version.
RUN npx remotion browser ensure

# The container's single job is to run the render entry script.
CMD ["node", "render.mjs"]

Two things make this image reliable. First, copying package.json and installing dependencies before copying the source keeps the dependency layer cached, so ordinary code changes rebuild fast. Second, baking npx remotion browser ensure into the image means the browser is present at build time — the container never has to download Chrome on its first render. Treat the exact apt-get list as something to copy from the current official Dockerfile rather than memorize; Remotion keeps that list authoritative and up to date.


Step 2 — The Render Script (render.mjs)

The CMD points at a small entry script. This is where the three server-side rendering calls live. Using an .mjs file lets you write top-level await cleanly.

import path from "node:path";
import { bundle } from "@remotion/bundler";
import { selectComposition, renderMedia } from "@remotion/renderer";

// 1. Bundle the project into a servable site. Do this once and reuse the URL.
const serveUrl = await bundle({
  entryPoint: path.resolve("src/index.ts"),
});

// 2. The data that parameterises this particular render.
const inputProps = {
  title: "Q3 Revenue Report",
  highlightColor: "#3b82f6",
};

// 3. Resolve the composition. selectComposition runs calculateMetadata,
//    so the real duration and dimensions reflect the inputProps.
const composition = await selectComposition({
  serveUrl,
  id: "MyComposition",
  inputProps,
});

// 4. Render every frame and encode the file to local disk.
await renderMedia({
  composition,
  serveUrl,
  codec: "h264",
  outputLocation: `out/${composition.id}.mp4`,
  inputProps,
  onProgress: ({ progress }) => {
    console.log(`Rendering: ${Math.round(progress * 100)}%`);
  },
});

console.log("Render complete.");

The flow reads top to bottom the way the docs describe it. bundle() compiles your compositions into a static site and returns a serveUrl. selectComposition() loads that bundle, finds the composition by its id, and returns its resolved metadata — this is also where a calculateMetadata function runs, so a data-driven composition reports the correct duration before rendering starts. renderMedia() then does the heavy lifting: it captures frames through the headless browser and encodes them with the chosen codec to outputLocation. The optional onProgress callback gives you a progress value from 0 to 1, which is useful for logs and status updates.


Passing inputProps

inputProps is how data flows into a render. It is any serialisable object, and you pass it to both selectComposition() and renderMedia() so the metadata resolution and the actual render see the same values. Inside the composition, your React components receive those props as regular component props — a customer name, a chart dataset, a color, a product SKU — and every render produces a video shaped by whatever you pass in. This is the mechanism that turns one composition into thousands of unique, personalized videos.

In a container pipeline, inputProps typically comes from the job payload: a queue message, an HTTP request body, or a row your renderer reads from a database. The render script stays the same; only the props change per job.


Turning the Container into an HTTP Render Service

A batch script is fine for scheduled jobs, but many teams want an endpoint they can call on demand. Conceptually, you wrap the same three calls in a small web server. The one detail that matters for performance: bundle once at startup and reuse the serveUrl for every request — bundling is the expensive part, and re-running it per request wastes work.

import path from "node:path";
import express from "express";
import { bundle } from "@remotion/bundler";
import { selectComposition, renderMedia } from "@remotion/renderer";

const app = express();
app.use(express.json());

// Bundle once when the process boots; reuse this serveUrl for all renders.
const serveUrl = await bundle({ entryPoint: path.resolve("src/index.ts") });

app.post("/render", async (req, res) => {
  const { compositionId, inputProps } = req.body;

  const composition = await selectComposition({
    serveUrl,
    id: compositionId,
    inputProps,
  });

  const outputLocation = `out/${composition.id}-${Date.now()}.mp4`;
  await renderMedia({
    composition,
    serveUrl,
    codec: "h264",
    outputLocation,
    inputProps,
  });

  res.json({ file: outputLocation });
});

app.listen(3000, () => console.log("Render service listening on :3000"));

This is the shape, not a production-ready service. In production you would not render synchronously inside the request — a single render can take real time. Instead, accept the job, push it onto a queue (BullMQ, SQS, or your own table), return a job ID immediately, and let a worker pool process renders with bounded concurrency. Upload the finished file to object storage and expose its URL or a webhook. The container stays the unit of deployment; the queue keeps it from being overwhelmed.


Operational Notes

A few realities decide whether a self-hosted render service is smooth or painful.

  • Chrome dependencies. Rendering fails at runtime if a Chrome shared library is missing from the image. This is the most common first-run problem. Copy the apt-get list from the official Dockerfile rather than trimming it by guesswork, and let npx remotion browser ensure provide the browser binary itself.
  • Memory. Headless Chrome plus frame encoding is memory-hungry, and the appetite scales with resolution and composition complexity — a 4K composition needs materially more headroom than a 720p one. Size the container generously and treat memory, not CPU, as the first thing to raise when renders die unexpectedly.
  • Shared memory in containers. Chrome uses /dev/shm, which is small by default in many container runtimes. Constrained shared memory is a classic cause of browser crashes inside Docker; increasing the container’s shared-memory allocation is the usual fix.
  • Concurrency. renderMedia() parallelises frame rendering internally and accepts a concurrency option to tune how many frames render at once — see Remotion performance optimization for tuning guidance. Separately, you control how many renders a container runs at the same time. Both consume memory, so raise them together under load testing — on a fixed machine, more concurrency shortens wall-clock time only until you saturate CPU or RAM.
  • Rendering backend. Some hosts need the browser’s OpenGL backend configured for headless Linux. Remotion exposes browser options for this; if you hit blank frames or GL errors on a particular host, consult the SSR and Docker docs for the current recommended setting rather than guessing at a value.

Decision Table: Self-Hosted Docker vs Lambda vs Managed SaaS

FactorSelf-hosted DockerRemotion LambdaManaged video SaaS
Where rendering runsYour infrastructureYour AWS accountThe vendor’s servers
Scaling modelFixed pool you size and scaleAutomatic, highly parallelHandled by the vendor
Best-fit load shapeSteady, predictable volumeBursty, spiky, on-demandAny, within plan limits
Cost profileFlat instance costPer-invocation, scales to zeroSubscription / render credits
Data residencyFully in your controlIn your AWS accountOn the vendor’s platform
Infra to manageContainer + host + queueIAM, functions, S3None
Vendor lock-inMinimal (portable image)Tied to AWSTied to the platform
Setup effortModerate (Dockerfile + service)Moderate (IAM + deploy)Lowest (API key)

None of these is the “right” answer in isolation. Self-hosting wins on control and steady-state cost; Lambda wins on elastic bursts; managed SaaS wins on time-to-first-render and zero operations. Match the tool to your load shape, your data requirements, and how much infrastructure your team wants to own.


Build Faster with RenderComp

A self-hosted render service is only as good as the compositions it renders. Heavy bundles, uncompressed assets, and inefficient React components cost you render time and memory on every job — the exact resources you are paying for on your own infrastructure.

RenderComp is a buy-once catalog of production-ready Remotion templates: intros, lower thirds, social formats, data visualizations, and product showcases, each shipping with clean TypeScript prop interfaces that slot straight into inputProps. Drop one into the render flow in this guide and you have a container that produces polished video from day one. Browse the full collection at rendercomp.com.

Frequently asked questions

Can Remotion run entirely inside a Docker container?

Yes. The render engine is a Node.js program that drives headless Chromium and FFmpeg, so it containerizes cleanly. Remotion publishes an official container guide built on the node:22-bookworm-slim base image.

Do I still need AWS to self-host Remotion with Docker?

No. Self-hosting is the alternative to Lambda. A Docker image runs on any container platform — a VPS, Google Cloud Run, Azure Container Apps, Cloudflare Containers, or your own cluster. AWS is only involved if you deliberately choose Lambda for its parallelism.

How does the container get a Chrome browser?

The Dockerfile runs npx remotion browser ensure during the build, which downloads a Chrome Headless Shell matching your Remotion version. The browser is baked into the image, so containers never download Chrome on their first render.

When should I choose Lambda over a self-hosted container?

When your load is bursty and unpredictable. Lambda splits a render across many concurrent functions and scales automatically, so it absorbs spikes that a fixed container pool would queue. For steady, predictable volume, a self-hosted container is simpler and its cost is easier to forecast.

What are the core APIs for server-side Remotion rendering?

Three, in order: bundle() from @remotion/bundler to compile the project, selectComposition() from @remotion/renderer to resolve the composition and its metadata, and renderMedia() from @remotion/renderer to produce the file.

How do I pass dynamic data into each render?

Use inputProps — a serialisable object passed to both selectComposition() and renderMedia(). Your composition receives it as React props, which is how one composition produces many different videos from different data.

How do I avoid re-bundling on every request in an HTTP render service?

Call bundle() once when the process starts and reuse the returned serveUrl for every render. Bundling is the expensive step; the per-request work is selectComposition() and renderMedia(). For heavy traffic, queue jobs and process them with a bounded worker concurrency rather than rendering inside the request.

Now available

Get 1,000+ Remotion Templates

Pay once — no subscription. Lifetime updates. TypeScript-first.

View pricing →