# Save FFmpeg output to an S3 bucket

Canonical: https://rendobar.com/blog/ffmpeg-output-to-s3/
Author: Abdelrahman Essawy
Published: 2026-09-03
Updated: 2026-09-13

---

## Key takeaways

- A regular MP4 can't be streamed into S3. FFmpeg writes the index after the media and seeks back for it, so a pipe stops with "muxer does not support non seekable output".
- A fragmented MP4 streams, and it cost nothing to switch. The same 5-second encode came out 598,786 bytes with frag_keyframe+empty_moov against 599,026 for the default.
- MPEG-TS is the container people reach for when a pipe fails, and it is the one that costs. The encode grew to 643,900 bytes, 7.5% more than the MP4.
- Writing to disk first is the only pipe-free route where +faststart still works, and it needs local disk as big as the output. On Lambda that means /tmp between 512 MB and 10,240 MB inside a 900-second run.
- Letting the job deliver the file takes the write out of your code. Each bucket gets its own webhook event, and a failed delivery retries without re-running the 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.

_Three routes into a bucket. The first two run wherever FFmpeg runs, the third moves the write into the job._

## 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:

```bash
ffmpeg -i sample.mp4 -c copy -f mp4 pipe:1 > /dev/null
```

```text
[mp4 @ 000001ee2e2e8100] muxer does not support non seekable output
[out#0/mp4 @ 000001ee2e247cc0] Could not write header (incorrect codec parameters ?): Invalid argument
```

The 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.

| 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.

```ts
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:

```bash
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/mp4
```

`aws 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](/changelog/connected-storage/)
lists every provider it works with. FFmpeg writes a real file inside the job, so `+faststart` works again.

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);`}
  curl={`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"]
    }'`}
/>

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.

_One row per destination. A failure says what went wrong in plain words and retries without re-running the job._

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:

```json
{
  "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](/docs/storage/amazon-s3) 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](/blog/process-s3-video-ffmpeg/) covers the other half of the trip. If your
links into S3 stop working before their expiry, see [why S3 presigned URLs expire
early](/blog/s3-presigned-url-expires-early/). And for the encode settings that
decide how big the file is in the first place, see [FFmpeg encoding settings
compared](/blog/ffmpeg-encoding-settings/).
