A featherweight, self-hosted media server. One process, one SQLite file, a ~3KB web client with no build step, and HLS adaptive streaming. Runs on a Raspberry Pi, a $5 VPS, or an old laptop — and stays out of your way.
I got tired of Plex's constant "improvements" pulling my own library further behind a cloud account, and Jellyfin never clicked for me. My library is several terabytes and I just wanted to reach my catalog, anywhere, anytime — without a heavy .NET stack, without a build pipeline, without an account gateway between me and my own files.
So VLMP is deliberately small. The server does the heavy lifting (transcoding, metadata, scanning); the client is a thin Preact shell served straight from disk with its dependencies vendored locally — no CDN, works offline, auditable in an afternoon. If your homelab philosophy is "fewer moving parts," this is built for you.
It is:
- A single-process Node.js server + browser client for streaming your own media
- Lightweight enough to run comfortably on modest hardware
- Web-first: you watch in any modern browser, on any device with one
- Security-conscious: multiple adversarial audit passes, closed-membership auth, HMAC-signed federation (see Security)
- Federated: link your instance to a friend's and browse/play their library, NAT-safe and proxied
It is not (yet, and maybe not ever — set your expectations):
- A replacement for native TV apps. There is no Roku / Apple TV / Android TV / smart-TV client. You watch in a browser. If your primary need is a polished app on the television, Jellyfin or Plex will serve you better today.
- A hardware-transcode powerhouse. Software x264 transcoding works; GPU transcoding is roadmap, not reality.
- A commercial product with a support desk. This is a personal project shared in case it's useful. Best-effort support, no promises.
If that scope fits how you actually watch, welcome.
- Adaptive bitrate streaming — On-the-fly HLS transcoding via FFmpeg (1080p/720p/480p/360p); playback recovers from a transient segment hiccup instead of erroring out
- Continuous playback — Start a series or playlist and it plays through to the end, auto-advancing episode to episode (or a manual ⏭ to skip)
- Direct play — Zero-transcode for browser-compatible formats (H.264 MP4, WebM, etc.)
- Smart library scanning — Recursive discovery with automatic classification (movies, TV, documentaries, education); sample/trailer video clips shorter than 2 minutes are ignored (
VLMP_MIN_DURATION_SECONDS, 0 disables; audio is never filtered — numbered lessons andSxxEyyepisodes are always kept) - View-triggered rescan — Opening a category quietly rescans exactly that category's folders in the background and refreshes the page when new files land; at most once per folder per cooldown window (
VLMP_AUTO_RESCAN_COOLDOWN_SECONDS, default 300, 0 disables) - Incremental metadata — A metadata fetch only touches new/unmatched titles, not the whole library; unmatchable files are remembered so they aren't re-queried every run (
{"full": true}forces a complete re-fetch) - Custom categories — Create, rename, or delete your own nav categories (defaults included); each is "single titles" or "series". The category bar scrolls, so any number of them stays reachable
- Playback has priority — Streaming (HLS segments, direct play) is never rate-limited, so background work — a metadata fetch, browsing, a busy household — can't 429 what's playing (
VLMP_RATE_LIMIT_MAXtunes the control-plane ceiling, default 600/min) - Series everywhere — Season/episode detection from filenames (
S01E01,1x01) andSeason N/Series N/Temporada Nfolders, in ANY category — a Docs library can mix single documentaries with doc series; episodes group into show pages with per-season episode lists. Even episodes with no parseable number (bare titles,E01) still bundle under their show rather than scattering across the grid - Full category, your way — A category page loads its entire library at once (cached for instant re-browsing, no paging) and sorts on demand by title, recently added, random, or liked-first
- OpenSubtitles integration — Subtitle availability shown on every detail page; search and apply subtitles from opensubtitles.com in two clicks (free API key required)
- Watch progress — Resume where you left off, "Continue Watching" row
- Guest passes — Share a single item with time-limited, view-limited codes
- Closed-membership auth — First registration bootstraps the admin, then registration closes; admin provisions accounts; per-request role re-check for instant revocation
- TMDb metadata — Posters, backdrops, descriptions, ratings, genres
- Thumbnails for personal media — Anything TMDb can't match gets an FFmpeg frame-grab thumbnail, generated on first view
- Subtitle extraction — On-demand VTT extraction from embedded tracks (scan-time pre-extraction opt-in)
- Playlists — User-owned, add/remove/reorder
- Server federation — Link instances to browse and play remote media, all proxied (NAT-safe), HMAC-SHA256 signed with replay protection
- Algorithmic recommendations — 5-strategy engine (next episode, collaborative filtering, genre matching, similar items, popularity) with no external AI APIs
- Library health dashboard — 8 checks (missing files, zero-byte, metadata gaps, no subtitles, codec/resolution analysis, orphans, duplicates) with admin cleanup
- Ultra-light client — Preact + HTM vendored locally (~3KB framework, no build step, works offline)
# 1. Get the repo (it carries the compose file)
git clone https://github.com/kosm1x/vlmp.git
cd vlmp
# 2. Set a real JWT secret and point it at your media
export VLMP_JWT_SECRET="$(openssl rand -hex 32)"
# edit docker-compose.yml: mount your media folder at /media (read-only)
# 3. Up — pulls the published multi-arch image (amd64 + arm64)
docker compose up -d
# 4. Open http://localhost:8080 — the first account you register becomes adminAdd --build only if you want to build from source instead of pulling.
⚠ Upgrading from any earlier release — read this first. Every version up to and including v0.1.9.8 bind-mounted
./data. This one uses a named volume, so a plaingit pull && docker compose up -dstarts the server on empty storage: no library, no accounts, no watch history, and a new federation identity. Nothing warns you — the container is healthy, it is just a different disk. Your old files are untouched in./data.Migrate first, with the stack stopped:
docker compose down if [ -f data/vlmp.db ]; then docker run --rm -v "$PWD/data":/from -v vlmp-data:/to alpine \ sh -c 'cp -a /from/. /to/ && chown -R 1000:1000 /to' else echo "no ./data/vlmp.db here — nothing to migrate" fi docker compose up -dThe
chownis not optional.cp -apreserves ownership, and if your./datais root-owned — which it is if you ever hit the old first-run crash-loop — it carries that into the volume and the server fails to start all over again.Or keep your current layout: swap the volume line in
docker-compose.ymlback to./data:/dataand runsudo chown 1000:1000 dataonce.Note the first account to register becomes admin — so do not expose an un-migrated instance to the internet before restoring your accounts.
Where your data lives. The database, transcode cache and backups go in a Docker named volume (vlmp-data), not a folder in the repo. That is deliberate: the container runs as an unprivileged user, and a bind mount to a not-yet-existing host path is created root-owned, which used to make the very first up fail and restart forever. docker compose down keeps the volume — only down -v removes it. To back it up:
docker run --rm -v vlmp-data:/d -v "$PWD:/out" alpine tar czf /out/vlmp-data.tar.gz -C /d .If you would rather keep data in a host folder, swap the volume line in docker-compose.yml back to ./data:/data and create it yourself first with mkdir -p data && sudo chown 1000:1000 data.
Updating: docker compose pull && docker compose up -d. The explicit pull matters — a local build is tagged with that same ghcr.io/kosm1x/vlmp:latest name, so a plain up -d would keep reusing your own image instead.
Pinning a version. VLMP uses a 4-part MAJOR.MINOR.PATCH.BUILD scheme (why). Each release publishes its own exact tags and advances the shorter ones, so you choose how much drift you accept. How many dot-parts a tag has tells you whether it moves:
| Tag | Example | Moves? |
|---|---|---|
v<version> |
v0.1.9.9.1 |
Never — the git tag verbatim |
| Four+ parts | 0.1.9.9.1 |
Never — a pointer never has four+ parts |
| Three parts | 0.1.9 |
To the newest build on that patch line |
| Two parts | 0.1 |
To the newest release on that minor line |
latest |
— | To the newest release overall |
For a fully immutable pin use v0.1.9.9.1 or the full 0.1.9.9.1. (Container images start at 0.1.9.5 — earlier tags exist in git and as GitHub releases, but were never published to GHCR, so 0.1.9.4 and below are not pullable.) Note the overlap: a release tagged with only three parts (v0.2.0) publishes 0.2.0, and that same name later becomes the patch-line pointer when v0.2.0.1 ships — so prefer the v-prefixed tag if you want a name that can never be reused.
Pointers only ever move forward, and pre-releases (-rc.N) never take one.
Verifying a release image. Release images carry a SLSA provenance attestation and an SBOM, so you don't have to take the tag's word for what you're running:
# Which commit and workflow built it, and what's inside
docker buildx imagetools inspect ghcr.io/kosm1x/vlmp:latest --format '{{ json .Provenance }}'
docker buildx imagetools inspect ghcr.io/kosm1x/vlmp:latest --format '{{ json .SBOM }}'Images are multi-arch, so both fields come back keyed by platform. For one architecture: --format '{{ json (index .SBOM "linux/amd64").SPDX }}'.
The provenance names the source repository, commit SHA and the workflow run that produced the image. Only tags built by release.yml carry it, and :latest is only ever moved to the newest non-pre-release tag.
git clone https://github.com/kosm1x/vlmp.git
cd vlmp
npm install
npm run dev # dev server with auto-reload
# open http://localhost:8080 and register the first (admin) userRequires Node.js >= 22 and FFmpeg + FFprobe in your $PATH.
Grab vlmp-setup-<version>-win-x64.exe from the releases page — self-contained (bundles the Node runtime + a service helper, offers to install FFmpeg). Details in docs/WINDOWS.md.
VLMP binds to your LAN by default. To get "anywhere, anytime" do not port-forward it raw to the internet — put it behind something:
- Easiest & safest: a mesh VPN like Tailscale or WireGuard. Your devices join a private network; the server is never publicly exposed.
- Public with TLS: a reverse proxy that terminates HTTPS — Caddy (automatic Let's Encrypt) or nginx — in front of VLMP. Or a Cloudflare Tunnel to avoid opening any inbound port.
Native TLS inside VLMP is on the roadmap but not shipped; today, terminate TLS at the proxy.
If you use a reverse proxy, set VLMP_TRUST_PROXY. Without it every request appears to come from the proxy's address, so all your users share one rate-limit bucket — a busy household can 429 itself, and the per-route login limits stop distinguishing between clients. With it, X-Forwarded-For is used and limits apply per real client:
# Proxy and VLMP both on the host:
VLMP_TRUST_PROXY=loopback
# Proxy on the host, VLMP in Docker: requests arrive from the bridge gateway
# (e.g. 172.17.0.1), NOT 127.0.0.1 — so loopback would match nothing. Name the
# gateway exactly; `docker network inspect bridge` prints it.
VLMP_TRUST_PROXY=172.17.0.1Accepts loopback, linklocal, uniquelocal, an address or CIDR list, or a hop count. A value it can't parse is ignored with an error — it will never fall back to trusting.
Name the proxy as narrowly as you can, and make sure it really is the only route in. Two ways to get this wrong:
- Too broad.
uniquelocalcovers10/8,172.16/12and192.168/16— on a home network that is every client, not just your proxy. LikewiseVLMP_TRUST_PROXY=truetrusts any upstream whatsoever. - Not actually behind the proxy. The compose file publishes
8080on all interfaces, so anything on your LAN can also reach VLMP directly, skipping the proxy. If a host proxy fronts it, bind the port to loopback so it can't:ports: - "127.0.0.1:8080:8080".
Either mistake hands rate limiting to the client. X-Forwarded-For is just a header a client can write, so a request that reaches VLMP from a trusted address can claim any identity it likes — forging a fresh one per request defeats the global limiter and the login brute-force caps alike. Leave it unset if VLMP is reachable directly; unset is strictly safer than wrong.
All configuration is via environment variables. The important ones:
| Variable | Default | Description |
|---|---|---|
VLMP_PORT |
8080 |
HTTP server port |
VLMP_DATA_DIR |
./data |
Database + transcode cache |
VLMP_JWT_SECRET |
vlmp-dev-secret-change-me |
Change this in production (or use _SECRET_FILE) |
VLMP_TMDB_API_KEY |
(empty) | TMDb key for metadata enrichment |
VLMP_OPENSUBTITLES_API_KEY |
(empty) | opensubtitles.com key for subtitle search/download |
VLMP_SERVER_NAME |
VLMP |
Display name in federation |
VLMP_PUBLIC_URL |
(empty) | Public URL for federation linking |
VLMP_TRUST_PROXY |
(off) | Trust X-Forwarded-For — set only behind a proxy |
The full list (transcode limits, free-disk floor, sample-duration floor, scheduled backups, x264 preset, empty-trash-on-scan) is documented in .env.example.
VLMP exposes a public, no-auth endpoint for clients and TV apps to identify the server before login:
GET /api/info
{
"name": "My VLMP",
"version": "0.1.9.9.1-r2",
"publicUrl": "https://vlmp.example.com",
"fingerprint": "vlmp-a3f2b1",
"capabilities": ["hls", "subtitles", "playlists", "federation"]
}This is the preferred handshake for future native clients — scan the local network for /api/info, get a fingerprint, then prompt for credentials. No credentials are exposed: fingerprint is a SHA-256-derived public ID, not the private federation key. capabilities reflect live config — subtitle-search is added when an OpenSubtitles key is set.
VLMP is built to run on modest hardware — a Raspberry Pi 4, a spare laptop, a $5 VPS. Representative numbers running v0.1.9 on a 2-core / 2 GB VPS serving a ~1 TB library:
| State | RSS | Notes |
|---|---|---|
| Idle (no active playback) | ~60–80 MB | Server + SQLite in WAL mode |
| 1 HLS transcode session | ~120–160 MB | FFmpeg child process included |
| 4 concurrent HLS sessions | ~300–400 MB | At VLMP_MAX_TRANSCODE_SESSIONS=4 default |
For comparison: Jellyfin (non-hardware transcode) typically idles at 200–400 MB and scales steeply with sessions. Plex Media Server (free tier, software transcode) idles at ~300–500 MB. VLMP has no background agents, no analytics, no daemon processes — one Node process and the FFmpeg children it spawns on demand.
Note: Transcoding is CPU-bound. A single x264 session at
veryfastpreset fits comfortably on a Raspberry Pi 4 at 720p. 1080p on a Pi 4 may stutter; tuneVLMP_TRANSCODE_PRESETtoultrafastor use direct play where possible.
Node.js 22 + TypeScript + Fastify 5 on the server; SQLite (WAL) for state; FFmpeg for transcoding; Preact + HTM + HLS.js on the client. Federation is HMAC-SHA256 signed with a 300s replay window and proxies all remote traffic through the local server, so clients never talk to peers directly.
The full module map and REST API reference live in the source tree under server/src/ — each subsystem (auth, scanner, streaming, federation, metadata, subtitles, recommendations) is its own directory with routes under server/src/routes/.
| Feature | Status |
|---|---|
| Lumiere Dark — polished dark-mode reskin (home, library, player) | 🔧 In progress |
| Native TV client scaffold — groundwork for Android TV / Apple TV | 🔲 Planned |
| GPU transcoding — NVENC / VAAPI / VideoToolbox | 🔲 Planned |
| Native TLS — terminate HTTPS inside VLMP (no reverse proxy needed) | 🔲 Planned |
/api/info device discovery ✅ |
Shipped in v0.1.9 branch |
- v0.1.9.9.1-r2 — thumbnails are content-addressed (
?v=<updated_at>+ immutable caching): grids load from the browser cache with zero requests, and a changed/recycled item busts its own URL — r1's always-revalidate mode (one round-trip per tile per load) is retired to a fallback - v0.1.9.9.1-r1 — thumbnails: browsers upgrading from pre-v0.1.9.9.1 kept serving ghost images from their own HTTP cache for up to a day (the old 24h header outlived the server fix); the client now always revalidates. Client-only; the
latest/0.1.9/0.1image pointers intentionally stay on 0.1.9.9.1 - v0.1.9.9.1 — category pages mirror the on-disk folder structure in sequence; scanner follows external symlinks and no longer silently drops files (
.m2ts/.mts, short numbered lessons,S00specials, mid-scan errors); thumbnails re-keyed by file path — a deleted category's images can no longer appear on new media; view-triggered rescan: opening a category refreshes its own folders (VLMP_AUTO_RESCAN_COOLDOWN_SECONDS) - v0.1.9.9 — Docker data moves to the named volume
vlmp-data(migration required — see the upgrade warning in Quick start); per-line gates for the0.1.9/0.1image pointers; subtitle extraction can no longer hang a scan; installer service scripts fail closed - v0.1.9.8 — Security: fast-uri 3.1.4 + brace-expansion 5.0.8 (npm audit now clean)
- v0.1.9.7 — Security: fastify 5.10 / @fastify/static 10 (closes the high-severity advisories); kill guards use real liveness
- v0.1.9.6 — Startup failures exit non-zero; ffmpeg 7.1 (transcode pacing now applies); no signalling of reaped PIDs
- v0.1.9.5 — CI hang made diagnosable: bounded test step, logs survive the failure
- v0.1.9.4 — Auto-fallback direct-play → transcode + sub-360p fix
- v0.1.9.3 — Re-probe null-codec files on first play
- v0.1.9.2 — Rate-limit fix: data plane (HLS, thumbnails) exempt; control-plane 600/min
- v0.1.9.1 — Incremental metadata, HLS retry, continuous play (auto-advance)
- v0.1.9 — Bundle unnumbered series, sortable full-library grids
- v0.1.8 — Server federation, algorithmic recommendations, library health dashboard
This is primarily a personal project, but issues and PRs are welcome. Please read CONTRIBUTING.md first — it sets honest expectations on scope and response times.
Found a vulnerability? Please do not open a public issue. See SECURITY.md for the private disclosure process. VLMP has been through multiple adversarial audit passes; findings and the deferred queue live in docs/.
Apache License 2.0. You can use, modify, and redistribute it freely, including commercially, with attribution and the patent grant intact. See NOTICE for attribution details.