How to use FFmpeg in n8n without touching your Docker image

You followed a tutorial, added USER root and RUN apk add --no-cache ffmpeg to your n8n Dockerfile, and got /bin/sh: apk: not found. Or the build worked, the job exited 0, and the output video came back with captions that aren't there. Both failures come from platform changes from the last nine months, and the n8n feature request to bundle FFmpeg is still collecting replies, with new posts through September 23, 2026.
Quick answer: The FFmpeg n8n Docker question has a different answer depending on which container you extend. On self-hosted n8n 2.x, install FFmpeg plusfontconfig,ttf-dejavuandfont-noto-emojiinto then8nio/runnerssidecar image, pin it to the exact same tag as yourn8nio/n8nimage, and shell out from a Code node, because the Execute Command node is disabled by default from n8n 2.0. On n8n Cloud there is no Dockerfile to edit, so the only path is an HTTP Request node calling a cloud FFmpeg API such as FFmpeg Micro, which also removes the job of re-verifying your image on every n8n release.
Why `apk add ffmpeg` stopped working in the n8n image
The apk: not found error is not a bug and won't be fixed. Issue #23246 was filed on December 15, 2025 reporting that apk add --no-cache ffmpeg worked on n8n 2.0.2 and failed on 2.1.0. It was closed as not planned with no replacement recipe.
The community labeled the new base "distroless," and that label sends people toward the wrong fix. The official image is a Docker Hardened Alpine 3.24 with apk del apk-tools run against it. Everything else Alpine ships is still there: /etc/apk/repositories still lists the mirrors, and roughly 118 font files still sit under /usr/share/fonts, verified against n8nio/n8n:2.39.8 by the nknaeppe/n8n-ffmpeg test suite. You don't need to rebuild a package manager. You need a binary in a path, or a different container entirely.
Which container actually needs FFmpeg
FFmpeg has to live in whichever container executes your node's code, and in n8n 2.x that's usually not the one you think. The Execute Command node runs in the main or worker process, but it's disabled by default from n8n 2.0 and does not exist on n8n Cloud. One user who set the documented environment variable to bring it back reported in issue #23439 that the node never appeared in the selector, and n8n closed the issue as working-as-expected. Installing FFmpeg into the main image can buy you a binary you have no supported way to invoke. We covered that shift in n8n disabled the Execute Command node.
A JavaScript Code node calling child_process still works. In external task-runner mode, that code runs inside the runners sidecar, so the runners image is the one that needs the binary, the fonts, and the allow-list entry.
Option 1: FFmpeg in the n8nio/runners image
Putting FFmpeg in n8nio/runners is where the community thread settled after September 20, 2026, and it's the only Docker route that matches how n8n 2.x executes code. External mode needs n8n 1.111.0 or later, extending the runners image needs at least n8nio/runners:1.121.0, and per n8n's task-runner docs the runners image version must match the n8n image version.
Build the image
n8nio/runners is plain Alpine 3.24 with no fonts, so the font packages have to come in explicitly or every text filter fails silently.
FROM alpine:3.24 AS fonts
RUN apk add --no-cache fontconfig ttf-dejavu font-noto-emoji && fc-cache -f
FROM n8nio/runners:2.39.8
USER root
RUN apk add --no-cache ffmpeg
COPY --from=fonts /usr/share/fonts /usr/share/fonts
COPY --from=fonts /etc/fonts /etc/fonts
COPY n8n-task-runners.json /etc/n8n-task-runners.json
USER node
That last COPY is the step most guides miss. The official runners image hardcodes NODE_FUNCTION_ALLOW_BUILTIN and the other allow-lists in /etc/n8n-task-runners.json and silently discards the same values set as container environment variables, documented in the maksimkurb/n8n-ffmpeg README. Pull the original out of the image, add child_process to the allow-list, and bake the edited file back in:
docker run --rm --entrypoint cat n8nio/runners:2.39.8 \
/etc/n8n-task-runners.json > n8n-task-runners.json
Wire up compose
Both containers need the same auth token and the same volume, because the runner writes the output file and n8n reads it back.
services:
n8n:
image: n8nio/n8n:2.39.8
environment:
- N8N_RUNNERS_MODE=external
- N8N_RUNNERS_AUTH_TOKEN=${RUNNERS_TOKEN}
- N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
volumes:
- shared:/data/shared
runners:
image: my-runners-ffmpeg:2.39.8
environment:
- N8N_RUNNERS_AUTH_TOKEN=${RUNNERS_TOKEN}
- N8N_RUNNERS_TASK_BROKER_URI=http://n8n:5679
volumes:
- shared:/data/shared
volumes:
shared:
Burn the captions
Write the video and the .srt to /data/shared with a Read/Write Files node, then run one Code node:
const { execFileSync } = require('child_process');
const dir = '/data/shared';
execFileSync('ffmpeg', [
'-y', '-i', `${dir}/input.mp4`,
'-vf', `subtitles=${dir}/captions.srt:force_style='FontName=DejaVu Sans,FontSize=24'`,
'-c:a', 'copy',
`${dir}/captioned.mp4`,
], { stdio: 'pipe' });
return [{ json: { file: `${dir}/captioned.mp4` } }];
Why your text overlays come out blank
A subtitle or drawtext filter with no usable font does not error. It exits with code 0 and hands you frames with no text, which is how Biboo described it in the n8n thread on September 21, 2026. The missing pieces on Alpine are fontconfig, ttf-dejavu and font-noto-emoji, and libass needs a real fontconfig cache, so fc-cache -f belongs in the same RUN layer.
The Fontconfig error: Cannot load default config file line in the logs is a warning, not the cause of the blank frames. We took that apart in FFmpeg fontconfig error isn't fatal: install the packages, rebuild the cache, and name the font explicitly with force_style='FontName=DejaVu Sans' instead of trusting the default.
Option 2: copy a static FFmpeg into the main image
The older answer in the thread, from July 3, 2026, is a multi-stage build that copies a prebuilt static binary over the hardened base. It's two lines and gives you a newer FFmpeg than Alpine's 8.1.2, at a cost of roughly 340 MB.
FROM mwader/static-ffmpeg:9.0.1 AS ffmpeg
FROM n8nio/n8n:2.39.8
USER root
COPY --from=ffmpeg /ffmpeg /usr/local/bin/ffmpeg
COPY --from=ffmpeg /ffprobe /usr/local/bin/ffprobe
USER node
Two things break this over time. The base image changed at 2.1.0 without a useful release note, so every tag bump means re-running your own smoke test instead of assuming the copy still lands in a writable path. And jumping the binary from 8.x to 9.0.1 changes flag behavior: if your filter strings came from older tutorials, read FFmpeg 9 breaking changes before you pin that tag. This route also inherits the Execute Command problem, because a binary in the main image is only reachable from a node you probably can't enable.
Option 3: call a cloud FFmpeg API from the HTTP Request node
On n8n Cloud there are no custom binaries and no shell access, so the HTTP Request node is the only option that exists. It's also the only one of the three that survives an n8n upgrade untouched, since your video step no longer depends on the image you're running.
The workflow for the same caption job is four nodes:
- Trigger with the video's URL. Pass the URL, never the bytes, so a 500 MB clip never lands in n8n's memory. Stop downloading from S3 covers the presigned-URL pattern.
- HTTP Request node, POST, submitting the job.
- Wait node set to 10 seconds, then an HTTP Request node polling job status, or a Webhook node if you'd rather be called back.
- Download or hand the output URL straight to your upload node.
| HTTP Request field | Value |
|---|---|
| Method | POST |
| URL | the jobs endpoint from the [FFmpeg Micro docs](https://www.ffmpeg-micro.com/docs) |
| Authentication | Generic Credential Type, Header Auth, your API key |
| Send Body | on, JSON |
| Body | the source video URL plus the caption step |
| Response | job ID to poll |
FFmpeg Micro runs the encoder, the fonts, and the fontconfig cache on its side, so the blank-caption failure and the Dockerfile re-verification both stop being yours. The Auto Captions blueprint is the one-click version of the job above, transcribe then burn in with a review step, and the Playground prints the request body you paste into the node. There's a free tier to test the workflow before wiring it into anything.
For long jobs, set the HTTP Request node's timeout deliberately and take a webhook instead of polling forever. Patterns are in webhooks for long-running video jobs.
The three paths side by side
Each route answers a different constraint, so where you run n8n and who maintains the image decides most of it.
| Runners image | Static copy in main image | HTTP Request | |
|---|---|---|---|
| Works on n8n Cloud | No | No | Yes |
| Node you invoke it from | Code node | Execute Command (off by default) | HTTP Request |
| Extra image size | ~150 MB | ~340 MB | none |
| Breaks on n8n upgrade | Tag must match n8n exactly | Yes, re-verify each release | No |
| Fonts you must install | fontconfig, ttf-dejavu, font-noto-emoji | usually present, still test | none |
| Where the CPU burn lands | your box | your box | the API |
Pitfalls that bite after the build works
Most of the time lost here goes to failures that look like something else. Check these first:
- Runners and n8n on different tags. Drift causes protocol errors that read like auth problems.
- The shared volume mounted into only one container, so n8n reports a missing file the runner definitely wrote.
NODE_FUNCTION_ALLOW_BUILTINset in compose and ignored, because the runners image reads/etc/n8n-task-runners.json.- No
fc-cache -fafter installing fonts. Packages present, text still blank. - Routing a 200 to 500 MB file through n8n's own nodes. That's the live complaint in n8n's large-file threads, and no Dockerfile fixes it.
A self-hostable media toolkit container is a reasonable middle ground if you want the whole pipeline on your own hardware and you're happy owning the queue, worker tuning, and storage config. If you started this because you didn't want to babysit an encoder, that trade goes the wrong way.
FAQ
Can I use FFmpeg on n8n Cloud at all?
FFmpeg itself cannot run on n8n Cloud, because Cloud allows no custom binaries and no shell access. An HTTP Request node calling a cloud FFmpeg API gives you the same operations, including subtitle burn-in and watermarking, with no image to maintain.
Does `NODES_EXCLUDE=[]` bring the Execute Command node back?
Setting NODES_EXCLUDE=[] is the documented way to re-enable the Execute Command node, but it doesn't always work: the reporter in issue #23439 found the node still missing from the selector, and n8n closed the issue as working-as-expected. Plan on a Code node with child_process instead.
Why does my subtitle filter succeed but produce no text?
A subtitles= or drawtext filter with no usable font exits 0 and writes frames with no text rather than failing. Install fontconfig plus ttf-dejavu and font-noto-emoji, run fc-cache -f in the same layer, and set the font name in force_style.
Do the n8n and runners images have to be the same version?
The n8nio/runners image version must match the n8nio/n8n image version, per n8n's task-runner docs, so pin both to an exact tag such as 2.39.8 and never latest. Bumping one without the other is the most common cause of a runners setup that worked yesterday.
Is the official n8n image distroless?
The official n8n image is not distroless. It's a Docker Hardened Alpine 3.24 with apk-tools deleted, which is why apk is gone while /etc/apk/repositories and the system fonts are still in place.
Whichever route you pick, the caption job is the same three inputs: a video, a subtitle file, and a font that exists. If you'd rather not maintain the third one, sign up free and run it as one API call from the HTTP Request node you already have on the canvas.
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.
You might also like

Convert MP4 to GIF in n8n Without a Community Node
n8n MP4 to GIF without a community node or custom Docker image: a webhook workflow that passes a video URL, runs the palette method, and returns a GIF link.

n8n Disabled the Execute Command Node. Your FFmpeg Needs HTTP.
n8n 2.0 disables the Execute Command node by default, breaking FFmpeg workflows on upgrade. How to diagnose it, re-enable it, and replace it for good.

How to Add Chapters to MP4 with FFmpeg (FFMETADATA)
To make FFmpeg add chapters you write an FFMETADATA file and remux with -c copy. The hard part is generating that file: TIMEBASE, escaping, and the last END.
Skip the command line
The Auto Captions blueprint transcribes your video and burns the captions in. You just review the transcript.
Run it (free)