Running FFmpeg in AWS Lambda with Container Images

Build a Lambda container image from public.ecr.aws/lambda/nodejs:20 (or the Python base), copy in a static FFmpeg build for the matching architecture, and invoke it with child_process.spawn from the handler. Give the function 3–10 GB of memory (CPU scales with memory), 2–10 GB of ephemeral /tmp, and a 15-minute timeout; download the source from S3 to /tmp (or pass FFmpeg a presigned URL for inputs it can seek over HTTP), write outputs to /tmp, upload them, and clean up. Reserve Lambda for jobs that finish comfortably inside 15 minutes — thumbnails, short clips, audio, image derivatives — and route anything longer to ECS, Batch or MediaConvert.

Lambda is attractive for media processing because it scales to zero and to thousands of concurrent jobs without managing a fleet. Container images remove the old 250 MB layer limit, so a full FFmpeg with the codecs you need fits comfortably. The constraints are fixed and well known: 15 minutes, 10 GB of memory, 10 GB of /tmp, and no GPU. This page belongs to post-upload media transcoding in backend validation and cloud storage architecture; it is typically triggered as in queueing transcode jobs with SQS and Lambda.

When to use this approach

  • Jobs are short: thumbnails, previews, audio transcodes, clips under a few minutes, image processing.
  • Upload volume is spiky and you want no idle cost.
  • You need FFmpeg features or codecs that managed services like MediaConvert do not offer.

Prerequisites

  1. AWS account with ECR, Lambda and S3; Docker with buildx.
  2. A static FFmpeg build for x86_64 or arm64 (for example from johnvansickle.com or BtbN’s builds), or your own build with the codecs you need and a licence review.
  3. An S3 bucket for sources and one prefix or bucket for outputs.
  4. An IAM role for the function with s3:GetObject on sources and s3:PutObject on outputs.

Lambda limits that shape the job

Lambda limits relevant to FFmpeg jobs Lambda allows up to 15 minutes per invocation, up to 10240 MB of memory with up to 6 vCPUs allocated proportionally, up to 10240 MB of ephemeral storage in tmp, a 10 GB container image, and no GPU. Jobs that need longer, more disk or a GPU belong elsewhere. Five numbers decide whether a job fits 15 minutes hard timeout 10 GB memory 6 vCPU at max 10 GB /tmp input + outputs 10 GB image plenty for FFmpeg 0 GPUs CPU encoding only A 1080p H.264 encode at "veryfast" on 6 vCPU runs roughly 1–3× real time; so a full ladder for a 10-minute video does not fit, while thumbnails and previews finish in seconds.
Measure your own encode speed per vCPU, then pick which jobs Lambda runs.

Implementation

Dockerfile:

FROM public.ecr.aws/lambda/nodejs:20

# Static FFmpeg for the image's architecture; pin the version and verify the checksum.
ARG FFMPEG_URL=https://johnvansickle.com/ffmpeg/releases/ffmpeg-7.0.2-amd64-static.tar.xz
ARG FFMPEG_SHA256=replace-with-published-checksum
RUN dnf install -y xz tar && \
    curl -fsSL "$FFMPEG_URL" -o /tmp/ff.tar.xz && \
    echo "$FFMPEG_SHA256  /tmp/ff.tar.xz" | sha256sum -c - && \
    tar -xJf /tmp/ff.tar.xz -C /tmp && \
    mv /tmp/ffmpeg-*-static/ffmpeg /tmp/ffmpeg-*-static/ffprobe /usr/local/bin/ && \
    rm -rf /tmp/ff* && dnf clean all

COPY package*.json ${LAMBDA_TASK_ROOT}/
RUN npm ci --omit=dev --prefix ${LAMBDA_TASK_ROOT}
COPY dist/ ${LAMBDA_TASK_ROOT}/
CMD ["handler.handler"]

handler.ts:

