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
- AWS account with ECR, Lambda and S3; Docker with buildx.
- A static FFmpeg build for
x86_64orarm64(for example from johnvansickle.com or BtbN’s builds), or your own build with the codecs you need and a licence review. - An S3 bucket for sources and one prefix or bucket for outputs.
- An IAM role for the function with
s3:GetObjecton sources ands3:PutObjecton outputs.
Lambda limits that shape the job
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
/tmprather than piping stdin. Many containers (MP4 withmoovat 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-iinstead; 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
/tmpinfinally. Warm execution environments are reused, and/tmppersists between invocations. Without cleanup, a few large jobs fill ephemeral storage and later invocations fail withNo space left on device. -threads 0and 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
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.
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.