Serving HLS (HTTP Live Streaming) from nginx is the backbone of most self-hosted streaming setups — VOD, catch-up, and live restreaming all ride on it. This guide configures nginx to serve HLS segments efficiently, with the caching and CORS headers that players actually need.
We assume you're generating .m3u8 playlists and .ts (or .m4s) segments already — for example from the FFmpeg NVENC pipeline.
Prerequisites
- Ubuntu 22.04 with nginx installed (
apt install -y nginx) - HLS segments being written to a directory (e.g.
/var/www/hls) - A domain pointed at the server
Step 1 — Basic HLS location block
Edit your site config (/etc/nginx/sites-available/streaming):
server {
listen 80;
server_name stream.yourdomain.com;
location /hls/ {
# Serve segments from disk
root /var/www;
# Correct MIME types for HLS
types {
application/vnd.apple.mpegurl m3u8;
video/mp2t ts;
video/iso.segment m4s;
}
# CORS — required for browser players (hls.js, video.js)
add_header Access-Control-Allow-Origin * always;
add_header Cache-Control no-cache always;
}
}The Cache-Control: no-cache on the playlist is important — players must re-fetch the .m3u8 to see new segments. We'll refine caching next.
Step 2 — Split caching: playlists vs segments
Playlists change every segment; segments never change once written. Cache them differently:
location ~ \.m3u8$ {
root /var/www;
add_header Access-Control-Allow-Origin * always;
add_header Cache-Control "no-cache, no-store" always;
types { application/vnd.apple.mpegurl m3u8; }
}
location ~ \.(ts|m4s)$ {
root /var/www;
add_header Access-Control-Allow-Origin * always;
# Segments are immutable — cache aggressively
add_header Cache-Control "public, max-age=86400" always;
types {
video/mp2t ts;
video/iso.segment m4s;
}
}This lets CDNs and browsers cache the heavy .ts files while always re-checking the tiny playlist.
Step 3 — Enable sendfile and tune for throughput
Streaming is bandwidth-bound. In the http {} block of /etc/nginx/nginx.conf:
sendfile on;
tcp_nopush on;
tcp_nodelay on;
sendfile_max_chunk 1m;
# Bump worker connections for high concurrency
worker_processes auto;
events {
worker_connections 8192;
use epoll;
multi_accept on;
}
# Larger output buffers for large segments
output_buffers 4 256k;sendfile on lets the kernel copy segment files directly from disk to socket without bouncing through userspace — a big win for high-concurrency segment serving.
Step 4 — Add HTTPS (players increasingly require it)
Browsers block mixed content, so a page on HTTPS can't pull HLS over HTTP. Get a cert with Certbot:
apt install -y certbot python3-certbot-nginx
certbot --nginx -d stream.yourdomain.comCertbot rewrites your server block to listen on 443 with the cert. Verify auto-renewal:
certbot renew --dry-runStep 5 — Enable HTTP/2 (and HTTP/3 if you can)
HTTP/2 multiplexes segment requests over one connection — meaningful for HLS where a player pulls many small files:
listen 443 ssl;
http2 on;For HTTP/3 (QUIC), you need nginx built with the http_v3 module or a build like nginx-quic. HTTP/3 helps players on lossy mobile networks the most.
Step 6 — Handle the "thundering herd" on live edges
When a popular live stream's segment drops, thousands of players request it near-simultaneously. Without care, they all hit disk at once. Enable open-file caching:
open_file_cache max=10000 inactive=60s;
open_file_cache_valid 30s;
open_file_cache_min_uses 2;
open_file_cache_errors on;This caches file descriptors and metadata, so the second-through-thousandth request for a hot segment is served from cache, not a fresh stat()+open().
Step 7 — Test it
Reload and verify:
nginx -t && systemctl reload nginx
curl -I https://stream.yourdomain.com/hls/stream.m3u8You want 200 OK, Content-Type: application/vnd.apple.mpegurl, Access-Control-Allow-Origin: * and Cache-Control: no-cache on the playlist. Load it in a browser with hls.js or in VLC to confirm playback.
Troubleshooting
CORS errors in browser console: the Access-Control-Allow-Origin header isn't reaching the client. Check it's set on both the .m3u8 and .ts locations, and that no upstream proxy strips it.
Player stalls / rebuffers: usually segment delivery can't keep up. Check bandwidth with iftop; if the port is saturated you need a bigger uplink. Also verify hls_time (segment duration) isn't too short — 6s is a safe default.
404 on segments but playlist loads: the root path is wrong, or segments are being deleted (short hls_list_size) before players request them. Increase hls_list_size in your FFmpeg command.
Where to go from here
For geo-distributed delivery put a CDN edge fleet in front of this origin. For the transcoding side, see the FFmpeg NVENC guide. And size your origin bandwidth with our port speed guide.