Low-Cost HLS Restreaming with Nginx
Stream 1000 HLS channels to 500+ viewers for under $7/month using Nginx and Cloudflare.
Introduction
30 Second Summary
Streaming video to hundreds of viewers usually means expensive servers or pricey streaming platforms. But HLS content is just regular web files, which means standard web caching can do all the heavy lifting.
In this project, you will build a low-cost HLS restreaming server using Nginx as a caching reverse proxy on a cheap VPS, with Cloudflare's free CDN handling all viewer-facing bandwidth.
What You'll Build
A complete HLS restreaming infrastructure that takes existing .m3u8 playlist sources and redistributes them to hundreds of concurrent viewers for under $7/month.
By the end of this project, you'll have:
- A Hetzner VPS running Nginx configured as a caching reverse proxy for HLS streams.
- Cloudflare Cache Rules that force .ts segments and .m3u8 playlists to be edge-cached on the free plan.
- A tuned caching setup verified with cache HIT rates proving Cloudflare serves 500+ viewers without stressing the origin.
- Secret Mission: Add cache monitoring with automated HIT rate alerting.
Are there any prerequisites?
You should be comfortable with Linux server administration (SSH, package management, editing config files). You also need a registered domain name and a free Cloudflare account.
Provision the VPS and Install Nginx
Every HLS restreaming setup needs a server to sit between the source stream and your viewers. This server will cache video segments so the source only gets hit once, no matter how many people are watching.
In this step, you will provision a cheap Hetzner VPS, install Nginx, and point your domain to it through Cloudflare.
In this step, get ready to:
- Create a Hetzner CX23 VPS running Ubuntu 24.04.
- Install Nginx and verify it serves pages on port 80.
- Point your domain to the VPS through Cloudflare DNS.
Create a Hetzner Cloud CX23 server
Hetzner Cloud is a VPS provider with excellent pricing for bandwidth-heavy workloads. The CX23 plan gives you 2 vCPU, 4GB RAM, and 40GB NVMe for $6.49/month.
- Open your browser and navigate to console.hetzner.cloud.
- Log in to your Hetzner account.
- Create a new project or select an existing one.
- Click Servers in the left menu.
- Click Add Server.
- For Location, choose the data center closest to your viewers (e.g. Falkenstein for Europe, Ashburn for the US).
- For Image, select Ubuntu 24.04.
- For Type, select Shared vCPU and choose the CX23 plan (2 vCPU, 4 GB RAM).
- Under SSH keys, add your public SSH key (e.g. the contents of ~/.ssh/id_ed25519.pub).
- Give your server a name like hls-proxy.
- Click CREATE & BUY NOW.
- Wait about 30 seconds for the status to show Running.
- Copy the server's public IPv4 address from the dashboard.
Why Hetzner for this project?
Hetzner includes 20 TB of egress traffic per VM. Combined with Cloudflare absorbing all viewer bandwidth, the VPS will barely use a fraction of that allowance. The CX23's 4GB RAM and NVMe storage are more than enough for caching thousands of HLS segments.
Don't have a Hetzner account?
Sign up at hetzner.com/cloud. Hetzner may ask for ID verification or a small initial payment during signup. This is normal and usually resolves within a few hours.
help me create a Hetzner Cloud account and set up my first server
SSH in and install Nginx
With your VPS running, you need to connect to it, update packages, and install Nginx as the web server that will handle all caching.
- Connect to your VPS over SSH by running this command:
ssh root@[[VPS_IP="your server IPv4 address"]]
What does this command do?
This opens a secure shell connection to your VPS as the root user. If you added your SSH key during server creation, you will connect immediately without a password.
- Update system packages and install Nginx by running these commands:
apt update && apt upgrade -y
apt install nginx -y
What do these commands do?
The first line updates the package index and upgrades all installed packages to their latest versions. The second line installs Nginx, which Ubuntu automatically starts and enables on boot.
- Verify Nginx is running by running this command:
systemctl status nginx
You should see active (running) in the output.
- Confirm Nginx serves pages by opening your browser and navigating to http:// followed by your server's IPv4 address.
You should see the default Nginx welcome page with the text "Welcome to nginx!" in the browser.
Can't reach the Nginx page?
Make sure you are using http:// (not https) followed by the IP address. Hetzner's default firewall allows port 80, but if you configured a Cloud Firewall during server creation, ensure port 80 is open for inbound TCP traffic.
help me troubleshoot why I cannot reach my Nginx server on port 80
Point your domain to the VPS in Cloudflare DNS
You need a domain name pointing to your VPS so that Cloudflare can later proxy and cache traffic. Setting the proxy status to "DNS only" initially lets you test the direct connection before turning on Cloudflare's CDN.
- Log in to the Cloudflare dashboard at dash.cloudflare.com.
- Select your domain from the dashboard.
- Click DNS in the left-hand menu, then Records.
- Click Add record.
- Set Type to A.
- In the Name field, enter stream (this creates the subdomain stream.yourdomain.com).
- In the IPv4 address field, paste your VPS public IPv4 address.
- Set Proxy status to DNS only (grey cloud icon). This sends traffic directly to your VPS without Cloudflare in the middle.
- Click Save.
Why DNS only first?
Starting with DNS only (grey cloud) lets you verify that Nginx works correctly at your domain before adding Cloudflare's proxy layer. You will switch to Proxied (orange cloud) in a later step when you configure the CDN cache rules.
- Verify DNS resolution by running this command from your local terminal:
dig stream.[[DOMAIN="yourdomain.com"]] +short
You should see your VPS IPv4 address in the output.
- Open your browser and navigate to http://stream. followed by your domain name.
You should see the same Nginx welcome page you saw when using the raw IP address.
Domain not resolving?
DNS propagation can take a few minutes. If dig returns no result, wait 2-3 minutes and try again. Double-check that the A record name is stream (not the full domain) and the IPv4 address matches your Hetzner server.
help me debug why my Cloudflare DNS A record is not resolving to my VPS IP
Your VPS is live and serving web pages through your domain. Next, you will configure Nginx as an HLS caching reverse proxy that pulls video segments from a source and stores them locally.
Configure Nginx as an HLS Caching Reverse Proxy
Your VPS is running and Nginx is serving its default page. Now you need to transform it from a basic web server into an intelligent caching reverse proxy that pulls HLS segments from a source and serves them to viewers.
The core insight is that HLS streams are just ordinary HTTP files. A .m3u8 playlist tells the player which .ts video segments to fetch next. By caching these files at Nginx, hundreds of viewers request the same segment but your origin only gets hit once.
In this step, get ready to:
- Configure a disk-backed cache path for HLS segments.
- Create split-cache location blocks for playlists and video segments.
- Reload Nginx and verify cache HIT responses with curl.
Set up the cache path configuration
Nginx needs a place on disk to store cached responses and metadata about what it has cached. The proxy_cache_path directive defines both of these.
- Open /etc/nginx/nginx.conf in a text editor by running:
sudo nano /etc/nginx/nginx.conf
- Select all existing content and delete it.
- Paste the following configuration, which sets up the global directives and cache path:
user www-data;
worker_processes auto;
pid /run/nginx.pid;
error_log /var/log/nginx/error.log;
events {
worker_connections 2048;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
# Custom log format that includes cache HIT/MISS status
log_format cache_log '$remote_addr - [$time_local] "$request" $status '
'$body_bytes_sent "$http_referer" '
'cache_status=$upstream_cache_status';
access_log /var/log/nginx/access.log cache_log;
# Disk-backed cache for HLS segments and playlists
proxy_cache_path /var/cache/nginx/hls
levels=1:2
keys_zone=hls_cache:100m
max_size=30g
inactive=10m
use_temp_path=off;
What does this configuration do?
The log_format cache_log line creates a custom log that records $upstream_cache_status for every request. This shows you whether each response was a HIT, MISS, or EXPIRED from cache.
The proxy_cache_path directive configures where and how Nginx stores cached content:
- levels=1:2 creates a two-level directory structure to avoid too many files in one folder.
- keys_zone=hls_cache:100m allocates 100MB of shared memory for cache key metadata (enough for millions of keys).
- max_size=30g caps disk usage at 30GB. When full, LRU eviction removes the least recently accessed items.
- inactive=10m removes items that nobody has requested in 10 minutes.
- use_temp_path=off writes directly to the cache directory, avoiding an extra file copy.
You won't see a change yet. The next chunk adds the server block that uses this cache.
Create split-cache location blocks
HLS playlists and video segments have very different caching needs. A .m3u8 playlist updates every few seconds to point at new segments. A .ts segment is immutable once created. Your Nginx config needs separate caching rules for each.
- Directly below the proxy_cache_path block you just pasted, add the server block with both location blocks:
server {
listen 80;
server_name [[STREAM_DOMAIN="stream.yourdomain.com"]];
# Default cache status header for all responses
add_header X-Cache-Status $upstream_cache_status always;
# Short-lived cache for dynamic playlists
location ~* \.m3u8$ {
proxy_pass [[HLS_SOURCE="http://your-hls-source-url"]];
proxy_cache hls_cache;
proxy_cache_valid 200 2s;
proxy_cache_lock on;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
add_header X-Cache-Status $upstream_cache_status always;
add_header Cache-Control "public, s-maxage=2, max-age=1" always;
}
# Long-lived cache for immutable video segments
location ~* \.ts$ {
proxy_pass [[HLS_SOURCE="http://your-hls-source-url"]];
proxy_cache hls_cache;
proxy_cache_valid 200 10m;
proxy_cache_lock on;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
add_header X-Cache-Status $upstream_cache_status always;
add_header Cache-Control "public, max-age=300, s-maxage=600" always;
}
}
}
What do these location blocks do?
Each location uses a regex to match file extensions. The .m3u8 block caches playlists for only 2 seconds (proxy_cache_valid 200 2s). Playlists update frequently, so viewers need fresh segment references.
The .ts block caches video segments for 10 minutes (proxy_cache_valid 200 10m). Segments never change once written, so long caching is safe.
The proxy_cache_lock on directive is critical. Without it, 500 viewers requesting the same uncached segment would trigger 500 upstream requests. With it, only the first request fetches from origin. All others wait for the cache to fill.
The Cache-Control headers tell Cloudflare CDN how long to cache at the edge. s-maxage controls shared caches (like Cloudflare) separately from the viewer's browser (max-age).
Why Nginx over a specialized media server?
Your HLS source is already encoded and segmented. The problem is purely HTTP distribution, not transcoding or protocol conversion. Nginx is the lightest, most proven tool for HTTP caching. Using a media server (like SRS or OvenMediaEngine) would be over-engineering for this use case.
You won't see a change yet. The next substep saves, reloads, and tests the full configuration.
✔️ Awesome, I've got everything!
Great. Make sure both parts are in the file with no missing braces, then continue to save and test.
ⓧ I'd like to double check the full code
Here is the complete /etc/nginx/nginx.conf file. Compare yours against it:
user www-data;
worker_processes auto;
pid /run/nginx.pid;
error_log /var/log/nginx/error.log;
events {
worker_connections 2048;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
log_format cache_log '$remote_addr - [$time_local] "$request" $status '
'$body_bytes_sent "$http_referer" '
'cache_status=$upstream_cache_status';
access_log /var/log/nginx/access.log cache_log;
proxy_cache_path /var/cache/nginx/hls
levels=1:2
keys_zone=hls_cache:100m
max_size=30g
inactive=10m
use_temp_path=off;
server {
listen 80;
server_name [[STREAM_DOMAIN="stream.yourdomain.com"]];
add_header X-Cache-Status $upstream_cache_status always;
location ~* \.m3u8$ {
proxy_pass [[HLS_SOURCE="http://your-hls-source-url"]];
proxy_cache hls_cache;
proxy_cache_valid 200 2s;
proxy_cache_lock on;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
add_header X-Cache-Status $upstream_cache_status always;
add_header Cache-Control "public, s-maxage=2, max-age=1" always;
}
location ~* \.ts$ {
proxy_pass [[HLS_SOURCE="http://your-hls-source-url"]];
proxy_cache hls_cache;
proxy_cache_valid 200 10m;
proxy_cache_lock on;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
add_header X-Cache-Status $upstream_cache_status always;
add_header Cache-Control "public, max-age=300, s-maxage=600" always;
}
}
}
Reload Nginx and verify caching
Your configuration file is complete. Now you need to create the cache directory, test the config syntax, and reload Nginx to activate the caching proxy.
- Save the file in nano by pressing Ctrl+O then Enter, and exit with Ctrl+X.
- Create the cache directory by running:
sudo mkdir -p /var/cache/nginx/hls
- Test your Nginx configuration for syntax errors by running:
sudo nginx -t
- You should see syntax is ok and test is successful in the output.
- Reload Nginx to apply the new configuration by running:
sudo systemctl reload nginx
Seeing a syntax error?
- Check for mismatched braces. Every opening { needs a closing }. The file should have exactly 5 opening braces.
- Verify that server_name and proxy_pass lines end with a semicolon.
- Make sure the proxy_cache_path directive is inside the http block, not inside the server block.
Help me fix my Nginx configuration syntax error
Now test that Nginx is proxying and caching HLS requests correctly.
- Send a request for a .m3u8 playlist through your proxy by running:
curl -I http://[[STREAM_DOMAIN="stream.yourdomain.com"]]/[[HLS_PATH="path/to/channel.m3u8"]]
- Look for the X-Cache-Status header in the response. The first request should show MISS (fetched from origin).
- Run the same curl command again immediately:
curl -I http://[[STREAM_DOMAIN="stream.yourdomain.com"]]/[[HLS_PATH="path/to/channel.m3u8"]]
- This time you should see X-Cache-Status: HIT in the response headers. This confirms Nginx served the response from its local cache instead of fetching from the origin.
What do the cache status values mean?
- MISS means the response was not in cache and was fetched from the upstream source.
- HIT means the response was served directly from the Nginx cache.
- EXPIRED means the cached copy had expired, so Nginx refreshed it from upstream.
- UPDATING means Nginx is serving a stale response while fetching a fresh one in the background (thanks to proxy_cache_background_update).
Not seeing X-Cache-Status in the response?
- Verify Nginx reloaded successfully by running sudo systemctl status nginx and checking for active (running).
- Make sure the URL path in your curl command matches a valid .m3u8 file on your HLS source. If the source returns a 404, Nginx won't cache it.
- Check that your proxy_pass URL points to a reachable HLS source. Test the source URL directly with curl to confirm it responds.
Help me debug why my Nginx HLS proxy is not returning X-Cache-Status headers
Your Nginx proxy is now caching HLS content locally. Next up, you will put Cloudflare in front of it so that viewers are served entirely from CDN edge nodes without ever touching your VPS.
Configure Cloudflare Cache Rules for HLS
Your Nginx proxy is pulling and caching HLS segments locally. The next piece of the puzzle is making Cloudflare serve those segments directly to viewers from its global edge network.
Cloudflare does not cache .m3u8 or .ts files by default. You need to create Cache Rules that explicitly tell Cloudflare to cache these file types at its edge. Once configured, Cloudflare absorbs all viewer bandwidth so your VPS only serves each segment once.
In this step, get ready to:
- Enable Cloudflare proxying and SSL/TLS for your streaming domain.
- Create a Cache Rule for .ts video segments with a 300-second Edge Cache TTL.
- Create a Cache Rule for .m3u8 playlists with a short Edge Cache TTL.
Enable Cloudflare proxying and SSL/TLS Full mode
For Cloudflare to cache and serve your HLS content, traffic must flow through the Cloudflare network. Right now your DNS record is set to DNS only (grey cloud), which means Cloudflare is just resolving the IP address without touching the traffic.
- In the Cloudflare dashboard, select your domain and go to the DNS > Records page.
- Find the A record for stream.yourdomain.com.
- Click the grey cloud icon next to the record to toggle it to Proxied (orange cloud).
- Click Save.
What does Proxied (orange cloud) do?
When a DNS record is proxied, all HTTP/HTTPS traffic routes through Cloudflare's network instead of going directly to your VPS. This enables caching, DDoS protection, and SSL termination at the edge.
With the grey cloud (DNS only), Cloudflare just returned the IP address. Cache Rules, WAF, and other features had no effect.
Now configure SSL/TLS so Cloudflare terminates TLS for viewers and connects to your origin over HTTP.
- In the Cloudflare dashboard, go to the SSL/TLS > Overview page.
- Under the encryption mode selector, choose Full.
Why Full mode and not Flexible?
Full mode tells Cloudflare to connect to your origin using the same protocol the viewer used. Since your Nginx currently listens on port 80 (HTTP), Cloudflare will connect over HTTP to your origin while still presenting HTTPS to viewers.
Flexible mode would also work here, but Full is the recommended minimum. It ensures that if you later add a certificate to Nginx, the connection upgrades automatically without reconfiguration.
- Verify the change by opening a new terminal tab on your local machine and running:
curl -I https://stream.yourdomain.com/
You should see a response with Cloudflare headers (server: cloudflare) confirming traffic is flowing through the proxy.
Getting a 525 or 522 error?
A 525 error means Cloudflare could not complete the SSL handshake with your origin. Since Nginx is listening on port 80 only, make sure you selected Full (not Full (strict)). Full mode does not validate the origin certificate.
A 522 error means Cloudflare cannot reach your server at all. Confirm your VPS firewall allows inbound traffic on port 80 and that Nginx is still running.
help me troubleshoot Cloudflare SSL errors when proxying to my Nginx origin
Create a Cache Rule for .ts video segments
Cloudflare does not cache .ts files by default because they are not in Cloudflare's default list of cached file extensions. You need a Cache Rule to explicitly mark them as cacheable with a long Edge Cache TTL.
- In the Cloudflare dashboard, go to Caching > Cache Rules.
- Click Create rule.
- In the Rule name field, enter Cache HLS .ts segments.
- Under When incoming requests match, select Custom filter expression.
- In the Field dropdown, select URI Path.
- In the Operator dropdown, select ends with.
- In the Value field, enter .ts.
- Under Then > Cache eligibility, select Eligible for cache.
- In the Edge TTL setting, select Ignore cache-control header and use this TTL.
- Set the TTL duration to 300 seconds (5 minutes).
- Click Deploy.
Why override the origin Cache-Control header?
Your Nginx config already sends Cache-Control: public, max-age=300, s-maxage=600 for .ts files. However, since .ts is not in Cloudflare's default cached extensions list, the header alone will not trigger caching.
By setting "Ignore cache-control header and use this TTL", you guarantee Cloudflare caches these segments for 300 seconds regardless of any header quirks. HLS segments are immutable once created, so long caching is perfectly safe.
What about the free plan rule limit?
Cloudflare's free plan gives you 10 Cache Rules. You only need 2 for this project (one for .ts, one for .m3u8), leaving 8 rules available for other uses.
Create a Cache Rule for .m3u8 playlists
HLS playlists (.m3u8 files) update every few seconds to reference new segments. They need a much shorter cache TTL to stay fresh while still offloading repeated requests from your origin.
- Return to Caching > Cache Rules in the Cloudflare dashboard.
- Click Create rule.
- In the Rule name field, enter Cache HLS .m3u8 playlists.
- Under When incoming requests match, select Custom filter expression.
- In the Field dropdown, select URI Path.
- In the Operator dropdown, select ends with.
- In the Value field, enter .m3u8.
- Under Then > Cache eligibility, select Eligible for cache.
- In the Edge TTL setting, select Ignore cache-control header and use this TTL.
- Set the TTL duration to 2 seconds.
- Click Deploy.
Why only 2 seconds for playlists?
A live HLS playlist updates every segment duration (typically 2-6 seconds) to reference the latest video chunks. If Cloudflare caches a stale playlist for too long, viewers miss new segments and playback stalls.
Even 2 seconds of caching is valuable. If 500 viewers all request the same playlist within that window, Cloudflare serves 499 of them from cache. Only one request reaches your origin per 2-second window.
Now verify that Cloudflare is actually caching your HLS content by testing with curl.
- Request a .ts segment through your proxied domain twice by running:
curl -sI https://stream.yourdomain.com/path/to/segment.ts | grep cf-cache-status
curl -sI https://stream.yourdomain.com/path/to/segment.ts | grep cf-cache-status
The first request should show cf-cache-status: MISS (Cloudflare fetched it from your origin). The second request should show cf-cache-status: HIT (served from Cloudflare's edge cache).
- Test a .m3u8 playlist the same way:
curl -sI https://stream.yourdomain.com/path/to/channel.m3u8 | grep cf-cache-status
curl -sI https://stream.yourdomain.com/path/to/channel.m3u8 | grep cf-cache-status
You should see MISS then HIT if you run the second request within 2 seconds. After 2 seconds, the playlist expires and you will see MISS or EXPIRED again. This confirms the short TTL is working correctly.
Still seeing MISS on repeated .ts requests?
Make sure you switched the DNS proxy status to Proxied (orange cloud). If the record is still DNS only, traffic bypasses Cloudflare entirely and you will not see cf-cache-status headers.
Also verify the URL you are testing ends in .ts and that the path corresponds to a real segment your Nginx proxy can serve. If Nginx returns a non-200 status, Cloudflare will not cache it.
help me debug why Cloudflare cf-cache-status shows MISS on all requests to my .ts files
Your Cloudflare CDN is now actively caching HLS content at the edge. Every viewer request for a cached segment is served by Cloudflare without touching your VPS. Next up, you will tune cache sizing and test playback under load to confirm the setup handles hundreds of concurrent viewers.
Tune Cache Sizing and Test Under Load
Your Nginx caching proxy is live and Cloudflare is edge-caching HLS content. The infrastructure is in place, but how do you know it actually works at scale?
In this step, you will validate your cache sizing against real bandwidth math, test end-to-end playback through a real HLS player, and monitor cache HIT rates to confirm the system is production-ready.
In this step, get ready to:
- Calculate cache requirements and confirm sizing for your channel spread.
- Test HLS playback end-to-end through the Cloudflare-proxied domain.
- Monitor Nginx and Cloudflare cache performance to verify production readiness.
Calculate cache requirements for your scenario
Before running real traffic, it helps to understand the math behind your cache sizing. This tells you whether your 40GB NVMe disk has enough room and how long segments stay cached before eviction.
How does the cache math work?
At 720p, a typical HLS segment is about 3MB for a 6-second chunk. That means one channel produces roughly 30MB of new segments per minute.
For 1000 channels, that is approximately 3GB of new segment data every minute. With max_size=30g and LRU eviction (least recently used segments are removed first), your cache holds roughly the last 10 minutes of all channels simultaneously.
For concentrated viewing (50 popular channels), only popular segments stay in cache and 10GB is plenty. For spread viewing (1000 channels), 30GB gives enough runway. The key insight: multiple viewers request the same segment within seconds of each other, so even short caching windows deliver near-100% HIT rates.
Your current configuration already has max_size=30g which is sized for the 1000-channel spread scenario.
- If you are restreaming fewer than 50 popular channels, you can reduce max_size to 10g to save disk space.
- If you are restreaming 1000 or more channels, keep max_size=30g as-is.
- Check your available disk space by running:
df -h /var/cache/nginx/hls
What does this command do?
The df -h command shows how much disk space is available on the partition where your cache lives. Verify you have at least 30GB free (or 10GB if you reduced max_size).
Adjust for higher resolutions
If your HLS source serves 1080p, segment sizes increase to roughly 5-8MB each. Double your max_size to compensate, or reduce the inactive timer to evict stale segments faster.
Test with a real HLS player
The real proof is smooth video playback. You will open your proxied stream in VLC (or any HLS-compatible player) and confirm that segments flow without stutter through the full Nginx-to-Cloudflare pipeline.
- Open VLC on your local machine.
- Click Media in the menu bar (macOS: File).
- Click Open Network Stream.
- Paste your stream URL into the URL field:
https://[[STREAM_DOMAIN="stream.yourdomain.com"]]/[[CHANNEL_PATH="path/to/channel.m3u8"]]
- Click Play.
You should see live video playing smoothly. VLC downloads the .m3u8 playlist, then fetches each .ts segment in sequence through your Cloudflare-proxied domain.
No VLC? Use hls.js in the browser
If you prefer browser testing, open any online HLS player (search for "hls.js demo player") and paste your stream URL. The browser fetches segments the same way VLC does, through Cloudflare's edge.
- Simulate concurrent viewers by opening 5-10 additional browser tabs with the same stream URL.
- Confirm each tab plays smoothly. Cloudflare should serve segments from its edge cache for all tabs after the first request populates the cache.
- Verify Cloudflare is serving cached content by running this on your VPS:
curl -sI https://[[STREAM_DOMAIN="stream.yourdomain.com"]]/[[SEGMENT_PATH="path/to/segment.ts"]] | grep -i cf-cache-status
What should I see?
The first request may return cf-cache-status: MISS. Run the same command again immediately. The second request should return cf-cache-status: HIT, proving Cloudflare is serving from its edge cache.
Stream not playing?
Make sure the proxy_pass URL in your Nginx config points to a valid, active HLS source. If the source is down, Nginx returns a 502 and VLC shows nothing.
Check that your Cloudflare SSL/TLS mode is set to Full. If it is set to Flexible, HTTPS requests may fail because Nginx is listening on port 80 only.
help me debug why my HLS stream is not playing through Cloudflare
Monitor cache performance
Playback confirms the stream works. Now you need to verify that caching is actually doing its job by checking HIT/MISS ratios at both the Nginx and Cloudflare layers.
- Check your Nginx access log for cache status distribution by running:
# Show the last 50 requests with their cache status
tail -50 /var/log/nginx/access.log | grep "cache_status"
What does the log output mean?
Each line includes cache_status=HIT, cache_status=MISS, or cache_status=EXPIRED. A HIT means Nginx served the segment from its local disk cache without contacting the upstream source. MISS means it fetched fresh from origin. EXPIRED means the cached copy's TTL ran out and a new copy was fetched.
- Count the HIT versus MISS ratio across your full log by running:
# Count HITs and MISSes in the access log
echo "HITs: $(grep -c 'cache_status=HIT' /var/log/nginx/access.log)"
echo "MISSes: $(grep -c 'cache_status=MISS' /var/log/nginx/access.log)"
After multiple requests to the same segments, you should see significantly more HITs than MISSes at the Nginx layer.
- Now check cache performance at the Cloudflare edge. In the Cloudflare dashboard, select your domain.
- Click Caching in the left sidebar.
- Click Overview.
The Cache Analytics page shows you the ratio of cached versus uncached requests. Look for the cache hit ratio on .ts requests. A well-tuned setup should show above 80% HIT rate for segments.
Why 80% and not 100%?
The first request for any new segment is always a MISS (it has never been cached before). With a live stream producing new segments every 6 seconds, there is always a baseline of initial MISSes. The HIT rate reflects how many subsequent viewers are served from cache rather than hitting your origin.
With 10 or more concurrent viewers on the same channel, you should see 90%+ HIT rates on .ts segments. The more viewers per channel, the higher the ratio.
Seeing mostly MISSes?
If your Nginx log shows all MISSes, confirm that proxy_cache hls_cache is present in both location blocks. A missing directive means Nginx passes every request straight through.
If Cloudflare shows all MISSes (cf-cache-status: MISS even on repeated requests), check that your Cache Rules are active and match .ts and .m3u8 paths. A disabled or misconfigured rule means Cloudflare treats the content as uncacheable.
help me debug why my Cloudflare cache HIT rate is low for HLS segments
Your HLS restreaming infrastructure is now tested and production-ready. Nginx caches segments locally, Cloudflare distributes them globally, and your origin handles minimal traffic regardless of viewer count.
Secret mission
Add Cache Monitoring and Alerts
Your restreaming setup is running, but how do you know when the cache stops working? In this secret mission, you'll add automated monitoring that watches your cache HIT rate and alerts you the moment performance degrades.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. Your Hetzner VPS costs approximately $6.49/month (billed hourly at $0.0104/hour). The Cloudflare CDN is on the free plan and incurs no charges.
Cost warning
Your Hetzner CX23 VPS continues to bill at $0.0104/hour as long as the server exists, even if it is powered off. If you are not actively using the restreaming infrastructure, consider pausing (snapshot) or deleting the server to stop charges immediately.
Resources you used:
- Hetzner Cloud CX23 VPS (Ubuntu 24.04, running Nginx with caching proxy and monitoring cron).
- Cloudflare DNS record (A record for stream.yourdomain.com pointing to VPS IP, proxied).
- Cloudflare Cache Rules (two rules: one for .ts files, one for .m3u8 files).
Keep everything running
No action needed. Choose this if you are still actively restreaming content or testing your setup.
- Your VPS stays active at ~$6.49/month.
- Cloudflare CDN continues caching at no cost.
- The cache monitoring cron job keeps running every 5 minutes.
Pause - I'll come back to this later
Shut down the VPS to stop hourly billing. Note that Hetzner still charges for a powered-off server. To truly pause billing, create a snapshot and delete the server.
- In Hetzner Cloud Console, open your project and select Server.
- Open your server, then select the Snapshots tab.
- Create a snapshot (costs $0.0143/GB of used disk per month while stored).
- Once the snapshot completes, delete the server to stop all compute charges.
- When you are ready to resume, create a new server from the snapshot.
- Leave the Cloudflare DNS record and Cache Rules in place. They incur no cost on the free plan.
Delete - I don't want to use this again
Remove all project resources to stop all charges immediately. This is irreversible.
- Delete the Hetzner VPS:
- Open https://console.hetzner.com/projects and log in.
- Select your project, then select the Server menu item.
- Open the server, click Delete, and confirm. Billing stops immediately.
- Delete the Cloudflare DNS record:
- In the Cloudflare dashboard, select your domain and go to DNS > Records.
- On the stream.yourdomain.com record, select Edit.
- Select Delete, then select Delete again to confirm.
- Delete the Cloudflare Cache Rules:
- In the Cloudflare dashboard, go to Caching > Cache Rules.
- For each of your two rules (the .ts rule and the .m3u8 rule), select the rule and delete it.
That's a Wrap!
That's a Wrap!
Nice work! You've built a production-ready HLS restreaming infrastructure that serves hundreds of concurrent viewers for under $7 a month.
You've learned how to:
- Configure Nginx as a caching reverse proxy with split TTL strategies for HLS streams: short-lived caching for dynamic .m3u8 playlists and long-lived caching for immutable .ts segments.
- Set up Cloudflare free CDN with Cache Rules to edge-cache HLS content, eliminating viewer bandwidth from ever hitting your origin server.
- Tuned cache sizing and verified cache HIT rates proving the setup can restream 1000 channels to 500+ concurrent viewers on a single $6.49/month VPS.
- Secret Mission: Added automated cache monitoring with a bash script and cron job that alerts when HIT rate drops below your threshold.
Ready to quiz yourself?