ffmpegdockerdevops

FFmpeg in Docker: the Dockerfile Isn't the Hard Part

·Javid Jamae·10 min read
FFmpeg in Docker: the Dockerfile Isn't the Hard Part

You need FFmpeg inside a container. The Dockerfile is four lines and it works on the first try. Then the image is 300 MB, the build takes 25 minutes on CI, and your first real encode gets killed with exit code 137.

Quick answer: The shortest working ffmpeg docker setup is FROM alpine:3.22 plus RUN apk add --no-cache ffmpeg, which gives you a container with FFmpeg 7.1 and ffprobe on the PATH in about 100 MB on disk. Use a Debian or Ubuntu base if you need a specific FFmpeg version or non-free codecs, and use a multi-stage build only if you're compiling from source, since apk add already installs runtime libraries only.

Conventional wisdom says containerizing FFmpeg solves the dependency problem. It does. What it doesn't solve is everything that made you want to containerize in the first place: the encode still needs CPU you have to pay for, disk you have to mount, a queue so a 20-minute job doesn't block your API, and a retry story for when the worker dies at minute 18. The Dockerfile is the easy 10%.

The shortest FFmpeg Dockerfile that works

Alpine's community repository ships an ffmpeg package, so the whole build is one apk add. This is the correct starting point for 90% of use cases.

FROM alpine:3.22

RUN apk add --no-cache ffmpeg

ENTRYPOINT ["ffmpeg"]

Build it and run a real conversion against a mounted directory:

docker build -t my-ffmpeg .

docker run --rm -v "$PWD:/work" -w /work my-ffmpeg \
  -i input.mov -c:v libx264 -preset veryfast -crf 23 -c:a aac -b:a 128k output.mp4

The --no-cache flag matters. Without it, apk leaves its package index in /var/cache/apk, which adds a few megabytes of pure waste to the layer. The -w /work sets the working directory so relative paths in your FFmpeg command resolve against the mount, not against /.

If you want ffprobe as your entrypoint sometimes and ffmpeg other times, drop the ENTRYPOINT line and pass the full command instead. Both binaries come from the same package. For what ffprobe can tell you before you spend CPU on a transcode, see inspecting video metadata with ffprobe.

Which base image for your FFmpeg Docker build

Base image choice is really a choice about FFmpeg version and codec availability, not about size. What each option gives you:

Base imageBase size (on disk)FFmpeg versionNotes
`alpine:3.22`~8 MB7.1.xmusl libc, smallest total, community repo
`alpine:3.20`~8 MB6.1.xpin here if you need FFmpeg 6 behavior
`debian:bookworm-slim`~75 MB5.1.xolder FFmpeg, glibc, best binary compatibility
`ubuntu:24.04`~78 MB6.1.xglibc, good NVIDIA/CUDA driver story
`jrottenberg/ffmpeg:7-alpine`prebuilt7.xmaintained image, wide codec set, no build step

The Alpine tradeoff is musl libc instead of glibc. That bites you if you drop in a precompiled binary from elsewhere, or link FFmpeg into a Node or Python app with native modules built against glibc. If any part of your stack assumes glibc, take the 75 MB hit and use debian:bookworm-slim.

The Debian tradeoff is FFmpeg 5.1, which is from 2022. Filters added in FFmpeg 6 and 7 won't be there. Check ffmpeg -filters | grep yourfilter inside the container before you commit to a base.

The image-size trap

Two numbers get quoted for the same image and they differ by roughly 3x. Docker Hub displays compressed size, which is what you download. docker images displays uncompressed on-disk size, which is what fills your host. A jrottenberg/ffmpeg Alpine tag listed at 30 to 35 MB on Docker Hub lands closer to 100 MB when you actually pull it.

So "FFmpeg in Alpine is only 35 MB" is the wire number, not the disk number. Verify with:

docker images my-ffmpeg --format '{{.Repository}}:{{.Tag}} {{.Size}}'

