Strum VOD
Self-Hosted Video Platform
Stop paying per-minute for video infrastructure. Own your video pipeline.
Quick Start • Features • API • Configuration • Docker Guide • Documentation
What is Strum VOD?
Strum VOD is a self-hosted alternative to Mux. Upload a video, get adaptive HLS streams from 360p up to 4320p (8K), and deliver them from your own infrastructure. No vendor lock-in, no per-minute pricing.
| Upload via dashboard, REST API, or resumable TUS protocol | Transcode automatically to adaptive HLS (360p–4320p, H.264/HEVC) |
| Stream via any HLS player or the standalone embeddable player | Scale with hardware-adaptive workers that auto-tune to your machine |
Why Strum VOD?
| No Per-Minute Fees | One Command Deploy | Own Your Data |
|---|---|---|
| Self-host at your own cost. No usage-based billing, no vendor lock-in. | Single Docker image with embedded PostgreSQL & queue. Just add S3. | Your server, your storage, your videos. Full control. |
| AI-Powered | R2 & S3-Compatible | Embeddable Player |
|---|---|---|
| Optional transcription (Whisper/Deepgram/Modal), subtitles, chapters & highlight clips. | First-class Cloudflare R2 support plus AWS S3, MinIO, Backblaze B2, and more. | Standalone player app (Cloudflare Workers + Assets) with quality selector, thumbnail seek preview, comments & reactions. |
How It Works
Upload Transcode Deliver
+-----------+ +--------------+ +------------+
| Browser |----->| FFmpeg HLS |----->| R2 / S3 |
| TUS/PUT | | (Go) | | (Stream) |
+-----------+ +--------------+ +------------+
| | |
v v v
Dashboard Postgres + Player app
(CF Workers) Redis (CF Pages)- Upload via presigned URL or resumable TUS directly to R2/S3
- Transcode automatically to adaptive HLS (360p–4320p)
- Stream via the standalone player app or any HLS-capable player
Video playback is served directly from R2/S3. The API only handles metadata — R2 absorbs all bandwidth.
Deployment Targets
| App | Technology | Recommended Host |
|---|---|---|
apps/api | Fastify (Node.js) — includes resumable TUS video upload | Fly.io, Railway, any VPS |
apps/worker | Node (bridge + AI pipeline) | Fly.io |
apps/transcoder | Go — the FFmpeg HLS ladder | Fly.io, dedicated server (CPU-bound) |
apps/dashboard | Vite + React SPA (PWA) | Cloudflare Workers + Assets |
apps/player | Vite + React SPA (PWA) | Cloudflare Pages |
Cloudflare resources (R2 buckets + CORS, Dashboard, Player) are deployed with wrangler (apps/*/wrangler.toml). R2 CORS is managed from the repo via the pnpm r2:* scripts (see infra/r2/).
Quick Start
One image, everything included (API, worker, transcoder, dashboard, PostgreSQL, Redis). Storage is Cloudflare R2 — set your R2 credentials in the environment variables.
docker run -d \
--name strum-vod \
-p 3000:3000 \
-v strum-vod-data:/data \
-e S3_ENDPOINT=https://s3.amazonaws.com \
-e S3_REGION=us-east-1 \
-e S3_BUCKET=my-strum-vod-bucket \
-e S3_ACCESS_KEY_ID=AKIA... \
-e S3_SECRET_ACCESS_KEY=... \
-e S3_PUBLIC_BASE_URL=https://my-strum-vod-bucket.s3.amazonaws.com \
synapsr/strum-vodThat's it. Open http://localhost:3000 — dashboard and API on the same port.
PostgreSQL and Redis run inside the container automatically. Data is persisted in the
/datavolume.
Features
Dashboard & Player
| Feature | Description |
|---|---|
| Dashboard | Web UI to upload, manage, and preview all your video assets (installable PWA) |
| Standalone Player App | Independently deployable Cloudflare Pages app with embeddable HLS player (installable PWA) |
| Resumable Upload (TUS) | Browser-side TUS protocol, served by the API → R2 (survives disconnects, resumes on retry) |
| Standard Upload | Pre-signed S3/R2 URLs for efficient browser-to-storage uploads |
| URL Import | Import videos from any public URL |
| Collections | Organize videos into color-coded folders |
| Transcript & Subtitles | AI-generated transcription and WebVTT/SRT subtitles (Whisper / Deepgram / Modal) |
| Chapters | Auto-generated chapters from transcript (via LLM) |
| Highlight Clips | Auto-generated highlight clips with downloadable MP4s |
| Comments & Reactions | Public engagement on watch pages |
| Webhooks | Outgoing asset.ready / asset.error / ai.completed events with delivery log & retry |
Video Pipeline
| Feature | Description |
|---|---|
| Adaptive Bitrate | HLS output 360p → 4320p (7 renditions, H.264 or HEVC, CPU or VA-API) |
| Single-Pass Ladder | One ffmpeg process encodes every rendition at once |
| Thumbnail Sprites | Tiled scrub-bar thumbnails with VTT metadata |
| Shared Audio Track | One EXT-X-MEDIA AAC track shared by all renditions |
| Downloadable MP4s | Fast remux (-c copy) of any rendition + audio |
| Hardware-Adaptive | Workers auto-detect CPU/RAM (cgroup-aware) to optimize concurrency and threading |
| Horizontal Scaling | Run multiple stateless workers against the same Redis queue |
| REST API | Clean JSON endpoints for full programmatic control |
| Cloudflare R2 | First-class R2 support — no per-object ACLs needed |
Platform
| Feature | Description |
|---|---|
| Multi-tenant | Organizations, members, roles, API keys |
| Analytics | Views, watch time, retention, engagement, heatmap, live viewer count (SSE) |
| Billing (optional) | Stripe tiered plans + usage metering |
| DB Backups (optional) | Scheduled pg_dump → R2 |
| Admin | Superadmin dashboards (orgs, errors, webhook failures, BullMQ queues) |
| OTP Login | Email magic-code sign-in (Resend / SMTP / console) |
Deployment
Full Docker guide: See DOCKER.md for architecture diagrams, all deployment modes, scaling, and networking reference.
All-in-One (simplest)
Everything in a single container. PostgreSQL and Redis are embedded. Only S3 storage is external.
docker run -d \
--name strum-vod \
-p 3000:3000 \
-v strum-vod-data:/data \
-e S3_ENDPOINT=https://s3.amazonaws.com \
-e S3_REGION=us-east-1 \
-e S3_BUCKET=my-bucket \
-e S3_ACCESS_KEY_ID=AKIA... \
-e S3_SECRET_ACCESS_KEY=... \
-e S3_PUBLIC_BASE_URL=https://my-bucket.s3.amazonaws.com \
-e S3_FORCE_PATH_STYLE=false \
synapsr/strum-vodLocal dev
git clone https://github.com/Synapsr/strum-vod.git && cd strum-vod
cp .env.example .env
pnpm install
pnpm run dev:web # dashboard + player with live reloadFor the full stack (API, worker, transcoder) see the per-app dev scripts in the project's CLAUDE.md.
Production (split deployment)
Frontends deploy to Cloudflare Workers + Assets with wrangler; the API, Node worker, and Go transcoder deploy separately to Fly.io or any VPS. R2 buckets and their CORS are managed with wrangler (see pnpm r2:* and infra/r2/).
| Component | Deploy with |
|---|---|
Dashboard (apps/dashboard) | wrangler deploy --config apps/dashboard/wrangler.toml |
Player (apps/player) | pnpm deploy:player (wrangler pages) |
| API + Worker | fly deploy --config apps/api/fly.toml / fly deploy --config fly.worker.toml |
| Transcoder (Go) | fly deploy --config fly.transcoder.toml |
| R2 buckets + CORS | pnpm r2:* (wrangler) |
One-Click Deploy
Works out of the box with your favorite platforms:
| Platform | How to deploy |
|---|---|
| EasyPanel | Add Docker app → synapsr/strum-vod |
| Dokploy | Import from Docker Hub |
| Coolify | One-click from Docker image |
| Portainer | Create stack from compose |
| Railway | Deploy from Docker image |
API at a Glance
# Create an asset
curl -X POST http://localhost:13002/v1/assets \
-H "Content-Type: application/json" \
-d '{"title": "My Video"}'
# Standard upload: get presigned URL, upload, confirm
curl -X POST http://localhost:13002/v1/assets/{id}/upload-url
curl -X PUT "<uploadUrl>" --data-binary @video.mp4
curl -X POST http://localhost:13002/v1/assets/{id}/upload-complete
# OR resumable upload: get TUS token (requires SHARED_AUTH_SECRET)
curl -X POST http://localhost:13002/v1/assets/{id}/upload-token \
-d '{"fileSize": 104857600}'
# ... then upload via tus-js-client to VITE_TUS_SERVER_URL/upload/videos
# Start transcoding
curl -X POST http://localhost:13002/v1/assets/{id}/process
# Get playback URL
curl http://localhost:13002/v1/assets/{id}/playback| Endpoint | Method | Description |
|---|---|---|
/v1/assets | POST | Create a new asset |
/v1/assets | GET | List all assets (optional collectionId filter) |
/v1/assets/:id | GET | Get asset details + renditions + AI job |
/v1/assets/:id | PATCH | Update title, description, public settings, collection |
/v1/assets/:id | DELETE | Hard-delete asset (DB row + R2 objects) |
/v1/assets/:id/upload-url | POST | Get pre-signed upload URL (standard) |
/v1/assets/:id/upload-token | POST | Get JWT for TUS resumable upload |
/v1/assets/:id/upload-complete | POST | Confirm upload landed in storage |
/v1/assets/:id/import | POST | Import from external URL |
/v1/assets/:id/process | POST | Start transcoding |
/v1/assets/:id/playback | GET | Get HLS manifest + player URL |
/v1/playback/:playbackId | GET | Public playback endpoint |
/v1/collections | GET/POST | List / create collections |
Most endpoints require auth (Authorization: Bearer <JWT> or X-Api-Key). Public endpoints: /v1/playback/*, /v1/auth/signup|login, /v1/config, /health/*.
Configuration
Required
| Variable | Description |
|---|---|
DATABASE_URL | PostgreSQL connection string (e.g. Neon: postgresql://...?sslmode=require) |
S3_ENDPOINT | S3-compatible endpoint URL |
S3_REGION | S3 region (auto for R2) |
S3_BUCKET | S3 bucket name |
S3_ACCESS_KEY_ID | S3 access key |
S3_SECRET_ACCESS_KEY | S3 secret key |
S3_PUBLIC_BASE_URL | Public URL prefix for HLS playback |
JWT_SECRET | Required for auth-protected routes (openssl rand -hex 32) |
Optional variables
| Variable | Default | Description |
|---|---|---|
REDIS_URL | embedded Redis | Redis connection string |
PORT | 3000 | API port |
S3_FORCE_PATH_STYLE | false | Set true for path-style S3 endpoints; false for R2 and AWS S3 |
S3_PUBLIC_ENDPOINT | same as S3_ENDPOINT | Public S3 endpoint for presigned URLs |
CORS_ORIGIN | * | Allowed CORS origins (comma-separated) |
SHARED_AUTH_SECRET | — | Base64 key the API signs/verifies the resumable upload JWT with (openssl rand -base64 32) |
VITE_TUS_SERVER_URL | — | Dashboard build-time: base URL for TUS uploads, normally same as the API (enables TUS upload toggle) |
VITE_PLAYER_BASE_URL | — | Dashboard build-time: player app URL (for embed link generation) |
Registration control
| Variable | Default | Description |
|---|---|---|
REGISTRATION_ENABLED | true | Set to false to disable new account registration |
REGISTRATION_ALLOWED_DOMAINS | — | Comma-separated list of allowed email domains |
AI Processing (optional)
Strum VOD can auto-generate transcripts, subtitles, chapters, and highlight clips. Omit these variables to disable AI features.
| Variable | Description |
|---|---|
TRANSCRIPTION_PROVIDER | local (default) | deepgram | modal |
WHISPER_API_URL | OpenAI-compatible transcription endpoint |
WHISPER_API_KEY | API key for Whisper service |
WHISPER_MODEL | Model name (e.g. whisper-1) |
DEEPGRAM_API_KEY / DEEPGRAM_MODEL | Deepgram provider config |
LLM_PROVIDER | Chapter generation provider (openai, anthropic, groq, custom) |
LLM_API_KEY | API key for LLM service |
LLM_MODEL | Model name (e.g. gpt-4o-mini) |
AI_ENABLED | Set to false to disable AI even if configured |
Async transcription via the self-hosted Modal provider (infra/modal-whisper, scale-to-zero GPU) — set these only when using it:
| Variable | Description |
|---|---|
WHISPER_WEBHOOK_SECRET | HMAC secret Modal signs the callback with. Must match the whisper-webhook-secret Modal Secret — the API verifies it on POST /v1/ai/whisper-callback |
API_PUBLIC_URL | Publicly reachable base URL of the API — Modal calls back to <API_PUBLIC_URL>/v1/ai/whisper-callback from its own cloud (a tunnel or deployed API is required; localhost won't work) |
Instead of holding an HTTP connection open across cold start + inference, the worker dispatches the audio to Modal (202 + callId), and Modal reports the result back via the signed webhook. The API then resumes the pipeline (subtitles/chapters/highlights) and a 5-minute reaper job re-dispatches any transcription whose callback never arrived. See infra/modal-whisper/README.md.
Transcoding ladder
| Variable | Default | Description |
|---|---|---|
RENDITION_CODEC | h264 | h264 or hevc — base codec for every rendition |
MAX_RENDITION_HEIGHT | 0 | Cap the tallest rendition (0 = full 360p–4320p) |
HEVC_MIN_HEIGHT | 0 | Hybrid ladder: renditions at/above this height encode in HEVC |
FFMPEG_HWACCEL | auto | auto | vaapi | disabled |
Scaling (auto-detected)
| Variable | Default | Description |
|---|---|---|
WORKER_CONCURRENCY | auto | Concurrent transcode jobs (Go transcoder) |
FFMPEG_THREADS | auto | Threads per FFmpeg process |
DB_POOL_SIZE | auto | PostgreSQL connection pool size |
S3 Provider Examples
Cloudflare R2 (recommended for production)
S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
S3_REGION=auto
S3_BUCKET=strum-videos
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_PUBLIC_BASE_URL=https://pub-<hash>.r2.dev
S3_FORCE_PATH_STYLE=falseR2 does not support per-object ACLs or
PutBucketCorsvia the S3 API. Enable public access and configure CORS in the Cloudflare dashboard instead.
AWS S3
S3_ENDPOINT=https://s3.amazonaws.com
S3_REGION=us-east-1
S3_BUCKET=my-strum-vod-bucket
S3_ACCESS_KEY_ID=AKIA...
S3_SECRET_ACCESS_KEY=...
S3_PUBLIC_BASE_URL=https://my-strum-vod-bucket.s3.amazonaws.com
S3_FORCE_PATH_STYLE=falseBackblaze B2
S3_ENDPOINT=https://s3.us-west-004.backblazeb2.com
S3_REGION=us-west-004
S3_BUCKET=strum-vod
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_PUBLIC_BASE_URL=https://f004.backblazeb2.com/file/strum-vod
S3_FORCE_PATH_STYLE=trueTech Stack
| Technology | Purpose |
|---|---|
| TypeScript | Type-safe development across all packages |
| Fastify | High-performance REST API with Zod validation |
| Go | FFmpeg HLS ladder (apps/transcoder) |
| FFmpeg | Video transcoding (H.264/HEVC HLS, 360p–4320p) |
| PostgreSQL | Relational database for state (Neon-compatible) |
| BullMQ + Redis Streams | Job queue + Node↔Go interop |
| React + Vite | Dashboard and Player SPAs with Tailwind CSS v4 |
| hls.js | Adaptive HLS player in the browser |
| tus-js-client / @tus/server + @tus/s3-store | Resumable upload client / server (server lives in apps/api) |
| Cloudflare Workers + Assets | Dashboard and Player SPA hosting |
| wrangler | Deploy R2 buckets, CORS, and frontends to Cloudflare |
| Drizzle ORM | Type-safe database queries |
| Redis | Job queue backend (BullMQ) |
Project Structure
strum-vod/
├── apps/
│ ├── api/ # Fastify REST API (Fly.io / Docker)
│ ├── worker/ # Node: bridge + AI pipeline (Fly.io / Docker)
│ ├── transcoder/ # Go: FFmpeg HLS ladder (Fly.io / Docker)
│ ├── dashboard/ # React SPA — management UI (CF Workers + Assets)
│ └── player/ # React SPA — public embeddable player (CF Workers + Assets)
├── packages/
│ ├── db/ # Shared Drizzle schemas & constants (Postgres)
│ ├── email/ # Transactional email templates (OTP)
│ ├── subtitles/ # VTT/SRT generation
│ ├── workbench/ # BullMQ admin dashboard
│ ├── player-ui/ # Shared player components (heatmap, etc.)
│ └── design-tokens/ # Design tokens / CSS variables
├── infra/
│ ├── r2/ # R2 bucket CORS configs (used by pnpm r2:cors:set)
│ └── modal-whisper/ # Async transcription provider (Modal, scale-to-zero GPU)
├── pnpm-workspace.yaml # pnpm workspace config
├── Dockerfile # All-in-one image (api + worker + transcoder + dashboard)
└── .env.exampleDevelopment Setup
# Clone the repo
git clone https://github.com/Synapsr/strum-vod.git
cd strum-vod
# Install dependencies (uses pnpm workspaces)
pnpm install
# Configure environment
cp .env.example .env
# Frontends (dashboard + player) with live reload
pnpm run dev:web
# → Dashboard: http://localhost:1337
# → Player: http://localhost:1338Or run individual apps directly (requires external PostgreSQL, Redis, and R2 credentials in .env):
pnpm run build -w @strum-vod/db # build shared package first
pnpm run dev -w @strum-vod/api # API
pnpm run dev -w @strum-vod/worker # Worker
pnpm run dev:transcoder # Go transcoder (needs Go toolchain + ffmpeg)
pnpm run dev -w @strum-vod/dashboard # Dashboard on :1337
pnpm run dev:player # Player appTesting
The API and worker have E2E test suites that exercise real HTTP routes against real infrastructure.
# Requires Docker — containers start automatically
pnpm test:e2e # API suite (PostgreSQL + Redis via Testcontainers)
pnpm --filter @strum-vod/worker run test:e2e # Worker suite (Postgres + Redis + MinIO)Tests use Vitest with Testcontainers: PostgreSQL 16 and Redis 7 spin up automatically per run, migrations are applied, and every test fires real SQL. The API suite mocks S3; the worker suite runs a real MinIO container.
| Test file (API) | Coverage |
|---|---|
health.test.ts | /health/*, /v1/config |
auth.test.ts | Signup, login, OTP, /v1/auth/me |
assets.test.ts | Asset CRUD, upload flow, import, process, org isolation |
playback.test.ts | Playback resolution, comments, reactions |
whisper-callback.test.ts | Async Modal transcription callback (HMAC, resume/retry) |
settings.test.ts | Org settings & AI provider config |
The worker suite additionally covers the Redis Streams bridge, the full transcode → AI pipeline (with a real Go transcoder binary + real ffmpeg encode against MinIO), and the Modal async flow.
100% Local & Sovereign Setup
Strum VOD can run entirely on your infrastructure with zero external API calls.
| Zero Cloud Dependencies | Air-Gap Ready | GPU-Accelerated |
|---|---|---|
| No OpenAI, no third-party APIs required. Everything can run on your hardware. | Works completely offline after setup. | Leverage NVIDIA GPUs for fast transcription with faster-whisper. |
License
STRUM Proprietary License — © 2026 Strum. All rights reserved. This software is the exclusive property of STRUM and is not open source. See LICENSE for the full terms.
Documentation
Full technical documentation (architecture, configuration, deployment, API reference) lives in the docs/ directory and is also rendered as a VitePress site. Run it locally with:
pnpm docs:dev # dev server with hot reload
pnpm docs:build # static build → docs/.vitepress/dist
pnpm docs:preview # serve the built site (default: http://localhost:4173)The docs site is deployed to Cloudflare Pages automatically via CI (.github/workflows/docs.yml) — production on every main push that touches docs, plus a preview URL per PR. Manual deploy: pnpm deploy:docs (see docs/deployment.md).
The dashboard sidebar links to these docs via the
VITE_DOCS_URLbuild-time env var (default:http://localhost:4173). Set it to your deployed docs URL in production.
If Strum VOD is useful to you, consider starring the repo!
Built with FFmpeg, Fastify, Go, and Postgres by Synapsr