32blogby StudioMitsu
ffmpeg10 min read

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.

FFmpegHLSCDNVideo StreamingAdaptive Bitrate
On this page

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. Add FFmpeg to the mix and 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.

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

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"

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. Recent versions ship their own TypeScript types, so a separate @types/hls.js package is usually 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
  • 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.

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.