The second half of the trap hits when apk add ffmpeg lacks the codec you need and you compile from source. A build stage with build-base, nasm, yasm, pkgconf, and the -dev packages for x264, x265, libvpx, opus, and lame adds 400 to 600 MB of toolchain. A full ./configure && make runs 20 to 40 minutes on 2 vCPUs, and it runs again on every CI cache miss. You traded a 4-line Dockerfile for a build that costs more in CI minutes per month than most managed video APIs cost outright.

A multi-stage FFmpeg Dockerfile (and when it actually helps)

Multi-stage builds do not shrink apk add --no-cache ffmpeg. The package manager already installs runtime shared libraries only, and copying just /usr/bin/ffmpeg into a scratch stage produces a binary that immediately dies with Error loading shared library libavcodec.so.61 because Alpine's FFmpeg is dynamically linked against roughly 30 libraries.

Multi-stage pays off when what you're copying is static. Fetch a prebuilt static binary in stage one and ship only that:

FROM alpine:3.22 AS fetch
RUN apk add --no-cache curl xz tar
RUN curl -fsSL -o /tmp/ff.tar.xz \
      https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz \
 && mkdir -p /tmp/ff && tar -xJf /tmp/ff.tar.xz -C /tmp/ff --strip-components=1 \
 && install -m 0755 /tmp/ff/bin/ffmpeg /tmp/ff/bin/ffprobe /out/ 2>/dev/null \
 || (mkdir -p /out && install -m 0755 /tmp/ff/bin/ffmpeg /tmp/ff/bin/ffprobe /out/)

FROM alpine:3.22
RUN apk add --no-cache gcompat font-dejavu fontconfig
COPY --from=fetch /out/ffmpeg /out/ffprobe /usr/local/bin/
RUN adduser -D -u 10001 ffuser
USER ffuser
ENTRYPOINT ["ffmpeg"]

Two things to note. gcompat is Alpine's glibc compatibility shim; the BtbN builds are static, but adding it costs under 1 MB and removes a whole class of "file not found" errors that are really loader errors. And USER ffuser matters because the default is root, which means every file FFmpeg writes to your mounted volume comes out owned by root on the host.

Common pitfalls when running FFmpeg in a container

These are the failures that show up after the Dockerfile builds clean.

  • Exit code 137. That's 128 + SIGKILL(9): the kernel OOM killer took your process. Docker Desktop's VM has its own memory ceiling, and any --memory limit applies to the whole container. Heavy filter graphs and 4K decode blow past 512 MB fast. Raise the limit or lower the resolution before you blame FFmpeg. The same failure mode on hosted platforms is covered in self-hosting FFmpeg on Railway.
  • drawtext and subtitles fail with "Cannot load default config file". The container has no fonts. Add font-dejavu and fontconfig on Alpine, or fonts-dejavu-core and fontconfig on Debian. Full filter syntax is in the drawtext guide.
  • Unknown encoder 'libfdk_aac'. No distro package ships it. Building with --enable-libfdk-aac requires --enable-nonfree, and a nonfree FFmpeg binary cannot be legally redistributed, which means you can't push that image to a public registry. Use the native aac encoder at 128k to 192k instead; the quality gap at those bitrates is small.
  • exec format error on deploy. You built on an Apple Silicon Mac, so the image is arm64 and your server is amd64. Build with docker build --platform=linux/amd64 -t my-ffmpeg . or use docker buildx build --platform linux/amd64,linux/arm64.
  • The container fills its disk mid-encode. Intermediate files, two-pass logs, and segment output all land in the container's writable layer unless you mount a volume. Mount one at /tmp and give it real space.
  • Long jobs block your HTTP handler. A 20-minute encode inside a request cycle will hit a proxy timeout long before it finishes. You need a queue and a callback, which is the same pattern described in the n8n timeout fix.

What the container still doesn't give you

