ffmpegvideo-streamingapi

Create an HLS stream with FFmpeg, no media server needed

·Javid Jamae·10 min read
Create an HLS stream with FFmpeg, no media server needed

You have MP4s in a bucket and a player that wants an .m3u8. Every guide you find either shows a 40-flag FFmpeg command with no explanation of what breaks, or points you at a streaming platform that wants your video files to live inside it forever. Neither helps when all you need is a playlist and some segments sitting next to your other static assets.

Quick answer: To create an HLS stream, FFmpeg needs -f hls -hls_time 6 -hls_playlist_type vod, which writes an .m3u8 playlist plus numbered .ts segments you can serve from any static bucket or CDN. That works for one file on your laptop. Doing it for every upload means running an encoder box, watching jobs that outlive your request timeout, and re-uploading hundreds of small files, so most teams send the MP4 to a hosted FFmpeg API like FFmpeg Micro and get the playlist and segments back as one job.

HLS is a text file and a pile of small video files

An HLS stream is not a protocol your server has to speak. It's a plain text playlist (.m3u8) listing chunk filenames in order, plus those chunks (.ts for MPEG-TS, or .m4s for fragmented MP4). The player downloads the playlist over ordinary HTTP, then pulls chunks one at a time. Nothing on the server side is doing anything clever.

That's why the NGINX-RTMP tutorials mislead people. RTMP ingest matters when a live encoder is pushing frames at you in real time. For video-on-demand, which is what most SaaS products have, the whole delivery layer is static file hosting. If your bucket can serve a PNG, it can serve HLS.

Multi-bitrate works the same way. A master playlist lists other playlists, each with a BANDWIDTH and RESOLUTION attribute, and the player picks one based on measured throughput. Adaptive bitrate is a client-side decision. Your job is only to produce the variants and label them honestly.

The FFmpeg command that writes an m3u8 playlist

A single-rendition VOD stream takes one command. Apple's HLS Authoring Specification recommends a 6-second target duration, and keyframes have to land on segment boundaries or the segmenter will produce chunks of uneven length.

ffmpeg -i input.mp4 \
  -c:v libx264 -preset veryfast -crf 21 \
  -g 60 -keyint_min 60 -sc_threshold 0 \
  -c:a aac -b:a 128k -ac 2 \
  -f hls \
  -hls_time 6 \
  -hls_playlist_type vod \
  -hls_segment_filename "seg_%03d.ts" \
  index.m3u8

At 30fps, -g 60 puts a keyframe every 2 seconds, so a 6-second segment is exactly three GOPs. -sc_threshold 0 stops x264 from inserting extra keyframes at scene changes, which would otherwise scatter your GOP boundaries. -hls_playlist_type vod writes every segment into the playlist and adds the #EXT-X-ENDLIST tag that tells the player the stream is complete.

Building a bitrate ladder with var_stream_map

Three renditions from one pass takes -var_stream_map, which assigns encoded streams to output variants and writes a master playlist alongside them. The bitrates below follow the ladder shape in Apple's authoring spec: 800 kbps at 360p, 2.8 Mbps at 720p, 5 Mbps at 1080p.

mkdir -p stream_0 stream_1 stream_2

ffmpeg -i input.mp4 \
  -filter_complex "[0:v]split=3[v1][v2][v3]; \
    [v1]scale=w=640:h=360[v1out]; \
    [v2]scale=w=1280:h=720[v2out]; \
    [v3]scale=w=1920:h=1080[v3out]" \
  -map "[v1out]" -c:v:0 libx264 -b:v:0 800k  -maxrate:v:0 856k  -bufsize:v:0 1200k \
  -map "[v2out]" -c:v:1 libx264 -b:v:1 2800k -maxrate:v:1 2996k -bufsize:v:1 4200k \
  -map "[v3out]" -c:v:2 libx264 -b:v:2 5000k -maxrate:v:2 5350k -bufsize:v:2 7500k \
  -g 60 -keyint_min 60 -sc_threshold 0 \
  -map a:0 -map a:0 -map a:0 -c:a aac -b:a 128k -ac 2 \
  -f hls -hls_time 6 -hls_playlist_type vod -hls_flags independent_segments \
  -hls_segment_filename "stream_%v/data%03d.ts" \
  -master_pl_name master.m3u8 \
  -var_stream_map "v:0,a:0 v:1,a:1 v:2,a:2" \
  "stream_%v/playlist.m3u8"

The output volume surprises people the first time. A 10-minute source at 6-second segments produces 100 segments per rendition, so this command writes 300 .ts files, three variant playlists, and one master playlist. Storage runs about 60 MB, 210 MB, and 375 MB for the three video renditions plus roughly 10 MB of audio each, so you're holding around 1.7x what the single 1080p MP4 would have cost you.

Swap -hls_segment_type fmp4 -hls_fmp4_init_filename init.mp4 if you want CMAF-style fragmented MP4 instead of MPEG-TS. You need that for HEVC or AV1, since MPEG-TS can't carry AV1 at all.

Serving HLS from a CDN without a media server

Upload the whole output directory to a bucket, keeping the folder structure intact, and point your player at the master playlist URL. Segment URIs inside a playlist are resolved relative to that playlist's own location, so if you flatten stream_0/data000.ts into the bucket root, playback dies with 404s on every chunk.

