CI/CD Pipeline for Automated Video Rendering with Remotion Lambda
CI/CD Pipeline for Automated Video Rendering with Remotion Lambda
A Remotion Lambda deployment without CI/CD is like a production database without backups — it works until it doesn’t, and the moment it fails is the worst possible time. When you are generating videos programmatically at scale, a code change that breaks the render pipeline can mean hundreds of failed jobs, missed deliveries, and debugging sessions at 2 AM.
A proper CI/CD pipeline solves this: your Lambda function and site bundle are deployed automatically when code merges to main, renders are triggered by external events (a Stripe purchase, a form submission, a cron schedule) rather than manual commands, and failed renders are caught and retried without human intervention.
This guide builds the complete picture: GitHub Actions workflows for deploying Remotion Lambda on code change, environment variable management for AWS credentials in CI, webhook-triggered rendering patterns, renderMediaOnLambda and getRenderProgress API usage, framesPerLambda tuning for cost optimization, and monitoring patterns for production render jobs.
Architecture Overview
Before diving into code, here is the complete system:
┌─────────────────────────────────────────────────────────────────┐
│ GitHub Repository │
│ ├── src/ ← Remotion compositions │
│ ├── .github/workflows/ ← CI/CD pipelines │
│ └── scripts/ ← Deploy and render scripts │
└─────────────────────────────────────────────────────────────────┘
│ push to main
▼
┌─────────────────────────────────────────────────────────────────┐
│ GitHub Actions │
│ 1. deploy-lambda.yml → deploys Lambda function + site bundle │
│ 2. render-on-event.yml → triggered by webhook, renders video │
└─────────────────────────────────────────────────────────────────┘
│ deployFunction() + deploySite()
▼
┌─────────────────────────────────────────────────────────────────┐
│ AWS │
│ ├── Lambda function ← Remotion render worker │
│ ├── S3 bucket ← Site bundle + rendered output │
│ └── CloudWatch ← Logs and monitoring │
└─────────────────────────────────────────────────────────────────┘
│ renderMediaOnLambda()
▼
┌─────────────────────────────────────────────────────────────────┐
│ External triggers │
│ ├── Stripe webhook → purchase → personalized video │
│ ├── Cron schedule → nightly batch renders │
│ └── REST API → on-demand render endpoint │
└─────────────────────────────────────────────────────────────────┘
Step 1: Project Structure
Organize your Remotion project to support CI/CD cleanly:
remotion-project/
├── src/
│ ├── index.ts # Remotion root — registers all compositions
│ ├── compositions/
│ │ ├── WelcomeVideo.tsx
│ │ └── ReportVideo.tsx
│ └── components/ # Shared React components
├── scripts/
│ ├── deploy.ts # Deploy Lambda function + site bundle
│ └── render.ts # Programmatic render script
├── .github/
│ └── workflows/
│ ├── deploy.yml # Runs on push to main
│ └── render.yml # Runs on workflow_dispatch or webhook
├── package.json
└── tsconfig.json
Step 2: AWS Credentials in GitHub Actions
Store AWS credentials as GitHub Actions secrets. In your repository, go to Settings → Secrets and variables → Actions → New repository secret and add:
| Secret name | Value |
|---|---|
AWS_ACCESS_KEY_ID | Your IAM user’s access key ID |
AWS_SECRET_ACCESS_KEY | Your IAM user’s secret access key |
AWS_REGION | e.g., us-east-1 |
REMOTION_FUNCTION_NAME | The deployed Lambda function name |
REMOTION_SERVE_URL | The S3 serve URL from deploySite() |
The IAM user needs the policy generated by npx remotion lambda policies user. Never put credentials directly in workflow files — secrets are encrypted and masked in logs.
For larger teams, consider using OIDC (OpenID Connect) federation instead of long-lived access keys. GitHub Actions supports OIDC trust relationships with AWS IAM, allowing the workflow to assume an IAM role without static credentials. The Remotion Lambda IAM setup works identically with either approach.
Step 3: Deploy Workflow — Runs on Code Change
This workflow deploys the Lambda function and re-bundles the site whenever code is pushed to the main branch:
# .github/workflows/deploy.yml
name: Deploy Remotion Lambda
on:
push:
branches:
- main
paths:
- "src/**"
- "package.json"
- "package-lock.json"
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Deploy Lambda function
run: npx remotion lambda functions deploy --yes
- name: Deploy site bundle
run: |
npx remotion lambda sites create src/index.ts \
--site-name=my-video \
--yes \
| tee /tmp/deploy-output.txt
- name: Extract and store serve URL
run: |
SERVE_URL=$(grep -oP 'https://[^\s]+index\.html' /tmp/deploy-output.txt | head -1)
echo "Serve URL: $SERVE_URL"
# In production: write to SSM Parameter Store or an environment file
# aws ssm put-parameter --name /remotion/serve-url --value "$SERVE_URL" --overwrite
Why deploy the function on every push?
The Remotion Lambda function name encodes the exact version of Remotion used (e.g., remotion-render-4-0-272-mem2048mb-disk2048mb-120sec). When you upgrade Remotion, you need a new function deployment. Running functions deploy on every push is idempotent — if the function already exists with the correct parameters, the command returns quickly. It is safer than trying to detect version changes explicitly.
The --yes flag skips interactive confirmation prompts, which would cause the CI job to hang indefinitely waiting for input.
Step 4: Programmatic Deploy Script
For more control — writing the serve URL to a configuration store, sending a Slack notification, or updating a database record — use the Node.js API instead of the CLI:
// scripts/deploy.ts
import { deployFunction, deploySite, getOrCreateBucket } from "@remotion/lambda";
import path from "path";
const REGION = process.env.AWS_REGION ?? "us-east-1";
const SITE_NAME = "my-video";
async function deploy(): Promise<void> {
console.log("Deploying Lambda function...");
const { functionName } = await deployFunction({
region: REGION,
timeoutInSeconds: 120,
memorySizeInMb: 2048,
createCloudWatchLogGroup: true,
// Remotion derives the function name from its own version automatically
});
console.log("Function deployed:", functionName);
console.log("Creating S3 bucket if needed...");
const { bucketName } = await getOrCreateBucket({ region: REGION });
console.log("Deploying site bundle...");
const { serveUrl } = await deploySite({
bucketName,
entryPoint: path.resolve("./src/index.ts"),
region: REGION,
siteName: SITE_NAME,
});
console.log("Site deployed:", serveUrl);
// Write the serve URL somewhere persistent:
// - AWS SSM Parameter Store
// - A .env file committed separately
// - A database record
// - A GitHub Actions output variable
process.env.REMOTION_SERVE_URL = serveUrl;
console.log("REMOTION_SERVE_URL set to", serveUrl);
}
deploy().catch((err) => {
console.error(err);
process.exit(1);
});
Run this in CI with npx ts-node scripts/deploy.ts or compile and run with Node directly.
Step 5: The renderMediaOnLambda API
With the infrastructure deployed, rendering is triggered by calling renderMediaOnLambda. This function is asynchronous and non-blocking — it returns a renderId immediately, and you poll for completion separately.
import { renderMediaOnLambda, getRenderProgress } from "@remotion/lambda";
interface RenderParams {
compositionId: string;
inputProps: Record<string, unknown>;
outName?: string;
}
async function triggerRender(params: RenderParams): Promise<string> {
const { renderId, bucketName } = await renderMediaOnLambda({
region: process.env.AWS_REGION ?? "us-east-1",
functionName: process.env.REMOTION_FUNCTION_NAME!,
serveUrl: process.env.REMOTION_SERVE_URL!,
compositionId: params.compositionId,
inputProps: params.inputProps,
codec: "h264",
imageFormat: "jpeg",
maxRetries: 1,
privacy: "public",
outName: params.outName ?? `render-${Date.now()}.mp4`,
// framesPerLambda controls parallelism — discussed below
framesPerLambda: 20,
});
console.log(`Render started — renderId: ${renderId}, bucket: ${bucketName}`);
return renderId;
}
Key renderMediaOnLambda parameters:
| Parameter | Type | Description |
|---|---|---|
region | string | AWS region matching your Lambda function |
functionName | string | Exact Lambda function name from deployFunction() |
serveUrl | string | S3 URL of your bundled site from deploySite() |
compositionId | string | The id of the composition in your root file |
inputProps | object | Any serializable props passed to your composition |
codec | string | "h264" |
imageFormat | string | "jpeg" (faster) or "png" (supports transparency) |
maxRetries | number | How many times failed chunks retry |
privacy | string | "public" (accessible URL) or "private" (S3 key only) |
framesPerLambda | number | Controls concurrency — see below |
Step 6: Polling Progress with getRenderProgress
renderMediaOnLambda is fire-and-forget. To know when the render is complete and where the output file is, poll getRenderProgress:
import { getRenderProgress } from "@remotion/lambda";
async function waitForRender(
renderId: string,
bucketName: string,
functionName: string,
region: string,
pollIntervalMs = 2000
): Promise<string> {
while (true) {
const progress = await getRenderProgress({
renderId,
bucketName,
functionName,
region,
});
if (progress.done) {
console.log("Render complete:", progress.outputFile);
console.log("File size:", progress.outputSizeInBytes, "bytes");
return progress.outputFile!;
}
if (progress.fatalErrorEncountered) {
const errors = progress.errors.map((e) => e.message).join("; ");
throw new Error(`Render failed: ${errors}`);
}
const pct = Math.round(progress.overallProgress * 100);
console.log(`Progress: ${pct}%`);
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
}
The getRenderProgress response includes:
| Field | Type | Description |
|---|---|---|
done | boolean | True when render is complete |
overallProgress | number | 0 to 1, combined render + encode progress |
fatalErrorEncountered | boolean | True if render failed permanently |
errors | array | Error objects when fatalErrorEncountered is true |
outputFile | string | null | Public URL or S3 key of the output (when done) |
outputSizeInBytes | number | null | File size in bytes (when done) |
renderedFrames | number | Frames rendered so far |
encodedFrames | number | Frames encoded so far |
costs | object | Estimated AWS cost breakdown for this render |
Step 7: Webhook-Triggered Rendering — Stripe Purchase Example
The most common production pattern is rendering a personalized video when a specific business event occurs. A Stripe purchase is a concrete example: a customer completes checkout, Stripe sends a webhook, your server renders a personalized welcome video, and the video URL is emailed or embedded in the post-purchase flow.
Here is a complete Express webhook handler:
import express from "express";
import Stripe from "stripe";
import { renderMediaOnLambda, getRenderProgress } from "@remotion/lambda";
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// Stripe sends raw body for signature verification
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), async (req, res) => {
const sig = req.headers["stripe-signature"] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return res.status(400).send("Webhook Error");
}
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
const customerName = session.customer_details?.name ?? "Valued Customer";
const customerEmail = session.customer_details?.email ?? "";
const productName = session.metadata?.product_name ?? "Your Purchase";
// Fire render asynchronously — do not block the webhook response
renderPersonalizedWelcomeVideo({
customerName,
customerEmail,
productName,
sessionId: session.id,
}).catch(console.error);
}
res.json({ received: true });
});
async function renderPersonalizedWelcomeVideo(data: {
customerName: string;
customerEmail: string;
productName: string;
sessionId: string;
}): Promise<void> {
const { renderId, bucketName } = await renderMediaOnLambda({
region: process.env.AWS_REGION!,
functionName: process.env.REMOTION_FUNCTION_NAME!,
serveUrl: process.env.REMOTION_SERVE_URL!,
compositionId: "WelcomeVideo",
inputProps: {
customerName: data.customerName,
productName: data.productName,
accentColor: "#10b981",
},
codec: "h264",
imageFormat: "jpeg",
maxRetries: 2,
privacy: "public",
outName: `welcome-${data.sessionId}.mp4`,
framesPerLambda: 20,
});
// Poll for completion
const outputUrl = await waitForRender(
renderId,
bucketName,
process.env.REMOTION_FUNCTION_NAME!,
process.env.AWS_REGION!
);
// Send email with video URL (using your email provider)
await sendWelcomeEmail({
to: data.customerEmail,
videoUrl: outputUrl,
customerName: data.customerName,
});
console.log(`Welcome video sent to ${data.customerEmail}: ${outputUrl}`);
}
Important: Always respond to the webhook immediately (res.json({ received: true })) before starting the render. Stripe expects a 200 response within a few seconds. The render runs asynchronously after the response is sent.
Step 8: GitHub Actions Render Workflow
For scenarios where rendering is triggered from CI — a scheduled batch job, a manual dispatch, or a workflow called from another pipeline:
# .github/workflows/render.yml
name: Render Video on Demand
on:
workflow_dispatch:
inputs:
composition_id:
description: "Remotion composition ID"
required: true
default: "WelcomeVideo"
customer_name:
description: "Customer name for personalization"
required: false
default: "Test Customer"
schedule:
# Run nightly batch render at 2 AM UTC
- cron: "0 2 * * *"
jobs:
render:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Render video
env:
REMOTION_FUNCTION_NAME: ${{ secrets.REMOTION_FUNCTION_NAME }}
REMOTION_SERVE_URL: ${{ secrets.REMOTION_SERVE_URL }}
AWS_REGION: ${{ secrets.AWS_REGION }}
COMPOSITION_ID: ${{ github.event.inputs.composition_id || 'NightlyReport' }}
CUSTOMER_NAME: ${{ github.event.inputs.customer_name || '' }}
run: npx ts-node scripts/render.ts
Step 9: Cost Optimization with framesPerLambda
framesPerLambda is the single most impactful parameter for balancing render speed against cost. It controls how many frames each Lambda invocation is responsible for.
Lower framesPerLambda = more concurrent Lambdas = faster wall-clock time, same or slightly higher total cost.
Higher framesPerLambda = fewer concurrent Lambdas = slower but potentially lower Lambda invocation overhead.
For a 300-frame (10-second at 30fps) video:
framesPerLambda: 10→ 30 Lambda invocations, maximum parallelismframesPerLambda: 20→ 15 Lambda invocations, balancedframesPerLambda: 50→ 6 Lambda invocations, minimal overhead
Remotion has a minimum of 4 frames per Lambda. The default, when framesPerLambda is not specified, is computed automatically based on composition length.
Practical guidance:
For production pipelines where latency matters (user is waiting for their video), use a low framesPerLambda (10–20) to maximize parallelism and minimize wall-clock time.
For batch pipelines where throughput matters more than per-render latency (nightly report generation), use a higher framesPerLambda (40–80) to reduce Lambda invocation counts and simplify CloudWatch monitoring.
Use estimatePrice() from @remotion/lambda to estimate cost before committing to a configuration:
import { estimatePrice } from "@remotion/lambda";
const estimate = estimatePrice({
region: "us-east-1",
memorySizeInMb: 2048,
durationInMilliseconds: 3000, // estimated per-chunk render time
concurrency: 15, // number of concurrent Lambdas
renderingTimeInMilliseconds: 2000,
});
console.log("Estimated cost:", estimate.priceInDollars.toFixed(6), "USD");
Step 10: Monitoring Render Jobs
CloudWatch Logs
Set createCloudWatchLogGroup: true when calling deployFunction(). Every Lambda invocation writes logs to CloudWatch. Use these logs to:
- Trace failed renders to specific frame chunks
- Profile render time per chunk
- Alert on error rates via CloudWatch Alarms
Tracking renders in a database
For production pipelines with many concurrent renders, maintain a render jobs table:
// Pseudo-code — adapt to your ORM/database
await db.renderJobs.create({
id: renderId,
compositionId,
inputProps: JSON.stringify(inputProps),
status: "pending",
startedAt: new Date(),
bucketName,
});
// During polling:
await db.renderJobs.update(renderId, {
status: "rendering",
progress: Math.round(progress.overallProgress * 100),
});
// On completion:
await db.renderJobs.update(renderId, {
status: "complete",
outputUrl,
completedAt: new Date(),
costEstimate: progress.costs?.estimatedCost,
});
Alerting on failures
A failed render (where progress.fatalErrorEncountered is true) should trigger an alert. Common causes are:
- Asset URLs that time out during rendering (use pre-fetched or CDN-served assets)
- Lambda memory exhaustion on complex compositions (increase
memorySizeInMb) - Version mismatch between the deployed Lambda function and the
@remotion/lambdapackage version in your CI environment
For Slack or PagerDuty alerting, wire the error path in your polling loop to a notification call:
if (progress.fatalErrorEncountered) {
await sendSlackAlert({
channel: "#render-alerts",
message: `Render failed: ${renderId}\nErrors: ${progress.errors.map(e => e.message).join(", ")}`,
});
throw new Error("Render failed");
}
FAQ
Q: How do I store the serve URL so the render workflow always has the latest version?
Store it in AWS SSM Parameter Store: write it with aws ssm put-parameter in the deploy workflow and read it with aws ssm get-parameter in the render workflow. This keeps the two workflows decoupled — the render workflow always gets the most recently deployed URL without needing to rebuild the site.
Q: What happens if a Lambda chunk fails mid-render?
Remotion retries failed chunks according to maxRetries. If all retries are exhausted, progress.fatalErrorEncountered becomes true and progress.errors contains the chunk error details. A render can continue even if non-fatal errors occur in individual chunks, depending on the error type.
Q: Can I run the deploy and render workflows in the same job? You can, but separating them is better practice. Deploy runs on code change; render runs on business events. Combining them means every code push triggers a render, which is rarely what you want.
Q: How do I handle concurrency limits in AWS Lambda?
By default, AWS accounts have a regional Lambda concurrency limit of 1,000 concurrent executions. For Remotion, each render can use up to (total_frames / framesPerLambda) concurrent invocations. A 300-frame video with framesPerLambda: 10 uses 30 concurrent invocations per render. If you need to run many renders simultaneously, request a concurrency limit increase from AWS Support.
Q: Can I use this pipeline with Remotion Cloud Run instead of Lambda?
Yes. @remotion/cloudrun provides renderMediaOnCloudRun() and getRenderProgress() with a similar API. Replace the Lambda-specific setup steps with the Cloud Run equivalents documented at docs.remotion.dev/cloudrun. The GitHub Actions workflow structure is identical.
Q: What is the maximum video length that can be rendered with Lambda?
Lambda functions have a 15-minute maximum execution timeout. Remotion works around this by splitting the video into chunks — each chunk renders in one Lambda invocation. The total video length is not theoretically limited; a 60-minute video is just more chunks. However, very long compositions increase total render cost, S3 storage, and stitching time. The Remotion team recommends keeping individual Lambda invocations under 120 seconds by tuning framesPerLambda.
Q: How do I pass secure data (API keys, customer PII) to a composition via inputProps?
inputProps is serialized and stored temporarily in S3 by the Lambda infrastructure during render. Do not pass raw secrets or sensitive PII directly. Instead, pass a reference (a customer ID, a signed URL that expires after the render completes) and resolve the sensitive data inside the composition only if necessary. For most personalization use cases (customer name, product name, purchase amount), the data is not sensitive enough to require additional handling.
Accelerate Your Pipeline with RenderComp Templates
The infrastructure and pipeline patterns in this guide work with any Remotion composition. But composition quality — how efficiently the React components render, how clean the prop interface is, how well the animations hold up across different data — directly affects Lambda execution time and therefore cost.
RenderComp provides a library of production-optimized Remotion templates designed for Lambda pipelines: lightweight bundles, clean inputProps interfaces, and JPEG-friendly designs that render fast per frame. Each template is tested for Lambda compatibility and ships with TypeScript types ready to wire into the render workflows above.
Browse templates at rendercomp.com and skip the composition-building phase entirely.
Ready to ship
Get 700+ Remotion Templates
Lifetime license. TypeScript-first. Ship polished video in minutes, not days.
Get RenderComp →