The Dockerfile is done in ten minutes. The production system around it is not. To run FFmpeg in Docker for real work, you also need a job queue so encodes don't run inline, worker autoscaling because CPU demand is spiky and encode CPU is the whole bill, persistent or object storage since container filesystems are ephemeral, retry and idempotency logic for jobs killed mid-encode, and monitoring that tells you a worker died at minute 18 of a 20-minute job. That's a media microservice. Most teams containerizing FFmpeg didn't set out to build one.

The full arithmetic on when that's worth owning is in FFmpeg in the cloud vs running FFmpeg yourself, and the same wall shows up with a different shape on serverless in FFmpeg on AWS Lambda.

Docker container vs one API call

Same job, both ways:

FFmpeg in DockerFFmpeg Micro
SetupDockerfile, registry, base-image pinningAPI key
Image size100 MB to 700 MB depending on codecsnone
Long jobsyour queue, your retriessubmit job, poll or webhook
Scalingyou size the workersno servers to run
Codec updatesrebuild and redeploymanaged

The API version of the transcode above is a single request:

curl -X POST https://api.ffmpeg-micro.com/v1/jobs \
  -H "Authorization: Bearer $FFMPEG_MICRO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input": "https://example.com/input.mov",
    "operation": "transcode",
    "options": { "format": "mp4", "video_codec": "h264", "crf": 23 },
    "webhook_url": "https://yourapp.com/hooks/ffmpeg"
  }'

You get a job ID back, poll it or wait for the webhook, then download the output. Exact payload fields for each operation are in the docs. The same jobs run from n8n, Make, and Zapier, or from your AI agents through the MCP server.

When to keep the container: you're batch-processing on hardware you already pay for, you need a codec or hardware acceleration path that a hosted API doesn't expose, media can't legally leave your network, or your volume is low enough that a cron job on an existing box is genuinely simpler. Those are real reasons. "I don't want to install FFmpeg locally" is not one of them.

FAQ

Which Docker image should I use for FFmpeg?

Use alpine:3.22 with apk add --no-cache ffmpeg for FFmpeg 7.1 in the smallest footprint, or jrottenberg/ffmpeg:7-alpine if you want a maintained prebuilt image with a wider codec set and no build step. Switch to debian:bookworm-slim if anything else in your image needs glibc.

How big is an FFmpeg Docker image?

An Alpine base with the ffmpeg package lands around 100 MB on disk, though Docker Hub will report roughly 30 to 35 MB because it shows compressed size. Debian and Ubuntu bases start at 75 to 78 MB before FFmpeg, and a from-source build with full codec support can exceed 700 MB unless you use a multi-stage build to drop the toolchain.

Why does my FFmpeg container exit with code 137?

Exit 137 is 128 plus signal 9, meaning the kernel OOM killer terminated FFmpeg because the container hit its memory limit. Raise the limit with docker run --memory=2g, reduce the working resolution, or split the filter graph into stages.

Can I run FFmpeg in Docker on an Apple Silicon Mac?

Yes, and apk add ffmpeg works natively on arm64. The problem is deploying that image to an amd64 server, which fails with exec format error. Build with --platform=linux/amd64 or produce a multi-arch image with docker buildx.

Do I need Docker at all just to run FFmpeg jobs?

Only if you want to own the workers. If the goal is a video step inside an app or an automation, a hosted FFmpeg API removes the image, the queue, and the scaling work entirely, and you call it the same way from code, from n8n, or from an agent.

If your Dockerfile is the easy part and the queue is what you're dreading, skip both. Start on the free tier and see what the FFmpeg API does with your first encode.

About Javid Jamae

Founder & CEO at FFmpeg Micro

Javid is a software engineer, author, and entrepreneur with over 25 years of professional software development experience across enterprise, startup, and consulting environments. He founded FFmpeg Micro to make video processing accessible to developers through a simple, automation-first REST API.

Software EngineeringVideo ProcessingFFmpegCloud ArchitectureAPI DesignAutomation

Ready to process videos at scale?

Start using FFmpeg Micro's simple API today. No infrastructure required.

Get Started Free