Two headers decide whether it plays anywhere other than your own laptop. Set Content-Type: application/vnd.apple.mpegurl on .m3u8 files and video/mp2t on .ts files, because S3 and most object stores default new uploads to binary/octet-stream and Safari refuses to treat that as a playlist. Then set Access-Control-Allow-Origin on the bucket, since hls.js and Video.js fetch segments with XHR and the browser will block cross-origin reads without it. Safari on iOS plays HLS natively; every other browser needs hls.js or a player that bundles it.

One API job instead of an encoder you own

Running the ladder command yourself is fine until it becomes infrastructure. A 10-minute 1080p source is three encodes in one pass, which on a small cloud VM often takes longer than the video itself, and that's well past the point where an HTTP request has given up. AWS Lambda caps you at 15 minutes and gives you 512 MB of writable /tmp by default, which a 300-file ladder will fill. We wrote up the real cost of running FFmpeg yourself and what self-hosting on Railway does to you when the disk turns out to be ephemeral.

The alternative is to keep the FFmpeg behavior and drop the hosting. With the FFmpeg Micro API, the shape is the same three steps every time:

  1. POST the source URL to api.ffmpeg-micro.com with your HLS options, and get a job ID back immediately.
  2. Poll the job or take a webhook when it finishes, so nothing sits inside a request timeout.
  3. Download the playlist and segments and push them to your CDN.

The exact field names for the job payload are in the docs, and the same call works from n8n, Make, and Zapier over plain HTTP nodes, or from an AI agent through the MCP server. The webhook step is the one that matters for no-code builders: it's the same fix as our n8n timeout recipe, where you never let the bytes or the wait time pass through the automation platform.

FFmpeg on your own boxHosted m3u8 API
SetupInstall FFmpeg, size the instance, mount storageAPI key
Long jobsYour problem: timeouts, retries, zombie processesJob ID plus webhook
ConcurrencyQueue and autoscale it yourselfSubmit jobs, they run
Encoder upgradesYou patch x264 and FFmpeg buildsManaged
Where files liveWherever you put themWherever you put them
Cost modelInstance-hours, idle includedUsage-based, free tier to start

Neither option locks your video into a vendor's player. That's the part worth protecting: the output is files, and files move.

Pitfalls that break HLS playback

The failures here are almost never encoding failures. FFmpeg exits 0, the files exist, and the player shows a black rectangle.

  • Your playlist only lists five segments. Without -hls_playlist_type vod, hls_list_size defaults to 5 and the playlist keeps only the most recent entries. The earlier segments are still on disk, just unreferenced.
  • No such file or directory on the ladder command. FFmpeg won't create the stream_0/ directories implied by -hls_segment_filename. Run mkdir -p first, which is why that line is in the command above.
  • Playback stalls when the player switches quality. Keyframes aren't aligned across renditions. Force the same -g and -keyint_min on every variant and keep -sc_threshold 0.
  • Works in Safari, fails in Chrome. That's the CORS header, not the encode.
  • Segments download but nothing renders. Check that audio and video are mapped into every variant. A -var_stream_map entry missing its a: half yields a silent or broken rendition.

When you shouldn't reach for HLS

A 45-second product clip served as a single MP4 with HTTP range requests will start faster than an HLS ladder and costs you nothing to produce. Adaptive bitrate earns its complexity on long videos and unreliable networks, not on short loops.

Live streaming is also a different job. A batch job API produces VOD output from a finished file, so if you need an RTMP ingest endpoint that accepts frames from OBS in real time, you want a live streaming platform, not a file-processing API. Same goes for DRM with rotating keys, signed per-viewer playback tokens, and per-session watch analytics. Those belong to a managed streaming product, and pretending otherwise wastes your week. If your requirement is closer to "turn this MP4 into something my player can adapt across," a transcoding job and a CDN cover it. For everything upstream of that, like compressing the source properly first, the ordinary encoding rules still apply.

FAQ

Can FFmpeg create an m3u8 file without a streaming server?

FFmpeg creates .m3u8 playlists and segments as ordinary files on disk with -f hls, and no streaming server is involved at any point. For video-on-demand you upload those files to a static bucket or CDN and serve them over plain HTTP.

How long should HLS segments be?

Six seconds is the target duration Apple recommends in its HLS Authoring Specification, and -hls_time 6 is a safe default for VOD. Shorter segments cut startup latency but multiply file count, so a 10-minute video at 2-second segments means 300 files per rendition instead of 100.

Do I still need an MP4 if I publish HLS?

Keep the original MP4 as your mezzanine file, because HLS segments are a delivery format and re-encoding from them loses quality. Downloads, social uploads, and any future re-encode should all start from the source file.

Why does my m3u8 playlist only show the last few segments?

The hls_list_size option defaults to 5, so FFmpeg writes a rolling window suited to live streams. Adding -hls_playlist_type vod sets the list size to unlimited and appends the #EXT-X-ENDLIST tag that marks the stream as complete.

Can I generate HLS from n8n, Make, or Zapier?

Automation platforms can trigger HLS generation through an HTTP request node calling a video API, then take a webhook when the job completes. Running FFmpeg inside them isn't an option on hosted plans, and n8n disabled the Execute Command node by default in v2.0 for security reasons, so the API path is the one that works. Our n8n video workflow walkthrough shows the polling and webhook pattern end to end.

If your next upload should come back as a master playlist and a folder of segments instead of a ticket to build an encoder service, sign up free and send one job through the API to see the output structure before you wire it into anything.

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