32blogby Studio Mitsu

Stream HLS Video with FFmpeg and a CDN

Learn how to generate HLS segments with FFmpeg and serve them via a CDN with adaptive bitrate streaming. Covers m3u8 playlists, multi-bitrate encoding, and CORS configuration.

by omitsu13 min read
FFmpegHLSCDNvideo-streamingAdaptive Bitrate

This article contains affiliate links.

On this page

To stream HLS video with FFmpeg and a CDN, encode your source file with ffmpeg -i input.mp4 -hls_time 6 -hls_list_size 0 output.m3u8, upload the segments to S3 with aggressive cache headers, and play them in the browser with hls.js. For adaptive bitrate, encode at multiple resolutions and create a master playlist that references each quality level.

When you want to serve video through a CDN, the first question is always: what format should the output be in? Serving an MP4 directly seems like the easy path, but that locks every viewer into a single bitrate regardless of their connection speed. It also makes CDN caching less efficient because seeking triggers range requests directly against the origin.

HLS (HTTP Live Streaming) solves both problems. It splits video into small segments and serves them as plain HTTP files — exactly what CDNs are built to cache. The protocol is defined in RFC 8216, and with FFmpeg you can convert any source file to HLS in a single command.

This guide walks through the full pipeline: single-bitrate HLS encoding, adaptive bitrate (ABR) with a master playlist, uploading to S3 and serving through CloudFront, CORS configuration, and playing back in the browser with hls.js.

What Is HLS

HLS (HTTP Live Streaming) is a streaming protocol developed by Apple. It works by splitting a video into small segments (.ts files, typically a few seconds each) and providing an index file called a playlist (.m3u8) that lists all the segments in order.

Source VideoMP4 / MOVEncodeFFmpegHLS SegmentationUploadCDNS3 + CloudFrontHTTPBrowserhls.js

Compared to serving a plain MP4, HLS offers three main advantages:

  • Segment-level caching: Each segment is an independent HTTP request, which CDNs cache with high efficiency
  • Adaptive bitrate (ABR): The player switches quality levels in real time based on available bandwidth (when using a master playlist)
  • Efficient seeking: The player only fetches the segment that contains the requested timestamp, making scrubbing through long videos fast

There are two types of playlists. A media playlist lists the segments for a single quality level. A master playlist lists multiple media playlists, one per quality level — that is the entry point when ABR is in use.

Single-Bitrate HLS Encoding

Start with the simplest case: converting one video file to HLS at a single bitrate.

bash
ffmpeg -i input.mp4 \
  -c:v libx264 \
  -preset fast \
  -crf 22 \
  -c:a aac \
  -b:a 128k \
  -hls_time 6 \
  -hls_list_size 0 \
  -hls_segment_filename "output/segment_%04d.ts" \
  output/index.m3u8

The full list of HLS muxer options is in the FFmpeg formats documentation. Here is what each relevant flag does:

FlagPurpose
-hls_time 6Target segment duration in seconds. 4–10 seconds is common for VOD
-hls_list_size 0Keep all segments in the playlist (live streaming uses a different value)
-hls_segment_filenamePath pattern for segment output files
-crf 22Quality level — lower is better quality and larger file size. 18–28 is the practical range
-preset fastTrade-off between encoding speed and compression. slow gives smaller files, fast runs quicker

After running the command, the output/ directory will contain:

output/
├── index.m3u8          # Playlist file
├── segment_0000.ts     # First segment (~6 seconds)
├── segment_0001.ts
├── segment_0002.ts
└── ...

Upload this directory to any static host or CDN and it is ready to play.

Building Adaptive Bitrate (ABR) Streams

With a single bitrate, viewers on slow connections receive the same file as viewers on fast connections — which leads to buffering for some and wasted bandwidth for others. ABR lets the player measure connection speed and automatically select the best quality level for each viewer.

Encode three quality levels separately:

bash
# Low quality (360p, ~500 kbps)
ffmpeg -i input.mp4 \
  -c:v libx264 -preset fast -crf 28 \
  -vf "scale=640:360" \
  -c:a aac -b:a 96k \
  -hls_time 6 -hls_list_size 0 \
  -hls_segment_filename "output/360p/segment_%04d.ts" \
  output/360p/index.m3u8