import { S3Client, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import { spawn } from "node:child_process";
import { createWriteStream, createReadStream } from "node:fs";
import { mkdtemp, rm, stat } from "node:fs/promises";
import { pipeline } from "node:stream/promises";
import { join } from "node:path";
import type { Readable } from "node:stream";

const s3 = new S3Client({});
const OUT_BUCKET = process.env.OUT_BUCKET!;

function ffmpeg(args: string[], deadlineMs: number): Promise<void> {
  return new Promise((resolve, reject) => {
    const p = spawn("/usr/local/bin/ffmpeg", ["-hide_banner", "-nostdin", "-y", ...args], { stdio: ["ignore", "ignore", "pipe"] });
    let tail = "";
    p.stderr.on("data", (d) => { tail = (tail + d).slice(-4000); });
    const timer = setTimeout(() => p.kill("SIGKILL"), deadlineMs);
    p.on("close", (code) => { clearTimeout(timer); code === 0 ? resolve() : reject(new Error(`ffmpeg exited ${code}: ${tail}`)); });
  });
}

export async function handler(event: { bucket: string; key: string; jobId: string }, ctx: { getRemainingTimeInMillis(): number }) {
  const dir = await mkdtemp("/tmp/job-");
  try {
    const src = join(dir, "src");
    const obj = await s3.send(new GetObjectCommand({ Bucket: event.bucket, Key: event.key }));
    await pipeline(obj.Body as Readable, createWriteStream(src));

    const thumb = join(dir, "thumb.jpg");
    const preview = join(dir, "preview.mp4");
    const budget = ctx.getRemainingTimeInMillis() - 30_000;   // leave time to upload and clean up

    await ffmpeg([
      "-i", src,
      "-map", "0:v:0", "-vf", "thumbnail=150,scale=640:-2", "-frames:v", "1", "-q:v", "3", thumb,
      "-map", "0:v:0", "-map", "0:a:0?", "-t", "20",
      "-vf", "scale=-2:480", "-c:v", "libx264", "-preset", "veryfast", "-crf", "26",
      "-c:a", "aac", "-b:a", "96k", "-movflags", "+faststart", "-threads", "0", preview,
    ], budget);

    for (const [file, key, type] of [[thumb, "thumb.jpg", "image/jpeg"], [preview, "preview.mp4", "video/mp4"]]) {
      const { size } = await stat(file);
      await s3.send(new PutObjectCommand({
        Bucket: OUT_BUCKET, Key: `derived/${event.jobId}/${key}`,
        Body: createReadStream(file), ContentLength: size, ContentType: type,
      }));
    }
    return { ok: true };
  } finally {
    await rm(dir, { recursive: true, force: true });            // /tmp persists across warm invocations
  }
}

Deploy:

docker buildx build --platform linux/amd64 --provenance=false -t $ECR/ffmpeg-lambda:1.4.0 --push .
aws lambda create-function --function-name media-preview \
  --package-type Image --code ImageUri=$ECR/ffmpeg-lambda:1.4.0 \
  --role arn:aws:iam::123456789012:role/media-preview \
  --memory-size 6144 --ephemeral-storage Size=4096 --timeout 900 \
  --environment Variables="{OUT_BUCKET=media-derived}"

Line-by-line on the decisions that matter

  • Static FFmpeg, pinned and checksummed. The Lambda base images are Amazon Linux without FFmpeg packages. A static build has no shared-library dependencies. Pinning the version and verifying its checksum makes builds reproducible and protects against a tampered download.
  • Download to /tmp rather than piping stdin. Many containers (MP4 with moov at the end, MOV) need seekable input; piping forces FFmpeg to buffer or fail. For very large inputs where only the start is needed — a thumbnail — pass a presigned URL as -i instead; FFmpeg reads with range requests and skips the download.
  • One FFmpeg process, two outputs. Decoding is often the expensive half. Producing the thumbnail and the preview from a single decode saves time and memory.
  • Deadline from getRemainingTimeInMillis. Killing FFmpeg before Lambda’s hard timeout gives the handler time to report a clean failure. A Lambda killed by timeout leaves no error from your code, just a timeout in the logs.
  • Cleaning /tmp in finally. Warm execution environments are reused, and /tmp persists between invocations. Without cleanup, a few large jobs fill ephemeral storage and later invocations fail with No space left on device.
  • -threads 0 and memory sizing. FFmpeg uses all available cores; Lambda allocates vCPUs proportionally to memory (about one per 1,769 MB). Below 1,769 MB you get a fraction of a core and encodes crawl.

Choosing where each job runs

Routing media jobs between Lambda, containers and managed services A router looks at probed duration, resolution and job type. Thumbnails, previews, audio and short clips go to Lambda. Full encoding ladders and long videos go to ECS Fargate or AWS Batch. Broadcast formats and DRM packaging go to MediaConvert. Route by estimated work, not by habit job router probe + estimate Lambda thumbnails, previews, audio, clips under ~3 min Fargate / Batch full ABR ladders, long videos, 4K, large disks MediaConvert broadcast inputs, DRM packaging, managed scaling
The same container image can run on Lambda and Fargate, so routing is a deployment decision, not a rewrite.

Use the probe from validating video uploads with ffprobe to estimate work: duration × pixels ÷ your measured encode rate. Send jobs expected to take less than half the timeout to Lambda, the rest to containers. Building the handler so it also runs as a plain container entrypoint (read the job from an environment variable or SQS) lets one image serve both paths; only the invocation differs. For heavy managed transcoding, see transcoding video with AWS Elemental MediaConvert.

Cost and concurrency

Lambda bills memory × duration. A preview job at 6 GB for 20 seconds costs a fraction of a cent, and thousands can run in parallel during an upload burst. The account’s concurrency limit (1,000 by default per region) is shared with every other function, so set reserved concurrency on the media function — high enough for bursts, low enough that a flood of uploads cannot starve your API functions. An SQS queue in front absorbs spikes: Lambda polls it and scales consumers up gradually, and jobs wait in the queue instead of failing with throttling errors.

Arm64 (Graviton) functions cost about 20 percent less per GB-second, and x264 and FFmpeg’s decoders are well optimised for Arm. Build a separate arm64 image with an arm64 static FFmpeg, benchmark your typical job on both, and choose per function.

Memory setting versus encode time and cost for one preview job At 2 GB memory the preview job takes 75 seconds. At 6 GB it takes 22 seconds. At 10 GB it takes 16 seconds. Because cost is memory times duration, 6 GB is cheaper than 2 GB and close to 10 GB, while finishing three times faster. More memory is often cheaper, because it is faster 2 GB · ~1.2 vCPU 75 s 150 GB-s 6 GB · ~3.5 vCPU 22 s 132 GB-s 10 GB · 6 vCPU 16 s 160 GB-s Illustrative numbers for a 20 s 480p preview from a 1080p source; benchmark your own mix.
CPU-bound work gets cheaper per job up to the point where extra cores stop helping.

Configuration gotchas

Runtime.InvalidEntrypoint or exec format error. The image was built for the wrong architecture. Build with --platform linux/amd64 (or arm64) to match the function, and use --provenance=false because Lambda rejects multi-manifest images from some buildx versions.

No space left on device after some invocations. /tmp from previous jobs was not cleaned, or ephemeral storage is smaller than input plus outputs. Clean in finally and size storage for the largest accepted input.

Cold starts of several seconds. Large images take longer to initialise the first time. Keep the image lean (no build tools), and consider provisioned concurrency only if latency matters — for background jobs it rarely does.

Encodes are much slower than on a laptop. Check the memory setting; at the default 128 MB or even 1 GB, FFmpeg gets a sliver of CPU. Also avoid -preset slow in Lambda; the time budget favours faster presets with a slightly higher CRF.

Verification

# Invoke with a sample and confirm outputs and duration.
aws lambda invoke --function-name media-preview \
  --payload '{"bucket":"media-uploads","key":"fixtures/phone.mp4","jobId":"test-1"}' \
  --cli-binary-format raw-in-base64-out out.json && cat out.json
aws s3 ls s3://media-derived/derived/test-1/
aws logs tail /aws/lambda/media-preview --since 5m | grep -E 'REPORT|Duration'

Frequently Asked Questions

Can I use a Lambda layer instead of a container image?

Yes for small, audio-only or image-only builds under the 250 MB unzipped limit. Container images are simpler once you need several codecs or ffprobe alongside FFmpeg.

Does Lambda support hardware-accelerated encoding?

No; there are no GPUs or media accelerators. Use EC2 or ECS on GPU instances, or a managed service, when hardware encoding matters.

How do I handle jobs that exceed 15 minutes?

Split them — segment the input and encode segments in parallel functions, then concatenate — or route them to containers. Splitting works well for simple encodes but complicates ladders with consistent keyframes; containers are usually simpler.