remotion lambda cicd github-actions aws automation

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 nameValue
AWS_ACCESS_KEY_IDYour IAM user’s access key ID
AWS_SECRET_ACCESS_KEYYour IAM user’s secret access key
AWS_REGIONe.g., us-east-1
REMOTION_FUNCTION_NAMEThe deployed Lambda function name
REMOTION_SERVE_URLThe 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:

ParameterTypeDescription
regionstringAWS region matching your Lambda function
functionNamestringExact Lambda function name from deployFunction()
serveUrlstringS3 URL of your bundled site from deploySite()
compositionIdstringThe id of the composition in your root file
inputPropsobjectAny serializable props passed to your composition
codecstring"h264"
imageFormatstring"jpeg" (faster) or "png" (supports transparency)
maxRetriesnumberHow many times failed chunks retry
privacystring"public" (accessible URL) or "private" (S3 key only)
framesPerLambdanumberControls 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:

FieldTypeDescription
donebooleanTrue when render is complete
overallProgressnumber0 to 1, combined render + encode progress
fatalErrorEncounteredbooleanTrue if render failed permanently
errorsarrayError objects when fatalErrorEncountered is true
outputFilestring | nullPublic URL or S3 key of the output (when done)
outputSizeInBytesnumber | nullFile size in bytes (when done)
renderedFramesnumberFrames rendered so far
encodedFramesnumberFrames encoded so far
costsobjectEstimated 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 parallelism
  • framesPerLambda: 20 → 15 Lambda invocations, balanced
  • framesPerLambda: 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/lambda package 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 →