Save FFmpeg output to an S3 bucket
Send FFmpeg output to an S3 bucket three ways. A pipe-safe fragmented MP4 came out 240 bytes smaller than the default MP4 on the same measured encode.
You have an FFmpeg command that works on your laptop and a bucket it needs to land in. The obvious move is to point the output at S3 and let it stream. For an MP4 that fails, and the error it prints does not mention S3 at all.
Short version. A regular MP4 can’t be streamed into S3, because FFmpeg writes the index last and seeks back to put it in place. There are three ways around that: write to disk and upload, pipe a fragmented MP4 into aws s3 cp -, or have the job deliver the finished file to the bucket for you. We measured what the pipe-safe flags cost on a real encode, and the answer is nothing. The fragmented MP4 came out 240 bytes smaller than the default one.
Why FFmpeg can’t stream a regular MP4
An MP4 keeps its index, the moov box, separately from the media. FFmpeg does not know how many frames it will write until it has written them, so it streams the media first and then goes back to write the index. That second step is a seek.
A file on disk can seek. A pipe can’t, and neither can an upload that is already on its way to S3. So the plain version of the obvious command stops before it writes a single frame. This is the same stream copy run against a local pipe with FFmpeg 8.0:
ffmpeg -i sample.mp4 -c copy -f mp4 pipe:1 > /dev/null[mp4 @ 000001ee2e2e8100] muxer does not support non seekable output[out#0/mp4 @ 000001ee2e247cc0] Could not write header (incorrect codec parameters ?): Invalid argumentThe second line blames codec parameters. The first line is the real cause.
+faststart doesn’t rescue it. It moves the index to the front of a finished file in a second pass, and FFmpeg’s own documentation says it “will not work in various situations such as fragmented output”. A pipe never produces a finished file to make a second pass over.
What does work is a fragmented MP4. empty_moov writes an index with no samples in it at the very start, and frag_keyframe starts a new fragment at every keyframe, so each piece of media arrives with its own small index and nothing ever needs to be revisited. Add both flags and set the format explicitly, because pipe:1 has no extension for FFmpeg to guess from. The same stream copy then went through the pipe and wrote 351,761 bytes.
What the pipe-safe flags cost
The worry with fragmenting is overhead: an index per fragment instead of one for the whole file. So we encoded the same 5 seconds six ways on the Rendobar API, holding the codec, the CRF and a 2-second keyframe interval fixed, and changed only the container.
| Variant | Encode | Size | Cost |
|---|---|---|---|
| moov at end (default) | 612 ms | 585.0 KB | $0.0032 |
| +faststart | 567 ms | 585.0 KB | $0.0032 |
| frag_keyframe+empty_moov | 639 ms | 584.8 KB | $0.0023 |
| +default_base_moof | 597 ms | 584.7 KB | $0.0022 |
| MPEG-TS | 525 ms | 628.8 KB | $0.0022 |
| Matroska | 518 ms fastest | 583.7 KB | $0.0037 |
| Container | Bytes | Against the default MP4 | Streams through a pipe |
|---|---|---|---|
| MP4, moov at end (default) | 599,026 | 0 | No |
MP4, +faststart | 599,026 | 0 | No |
MP4, frag_keyframe+empty_moov | 598,786 | 240 smaller | Yes |
MP4, +default_base_moof added | 598,698 | 328 smaller | Yes |
| Matroska | 597,724 | 1,302 smaller | Yes |
| MPEG-TS | 643,900 | 44,874 larger (7.5%) | Yes |
The fragmented MP4 was not larger at all. On a file this short one full index describing every frame costs slightly more than a few fragment headers, so the streamable version came out ahead by 240 bytes. +faststart produced exactly the same byte count as the default, which makes sense: it moves the index, it doesn’t change it.
MPEG-TS is the one that costs. It wraps everything in small fixed-size packets, each with its own header, and on this encode that added 44,874 bytes. It is the container people often switch to when a pipe fails, because it was built for broadcast streams. For a file headed into a bucket, fragmented MP4 or Matroska gets you the same streamability without the 7.5%.
Route 1: write to disk, then upload
The route with no surprises. FFmpeg writes a real file, so every flag works, +faststart included, and the upload is a separate step you can retry on its own.
import { spawn } from "node:child_process";import { createReadStream } from "node:fs";import { S3Client } from "@aws-sdk/client-s3";import { Upload } from "@aws-sdk/lib-storage";
await new Promise((resolve, reject) => { const ff = spawn("ffmpeg", [ "-i", "input.mp4", "-c:v", "libx264", "-crf", "23", "-c:a", "aac", "-movflags", "+faststart", "/tmp/out.mp4", ]); ff.on("error", reject); ff.on("close", (code) => (code === 0 ? resolve(undefined) : reject(new Error(`ffmpeg exited ${code}`))));});
// Upload sends one PUT for a small body and switches to multipart for a large one.await new Upload({ client: new S3Client({}), params: { Bucket: "my-bucket", Key: "exports/out.mp4", Body: createReadStream("/tmp/out.mp4"), ContentType: "video/mp4", },}).done();The cost is disk, and on serverless that is the constraint that bites. A single PUT tops out at 5 GB, which the multipart path in Upload handles, with parts between 5 MiB and 5 GiB and at most 10,000 of them. The file still has to exist somewhere first. On AWS Lambda that somewhere is /tmp, configurable between 512 MB and 10,240 MB, and the whole encode plus upload has to finish inside the 900-second function timeout.
Route 2: pipe a fragmented MP4 to S3
No disk, one line, and the flags from the table above:
ffmpeg -i input.mp4 -c:v libx264 -crf 23 -c:a aac \ -movflags frag_keyframe+empty_moov -f mp4 pipe:1 \ | aws s3 cp - s3://my-bucket/exports/out.mp4 --content-type video/mp4aws s3 cp - reads the stream and uploads it in parts as it arrives. Above 50 GB it needs --expected-size in bytes, or the upload can run out of its 10,000 parts partway through.
Two things to know before you build on it. A pipe can’t be replayed, so if the upload fails at minute 40 the retry is the whole encode, not just the upload. And there is no +faststart on this route, because nothing ever writes a finished file to move the index around in.
Both routes share one property that is easy to miss. They run wherever FFmpeg runs, so that machine needs credentials that can write to the bucket.
Route 3: let the job deliver the file
The third route moves the write out of your code. You connect the bucket once, add destinations to the job, and the output is delivered after the job completes. The connected storage changelog entry lists every provider it works with. FFmpeg writes a real file inside the job, so +faststart works again.
Read from a connected bucket and deliver back into it. media is the connection's ID.
import { createClient } from "@rendobar/sdk";
const rb = createClient({ apiKey: process.env.RENDOBAR_API_KEY });
const job = await rb.jobs.create({ type: "ffmpeg", inputs: { source: "storage://media/raw/clip.mp4" }, params: { command: "ffmpeg -i source -c:v libx264 -crf 23 -c:a aac -movflags +faststart out.mp4", }, // A folder keeps the connection's file name pattern, so this lands at exports/clip.mp4. destinations: ["storage://media/exports"],});
console.log(job.id);Install with npm i @rendobar/sdk. jobs.run() submits and waits, so it returns the finished job in one call.
curl -X POST https://api.rendobar.com/jobs \ -H "Authorization: Bearer $RENDOBAR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "ffmpeg", "inputs": { "source": "storage://media/raw/clip.mp4" }, "params": { "command": "ffmpeg -i source -c:v libx264 -crf 23 -c:a aac -movflags +faststart out.mp4" }, "destinations": ["storage://media/exports"] }'Returns immediately with a job id. Poll GET /jobs/{id} or register a webhook rather than blocking on the request.
A destination is storage://<id> with an optional path. A bare ID uses the connection’s output path, which defaults to rendobar/{date}/{source_name}.{ext}. A folder keeps that pattern’s file name inside the folder. A path containing a token such as {job_id}, or ending in a file name, is used exactly as written. When a file with that name already exists, the connection either keeps both by adding a short suffix or replaces it, whichever you picked.
The file also stays downloadable from the job whether or not the delivery succeeds, so a bucket that rejects a write does not cost you the encode.

Knowing the file arrived is a webhook rather than a poll. Each bucket fires its own job.delivery_succeeded or job.delivery_failed, and job.deliveries_settled fires once when every destination has finished, which is the one to wait on when a pipeline has a next step. Abridged, a success looks like this:
{ "event": "job.delivery_succeeded", "data": { "jobId": "job_8f3c2a91d4b04e17", "storageId": "media", "status": "delivered", "path": "exports/clip.mp4" }}A failed delivery retries on its own first. If it still fails, it reports a reason such as destination_denied rather than the raw bucket error, and rb.jobs.retryDeliveries(job.id) tries it again from the finished output.
Which route to pick
| Write, then upload | Pipe to S3 | Deliver from the job | |
|---|---|---|---|
| Local disk | As large as the output | None | None |
+faststart | Works | Not possible | Works |
| When the upload fails | Upload the file again | Run the encode again | Retries from the output |
| Bucket credentials | Wherever FFmpeg runs | Wherever FFmpeg runs | In the connection, not in your code |
| Knowing it landed | Your own code | Your own code | One webhook per bucket |
If you already run FFmpeg on a machine with room to spare, route 1 is fine and you should keep it. Route 2 earns its place when disk is the thing you don’t have and the files are small enough that re-running a failed encode is cheap. Route 3 is for when you would rather not hold bucket credentials next to FFmpeg at all, or when more than one bucket needs the same output.
Where this stops
The container comparison is one 5-second 720p encode. A fragmented MP4 adds a small index per fragment, so an hour of video with a 2-second keyframe interval carries far more of them than this clip did. We measured that the short case is not larger, not that a long file stays smaller.
The encode times in the table are a single sample each. At half a second they are noise, so read the sizes, which are exact, and ignore the timing differences.
The error text comes from FFmpeg 8.0 run locally. Older builds word it differently, but the cause is the same seek.
Route 3 needs the bucket connected first, which the Amazon S3 storage guide walks through, and it delivers the job’s output file. It does not stream an arbitrary pipe into S3 for you.
When the source already lives in the bucket too, process video from an S3 bucket covers the other half of the trip. If your links into S3 stop working before their expiry, see why S3 presigned URLs expire early. And for the encode settings that decide how big the file is in the first place, see FFmpeg encoding settings compared.
Frequently asked questions
Can FFmpeg write directly to an S3 URL?
Not as a regular MP4. FFmpeg has no s3 protocol, and an MP4 cannot be written as a stream because the muxer seeks back to write its index. A fragmented MP4 written to stdout and piped into aws s3 cp works, and so does writing a file first and uploading it.
What does FFmpeg's 'muxer does not support non seekable output' error mean?
The MP4 muxer needs to jump back to the start of the file to write the moov index, and a pipe or an upload cannot jump back. Add -movflags frag_keyframe+empty_moov and set -f mp4 so FFmpeg writes a fragmented MP4 that never seeks.
Does -movflags +faststart work when piping to S3?
No. faststart is a second pass that moves the index to the front of a finished file, and FFmpeg's documentation says it will not work with fragmented output. It works when FFmpeg writes a real file, which includes a job that delivers its output afterwards.
Do I need --expected-size with aws s3 cp from stdin?
Only for streams larger than 50 GB. The AWS CLI does not know how long a stream is, and past 50 GB it can run out of its 10,000 upload parts unless --expected-size tells it the size in bytes.
How do I know when a delivered file has arrived in the bucket?
Subscribe to job.delivery_succeeded and job.delivery_failed, which fire once per bucket, or wait for job.deliveries_settled, which fires once when every destination has finished. The job also lists each delivery with the path it was written to.