# Medium quality (720p, ~1500 kbps)
ffmpeg -i input.mp4 \
  -c:v libx264 -preset fast -crf 23 \
  -vf "scale=1280:720" \
  -c:a aac -b:a 128k \
  -hls_time 6 -hls_list_size 0 \
  -hls_segment_filename "output/720p/segment_%04d.ts" \
  output/720p/index.m3u8

# High quality (1080p, ~3000 kbps)
ffmpeg -i input.mp4 \
  -c:v libx264 -preset fast -crf 20 \
  -vf "scale=1920:1080" \
  -c:a aac -b:a 192k \
  -hls_time 6 -hls_list_size 0 \
  -hls_segment_filename "output/1080p/segment_%04d.ts" \
  output/1080p/index.m3u8

Once the three encodes finish, create the master playlist manually:

m3u8
#EXTM3U
#EXT-X-VERSION:3

#EXT-X-STREAM-INF:BANDWIDTH=500000,RESOLUTION=640x360,CODECS="avc1.42e01e,mp4a.40.2"
360p/index.m3u8

#EXT-X-STREAM-INF:BANDWIDTH=1500000,RESOLUTION=1280x720,CODECS="avc1.64001f,mp4a.40.2"
720p/index.m3u8

#EXT-X-STREAM-INF:BANDWIDTH=3000000,RESOLUTION=1920x1080,CODECS="avc1.640028,mp4a.40.2"
1080p/index.m3u8

Save this as output/master.m3u8. The player loads this file first, then switches between the media playlists at runtime based on measured bandwidth. The final directory layout looks like this:

output/
├── master.m3u8         # Master playlist (player entry point)
├── 360p/
│   ├── index.m3u8
│   ├── segment_0000.ts
│   └── ...
├── 720p/
│   ├── index.m3u8
│   └── ...
└── 1080p/
    ├── index.m3u8
    └── ...

Uploading to a CDN

The most common setup is S3 (or an S3-compatible store like Cloudflare R2) as origin storage, with CloudFront or Cloudflare in front for caching and global distribution.

Upload the files with the AWS CLI, applying different cache headers for playlists and segments:

bash
# Upload m3u8 playlists — disable caching so players always get the latest version
aws s3 sync output/ s3://your-bucket-name/videos/sample/ \
  --exclude "*" \
  --include "*.m3u8" \
  --content-type "application/vnd.apple.mpegurl" \
  --cache-control "no-cache, no-store"

# Upload ts segments — cache aggressively since segment files never change
aws s3 sync output/ s3://your-bucket-name/videos/sample/ \
  --exclude "*" \
  --include "*.ts" \
  --content-type "video/mp2t" \
  --cache-control "public, max-age=31536000, immutable"
.m3u8Playlistno-cacheCDN EdgeCache Layermax-age=1yr.ts SegmentVideo Data

The cache strategy matters here:

  • m3u8 playlists: Use no-cache. A playlist can change if segments are added (live) or the video is re-encoded. You want players to always fetch a fresh copy.
  • ts segments: Use max-age=31536000 (one year). Segment filenames contain a sequence number that never changes, so the content is immutable — let the CDN cache it indefinitely.

If you use CloudFront, configure separate cache behaviors for *.m3u8 and *.ts to apply these policies independently.

CORS and Security Configuration

When hls.js loads segments from a CDN on a different origin than your website, the browser enforces CORS. Without the correct headers, the browser will block every segment request and the video will not play.

Apply a CORS rule to the S3 bucket:

bash
# Create the config file and apply it
cat > cors-config.json << 'EOF'
[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["GET", "HEAD"],
    "AllowedOrigins": ["https://your-site.com"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }
]
EOF

aws s3api put-bucket-cors \
  --bucket your-bucket-name \
  --cors-configuration file://cors-config.json

For CloudFront, add a response headers policy that includes Access-Control-Allow-Origin. In the AWS Console, go to CloudFront → Response Headers Policies → Create policy and add the CORS headers there.

