Logo

Command Palette

Search for a command to run...

Command Palette

Search for a command to run...

Writing
PreviousNext

Building Zorvid: A Self-Hosted Video Pipeline

A case study on Zorvid — a decoupled, horizontally scalable video-on-demand pipeline I built from scratch. Upload a video, watch it transcode to adaptive HLS in the background, and stream it with per-segment token security. The story of building infrastructure ahead of the deal that needed it.

Most of my projects start because someone asked. Zorvid started because I got tired of waiting.

There have been ongoing talks about a learning platform for the Government of Tripura — an LMS that would eventually need to host and stream a lot of video to a lot of students. Video is the part of any LMS that quietly breaks budgets and backs. Managed transcoding services bill you per minute of footage; naive setups fall over the moment two hundred students hit play at 9am. I did not want to walk into that conversation with a hunch. I wanted to walk in with a working pipeline.

So I built Zorvid — a self-hosted, horizontally scalable video-on-demand platform — before anyone asked me to. Upload a video, watch it transcode to adaptive HLS in the background, and stream it back with automatic quality switching. Fully mine, fully open, and designed so every piece can move to its own machine without a rewrite.

The shape of the thing

The core idea is a pipeline you can pull apart:

Upload → queue → FFmpeg transcode (360p/480p/720p) → adaptive HLS → CDN-cacheable streaming

And the architecture is deliberately decoupled, because the whole point was that it scales:

  • nginx at the edge — serves the SPA, proxies the API, and caches public HLS.
  • A Fastify (TypeScript) API — auth, uploads, CRUD, enqueuing jobs, and signing streams. Stateless, so you can run as many copies as you want.
  • Redis + BullMQ as the job queue between "a video was uploaded" and "someone actually transcodes it."
  • FFmpeg workers that pull jobs and grind video into a multi-bitrate HLS ladder. Want more throughput? --scale worker=3. That is the whole knob.
  • MinIO (S3-compatible) for inputs, HLS outputs, and thumbnails.
  • PostgreSQL for users, videos, jobs, and progress.

The reason it is split like this is the reason it exists. The API never blocks on transcoding — it drops a job in the queue and moves on. The heavy lifting happens in workers you can multiply independently. Storage is object storage, so nothing important lives on a box you cannot lose. It is boring on purpose, and boring is exactly what you want when a government LMS is depending on it.

The problem I did not see coming: streaming private video

I expected transcoding to be the hard part. It was not. The hard part was letting the right person watch a private video and nobody else.

Here is the trap. The obvious answer is presigned URLs — hand the player a signed, expiring link to the video in storage. And it almost works. But HLS is not one file; it is a master playlist that points to child playlists that point to hundreds of little .ts segments, and hls.js fetches all of those nested files using relative URLs. Relative URLs drop your query-string signature. So the master playlist loads beautifully, and then every segment after it comes back 403. You have built a video player that plays exactly zero seconds of video.

The fix was to stop fighting the player and change where the signature lives. Instead of signing URLs, Zorvid serves all HLS bytes through the API and attaches a short-lived stream token to every request via hls.js's xhrSetup hook. Master playlist, child playlists, individual segments — each one carries a fresh token in its headers, and private responses are marked no-store so nothing leaks into a cache. Public videos take the opposite path: served anonymously, marked immutable, and left for nginx and Cloudflare to cache at the edge so popular content barely touches the origin.

That split — public content flying off the CDN, private content gated per-segment — is the piece of Zorvid I am proudest of, precisely because it is invisible when it works.

Building for failure

The other thing a government-scale pipeline cannot do is lose work silently. Uploads are big, transcoding is slow, and machines die mid-job.

So the workers lean on BullMQ's retries with backoff, every job persists its state and logs to Postgres, and the transcode stages are idempotent — a job that dies halfway can be retried without producing a corrupted, half-transcoded mess. The status a user sees walks honestly through pending → analyzing → transcoding → … → completed, polled live, so nobody is left staring at a spinner wondering if their upload fell into a hole.

Why self-hosted was the whole point

I could have wired this to a cloud transcoding API in an afternoon. I deliberately did not.

For a government platform, "you own the pipeline, the storage, and the bill" is not a nice-to-have — it is the entire pitch. No per-minute transcoding invoice that scales with every lecture uploaded. No student data flowing through a third party. No vendor who can change pricing and hold a public institution hostage. Zorvid runs on hardware you control, and it scales by adding workers, not by upgrading a subscription tier.

Where it stands

The LMS conversation is still a conversation. But that is exactly why I am glad I built Zorvid the way I did — not as a slide, but as a running system with a real architecture diagram and a hard problem already solved. When the talks turn into a project, the scariest part of it is already done and sitting in a repo.

Sometimes the best move is to build the boring, load-bearing infrastructure before anyone tells you to. When the deal is ready, you are not promising you can do it. You are showing them it already runs.