back to archive

How I Built an HLS Streaming Stack on Shared Hosting

Can shared hosting stream HLS? I built a browser-powered HLS pipeline, measured its limits, and shared the lessons learned.

How I Built an HLS Streaming Stack on Shared Hosting
on this page9 sections
Architecture diagram: the browser converts and uploads, shared hosting stores and serves, the player streams

The final shape: the browser does the heavy lifting, shared hosting just stores and serves.

I did not set out to build a streaming stack.

It started with a line on my hosting invoice. I had a cheap shared hosting account advertising "unlimited storage" and "unlimited bandwidth." That kind of phrasing makes you feel like you can build anything. The catch is the usual one: it's shared hosting, so it really only runs PHP and maybe Perl. No long-running processes, no ffmpeg daemon, no Node service humming in the background.

So I asked the obvious question: can I use this thing as an HLS store and origin for my LMS?

The answer turned out to be far more interesting than a yes or a no. This is the honest build log: what worked, what didn't, and the number that quietly decided everything.

For context, this was for a small LMS I'm building. I wasn't chasing a tiny Netflix. I wanted something practical that wouldn't make my monthly bill cry.

First realization: HLS is just files

Before writing a line of code, I had to correct a mental model. I'd been thinking of "video streaming" as a heavy, special thing that needs a heavy, special server. It isn't. HLS is just a pile of static files: a text playlist (.m3u8) that points at a sequence of small video chunks (.ts). A player reads the playlist, then fetches chunks in order.

Serving that is exactly what a dumb web server is good at. No PHP is required to stream, just to guard access. Suddenly shared hosting didn't look absurd; it looked plausible.

There are a few boring gotchas that bite if you skip them, and I hit most of them:

plain
# .htaccess: the unglamorous but essential bits
AddType application/vnd.apple.mpegurl .m3u8
AddType video/mp2t .ts

# CORS, if the player lives on another domain
<FilesMatch "\.(m3u8|ts)$">
    Header set Access-Control-Allow-Origin "*"
</FilesMatch>

Two rules I learned to respect: allow HTTP Range requests (players depend on them) and never gzip your .ts files (compressing already-compressed video just wastes CPU and can break range serving). Compressing the .m3u8 text is fine.

So storage and delivery were solvable. The real wall was somewhere else.

The thing shared hosting genuinely cannot do

You cannot transcode video on shared hosting. There's no running ffmpeg for minutes at a time; CPU caps, execution-time limits, and worker limits all conspire to kill the process. The textbook answer is to stand up a Node/ffmpeg box on Railway or a VPS, transcode there, and push the HLS files to storage.

I almost did that. Then I stopped and asked: do I even need a server for the transcode at all?

Moving the heavy lifting into the browser

This is where the project got fun. I moved the entire transcode into the browser, using MediaBunny on top of the browser's native WebCodecs API. The teacher's machine takes the MP4, decodes it, resizes it into multiple renditions, re-encodes each one, chops them into segments, and writes the playlists, all client-side. The server never transcodes anything.

There was a small moment of confusion I want to flag, because it's actually the interesting technical bit. When I inspected what was loading, I noticed there was no .wasm file anywhere. That surprised me. Most in-browser video tools ship a multi-megabyte WebAssembly build of ffmpeg. MediaBunny doesn't. It drives the browser's built-in codecs through WebCodecs, so the real encode/decode work runs on the operating system's own media stack, hardware-accelerated where available. No giant WASM download, and it's genuinely fast.

The clever part isn't just "no server transcode." It's where the work moved. The teacher's laptop is sitting idle anyway while a file uploads, so let it earn its keep on the GPU instead of paying a server to do the same job worse.

The uploader page with separate conversion and upload progress bars

Two progress bars on purpose: one for conversion, one for upload. Later, that split told me exactly where time was going.

The upload, and its one sharp edge

The flow settled into: pick an MP4, generate the renditions in the browser, and upload each file the moment it's written, then drop it from memory so nothing piles up while a long video converts.

The one sharp edge was ordering. If the master.m3u8 lands first, the video looks "ready" before its segments exist, and playback breaks in confusing ways. So the rule became: everything else first, master playlist last. The video only becomes real once the whole package is on disk.

Obvious in hindsight. Not obvious at 1 a.m.

js
// upload each file as MediaBunny finishes it, hold the master back
for (const file of filesAsTheyAreProduced) {
  if (file.isMasterPlaylist) { master = file; continue; }
  await uploadQueue.add(file);   // segments + per-rendition playlists
  file.free();                   // drop it from memory
}
await uploadQueue.drain();
await upload(master);            // only now does the video "go live"

Then came playback (and a detour)