On the security side, keep the S3 bucket private and use Origin Access Control (OAC) so only CloudFront can read from it. For pay-walled or authenticated content, CloudFront signed URLs or signed cookies let you restrict access to specific viewers.

Playing HLS in the Browser with hls.js

Safari and iOS support HLS natively through the <video> element. Chrome and Firefox require hls.js. The component below handles both cases:

tsx
"use client";

import { useEffect, useRef } from "react";
import Hls from "hls.js";

interface HlsPlayerProps {
  src: string;
  poster?: string;
}

export function HlsPlayer({ src, poster }: HlsPlayerProps) {
  const videoRef = useRef<HTMLVideoElement>(null);

  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;

    // Safari has native HLS support — no hls.js needed
    if (video.canPlayType("application/vnd.apple.mpegurl")) {
      video.src = src;
      return;
    }

    if (!Hls.isSupported()) {
      console.error("HLS is not supported in this browser");
      return;
    }

    const hls = new Hls({
      maxBufferLength: 30,
      maxMaxBufferLength: 600,
    });

    hls.loadSource(src);
    hls.attachMedia(video);

    hls.on(Hls.Events.MANIFEST_PARSED, () => {
      video.play().catch(() => {
        // Autoplay may be blocked by browser policy unless the video is muted
      });
    });

    hls.on(Hls.Events.ERROR, (_, data) => {
      if (data.fatal) {
        switch (data.type) {
          case Hls.ErrorTypes.NETWORK_ERROR:
            hls.startLoad(); // Retry on network errors
            break;
          case Hls.ErrorTypes.MEDIA_ERROR:
            hls.recoverMediaError(); // Attempt recovery on decode errors
            break;
          default:
            hls.destroy(); // Unrecoverable error — clean up
        }
      }
    });

    return () => {
      hls.destroy();
    };
  }, [src]);

  return (
    <video
      ref={videoRef}
      controls
      poster={poster}
      style={{ width: "100%", aspectRatio: "16/9" }}
    />
  );
}

Install hls.js with npm install hls.js. As of v1.6, hls.js ships its own TypeScript types, so a separate @types/hls.js package is not needed.

Use the component like this:

tsx
// app/video/page.tsx
import { HlsPlayer } from "@/components/HlsPlayer";

export default function VideoPage() {
  return (
    <main>
      <h1>Sample Video</h1>
      <HlsPlayer
        src="https://cdn.your-site.com/videos/sample/master.m3u8"
        poster="https://cdn.your-site.com/videos/sample/thumbnail.jpg"
      />
    </main>
  );
}

Troubleshooting

Here are the most common issues and how to fix them.

Segments return 404 or CORS errors

  • Verify the S3 bucket CORS rules include the correct AllowedOrigins
  • Check the CloudFront response headers policy has Access-Control-Allow-Origin set
  • Confirm the .ts files were uploaded with Content-Type: video/mp2t — using application/octet-stream can cause problems with some players

Playback stalls mid-video

  • The segment duration may be too long. Try reducing -hls_time to 4 seconds
  • Check whether CDN cache is actually hitting. Look at the CloudFront cache hit rate in CloudWatch metrics
  • The low-quality stream bitrate may still be too high for slow connections. Try increasing the CRF for 360p to 30 or higher

Safari cannot play the stream

  • Make sure the .m3u8 files are served with Content-Type: application/vnd.apple.mpegurl
  • Verify there are no S3 redirects interfering. Access the m3u8 URL directly in a browser to check

hls.js throws MANIFEST_LOAD_ERROR

  • Double-check the master playlist URL is correct
  • Make sure the CORS AllowedOrigins list includes http://localhost:3000 for local development (add it as a separate entry alongside the production domain)

Encoding is too slow

  • Switch -preset to ultrafast for faster encoding at the cost of file size
  • Use GPU encoding: -c:v h264_nvenc for NVIDIA, -c:v h264_videotoolbox on Mac. See the GPU Encoding Guide for details
  • Run all three encodes in parallel using bash background jobs:
bash
ffmpeg -i input.mp4 -vf "scale=640:360" ... output/360p/index.m3u8 &
ffmpeg -i input.mp4 -vf "scale=1280:720" ... output/720p/index.m3u8 &
ffmpeg -i input.mp4 -vf "scale=1920:1080" ... output/1080p/index.m3u8 &
wait
echo "All encodes finished"