I wanted playback protected but boring to consume. So: signed URLs plus a small PHP stream endpoint. The LMS hands out a short-lived link, PHP verifies the HMAC signature and expiry, and the stream comes back. The neat trick is that PHP rewrites every reference inside a playlist (including the audio rendition's URI="...") into a freshly signed link, resolving each playlist's relative paths against its own folder. The player never needs to know any of this exists. It just sees a valid HLS source:

plain
#EXT-X-STREAM-INF:...,RESOLUTION=1280x720
https://host/stream.php?video=UUID&file=720/playlist.m3u8&exp=...&sig=...

My first sanity check was to paste a stream URL into a public HLS tester (castr.com's player), and it threw "invalid or incomplete stream source." A brief heart-drop. It turned out to be a source-type/URL detail, not a broken stream; my own page and other players handled it fine once the manifest was fully signed and served with the right content type.

The player itself went through a small saga too. My first pick looked slick but its quality menu refused to populate from the HLS renditions, so switching resolution was dead on arrival. I swapped it for Plyr + hls.js and wired the quality menu directly from the HLS levels (Auto / 1080p / 720p / 480p), then confirmed a switch actually changed the decoded resolution, not just a label. "The button lies" is a bad look on a video player.

The Plyr player with the quality menu showing Auto, and available resolutions

Resolution switching, verified at the decode level. The menu had to change the picture, not just the label.

When buffering showed up (and streaming always collects its debt eventually), I did the thing I should always do first and usually do last. I measured instead of guessing.

I tested direct .ts versus PHP delivery. HTTP/1.1 versus HTTP/2. Whether X-Sendfile was available. Whether PHP-FPM was even an option on my plan (it wasn't). Whether LiteSpeed was helping or just wearing the badge. TTFB after the server had been idle. Throughput on a single connection, because that's what one student on one video actually gets.

I measured direct file delivery, PHP TTFB, and throughput before writing a single optimization.

What I measured

Rough observation

Static .ts delivery

Fine for small files

PHP-served delivery

Noticeably slower per request

First request after idle

Occasionally painful

Sustained throughput, single connection

~2.3 Mbps

Swap these for your own numbers before publishing. The exact figures depend on your host, but the shape will be the same.

That last row is the entire story, so I'll say it plainly:

"Unlimited" on shared hosting is a marketing word, not a physics word.

The host can deliver video. But the origin gives roughly 2.3 Mbps per connection, and the first viewer of anything always pays full price. Everything I did afterward was really just a negotiation with that number.

Why a CDN wouldn't save me (yet)

The reflex fix is "put Cloudflare in front." I thought hard about it, and for my workload it doesn't help much. A CDN is magic when 500 students hit the same lecture on the same day: one origin request, 499 cache hits. My reality is a handful of students watching different courses at different times. That's a terrible cache-hit ratio; the CDN becomes an expensive proxy that still has to pull each cold segment from a slow origin, and the first viewer still buffers.

A CDN is magic at scale and dead weight at five students. Know which one you are before you add the bill.

The shared-hosting-only playbook (what actually helps)

There are real wins that don't need a CDN or a new host, and if you're staying 100% on shared hosting, this is the order I'd do them:

  • Encode to fit the pipe. If one connection sustains ~2.3 Mbps, a 720p rung near that number streams; 1080p at 5 Mbps never will. Match the ladder to the measured ceiling.
  • Start low, buffer deep. Begin at the lowest rendition and let the player build a large cushion, so throughput dips don't become visible stalls.
  • Fewer, larger segments. 8 to 10s instead of 4s means fewer files, fewer PHP hits, fewer requests, and fewer inodes, which "unlimited" plans quietly cap.
  • Let the web server push bytes, not PHP. X-Sendfile / LiteSpeed hands off the file and frees the worker instantly.

But added all together, that's maybe a 10 to 20% gain against a hard ~2.3 Mbps wall. Which brings me to the least glamorous engineering decision of the whole project.

Why I'm freezing it here

I stopped. Not because there was nothing left to optimize, but because the ROI had quietly gone negative and I was only continuing because the graphs were fun.

The architecture is already good in the one way that matters: it's separated. The browser-side upload pipeline doesn't care what's behind the storage. The day a client says "we have 800 students," I don't rewrite anything. I move only the HLS origin:

Same uploader. Same signing. Same player. Only the backend changes. That clean seam is worth more than any byte I could have shaved off stream.php.

The one thing I am keeping on, even in production, is measurement:

  • average upload time,
  • average conversion time,
  • first-play startup time,
  • stall / rebuffer events,
  • which rendition students actually pick (480p vs 720p).

Those numbers will tell me when the ceiling starts hurting real people. Until the data says otherwise, this is a sensible balance of cost, simplicity, and capability for a small LMS.

What I'd tell past me

Measure first. Assumptions lie; measurements don't. I'd have saved a day.

Accept the ceiling earlier. It's tempting to believe you can squeeze anything hard enough. Sometimes you can. Sometimes you're just learning how the ceiling feels with your face.

Keep the browser-side transcode no matter what. That's the cleanest idea in the project, and it makes the whole shared-hosting question almost beside the point.

Because in the end I didn't build a tiny Netflix. I built something more honest: a streaming pipeline that respects the limits of a cheap shared host and still gets the job done.

That's usually where the good projects live.

e-labInnovations/php-hls-streaming
PHP 0 0


e-lab innovations

Browse the archive for more, or subscribe to get new posts in your inbox.

Discussion 0 comments

Be kind. I read everything but might take a day or two to reply.

no comments yet — be the first
// related

More from this category