For a broader introduction to FFmpeg commands, see the FFmpeg usage tutorial.

FAQ

What is the difference between HLS and DASH?

Both are adaptive bitrate streaming protocols that split video into segments. HLS was developed by Apple and uses .m3u8 playlists with .ts segments. DASH (Dynamic Adaptive Streaming over HTTP) uses .mpd manifests with .m4s segments. HLS has broader native browser support (especially iOS/Safari) and is the more common choice for general-purpose video delivery. DASH offers slightly more flexibility in codec choices but requires a JavaScript player on all platforms.

What segment duration should I use for HLS?

A segment duration of 4–6 seconds works well for most VOD (video-on-demand) content. Shorter segments (2 seconds) allow faster quality switching in ABR but increase the total number of HTTP requests. Longer segments (10 seconds) reduce request overhead but make seeking less precise. The Apple HLS authoring specification recommends a target duration of 6 seconds.

Can I use HLS for live streaming with FFmpeg?

Yes. For live streaming, pipe your input (e.g., from a camera or screen capture) into FFmpeg with -hls_flags delete_segments and a non-zero -hls_list_size (e.g., 5). This keeps only recent segments in the playlist, which is essential to avoid the playlist growing indefinitely. You will also want -hls_allow_cache 0 so players do not cache stale segments.

Do I need CloudFront, or can I use another CDN?

Any CDN that can serve static files works — Cloudflare, Fastly, Akamai, Bunny CDN, etc. CloudFront is shown in this guide because it integrates directly with S3 and supports per-path cache behaviors, which is handy for applying different headers to .m3u8 and .ts files. If you use Cloudflare R2 as your origin, Cloudflare's built-in CDN is a natural choice with zero egress fees.

Why are my HLS segments .ts instead of .mp4?

The .ts (MPEG Transport Stream) format is the default segment format for HLS and has the widest player compatibility. FFmpeg also supports fMP4 segments (-hls_segment_type fmp4), which produce .m4s files. fMP4 segments are smaller and support modern codecs like AV1 more naturally, but some older players may not handle them. Stick with .ts unless you have a specific reason to switch.

How much storage does ABR require compared to single-bitrate?

Roughly 2–3x more than a single 1080p encode. A three-rung ladder (360p + 720p + 1080p) stores the full-quality version plus two lower-bitrate copies. The 360p and 720p versions are significantly smaller per second of video, so the total is less than 3x. For a 10-minute 1080p video at ~3 Mbps, expect around 250 MB for a three-rung setup versus ~140 MB for single-bitrate.

Does hls.js work on mobile browsers?

On iOS, Safari plays HLS natively without hls.js. On Android, Chrome supports HLS through hls.js using Media Source Extensions (MSE). The component in this guide detects native support first and only falls back to hls.js when needed, so it works across all modern browsers and devices.

Wrapping Up

The full HLS streaming pipeline with FFmpeg and a CDN comes down to five steps:

  1. Encode with ffmpeg using -hls_time 6 to produce segments and a playlist
  2. For ABR, encode three quality levels (360p / 720p / 1080p) and write a master playlist by hand
  3. Upload to S3 — no-cache for .m3u8, max-age=31536000 for .ts
  4. Configure CORS on both S3 and CloudFront
  5. Use hls.js in the browser (with a Safari native fallback)

The setup takes more effort than dropping an MP4 on a server, but the payoff is meaningful: CDN cache efficiency goes up, and viewers on slower connections get a smooth experience instead of constant buffering. If you are putting video into production, starting with HLS and a CDN from day one is worth it.

Encoding multiple ABR rungs is CPU-intensive. If you do not want to tie up your local machine, build a dedicated encoding server on a VPS so you can run all three encodes in parallel on a remote box.

Kamatera

Enterprise-grade cloud VPS with global data centers

  • 13 data centers (US, EU, Asia, Middle East)
  • Starting at $4/month for 1GB RAM — pay-as-you-go
  • 30-day free trial available

Related articles: