Self-hosted encrypted media streaming vault for browsing, uploading, and protecting personal media through a web interface. Built with React Router v7 and pure Bun runtime.
- π¬ Stream local media files through a web browser
- π SQLite-backed account login with httpOnly site sessions
- π Browser-first upload with staged commit into the library
- π Protected DASH playback with JWT tokens, ClearKey, and encrypted media packaging
- π¨ YouTube-inspired UI for video browsing
- β‘ Pure Bun runtime for maximum performance
# Install dependencies
bun install
# Create local environment config
cp .env.example .env
# Start development server
bun run devAccess at http://localhost:5173
Create the first local login account after the server starts by enabling the operator-only admin API for bootstrap and calling it with a bearer token:
MEDIAVAULT_ADMIN_API_MODE=bootstrap \
MEDIAVAULT_ADMIN_API_TOKEN=dev-admin-token \
MEDIAVAULT_DATABASE_ENCRYPTION_KEY=dev-database-key \
bun run dev
curl -fsS \
-H "Authorization: Bearer dev-admin-token" \
-H "Content-Type: application/json" \
-d '{"username":"owner","password":"vault-password"}' \
http://localhost:5173/api/admin/usersbun run dev is for trusted local development only. Do not expose the Vite
development server through a public tunnel, reverse proxy, or untrusted LAN. Use
bun run build and bun run start, or the Docker production image, for
deployment. When MEDIAVAULT_STORAGE_DIR is not set, development runtime storage defaults
to a checkout-specific directory outside this repository so Vite cannot serve
runtime media, keys, or SQLite data from the project root.
# Build application
bun run build
# Start production server
bun startCreate login accounts through the operator-only admin API. For first-run
production bootstrap, set MEDIAVAULT_ADMIN_API_MODE=bootstrap and
MEDIAVAULT_ADMIN_API_TOKEN to a deployment-specific random value, start the
server, and call POST /api/admin/users with Authorization: Bearer <token>.
Set MEDIAVAULT_DATABASE_ENCRYPTION_KEY before starting the app so the primary
SQLite database can be opened. For protected playback routes, set MEDIAVAULT_PLAYBACK_JWT_SECRET.
For ingest and encrypted playback packaging, also set MEDIAVAULT_MEDIA_KEY_DERIVATION_SECRET.
For signed client identity cookies, set MEDIAVAULT_AUTH_CLIENT_COOKIE_SECRET.
When NODE_ENV=production, Mediavault runs a startup preflight and refuses to start
without the full vault requirements: at least one auth account in the primary SQLite
database or a bootstrap admin API token, MEDIAVAULT_PLAYBACK_JWT_SECRET,
MEDIAVAULT_MEDIA_KEY_DERIVATION_SECRET, MEDIAVAULT_AUTH_CLIENT_COOKIE_SECRET,
MEDIAVAULT_DATABASE_ENCRYPTION_KEY, and usable configured storage.
Mediavault publishes production images to GitHub Container Registry after verified
main pushes. Pulling and restarting the service on a host is an operator-owned
deployment step.
docker pull ghcr.io/nbsp1221/mediavault:latestThe current checked-in docker-compose.yaml remains a local source-build path. Do not
use docker compose pull as the GHCR image update path until a future Compose migration
changes that file to consume the published image.
# Start the application
docker-compose up -d
# Access at http://localhost:3000For remote browser use, run the container behind an HTTPS reverse proxy. Production auth cookies are secure cookies, so plain remote HTTP is not a complete production deployment. Reverse proxy routing, TLS certificates, firewall rules, and public port exposure are operator-owned infrastructure responsibilities.
β
Pure Bun runtime - Fast, modern JavaScript runtime
β
Security hardened - Non-root user, minimal capabilities
β
Health monitoring - Auto-restart on failure
β
Persistent storage - Data and videos preserved
Docker healthchecks use GET /health/ready. The endpoint returns 204 No Content only
when the production full-vault readiness checks pass, and 503 Service Unavailable with
no diagnostic body when they fail. Detailed causes are written to container logs.
The app writes to /app/storage inside the container.
By default, Docker Compose backs that path with the named volume
mediavault-storage. If you want a host-visible bind mount, edit
docker-compose.yaml and replace the named volume with a path such as
./storage:/app/storage.
- named volume: Docker-managed storage with fewer host permission issues
- bind mount: host-visible files, but host ownership and write permissions matter
Storage layout:
storage/db.sqlite
storage/videos/
storage/staging/
The production image provisions FFmpeg, ffprobe, and Shaka Packager for browser upload commit and encrypted DASH packaging.
# Start
docker-compose up -d
# View logs
docker-compose logs -f
# Stop
docker-compose downCreate .env file before starting the app:
cp .env.example .envRequired for the full vault feature set:
MEDIAVAULT_DATABASE_ENCRYPTION_KEY: encryption key for the primary SQLite database at restMEDIAVAULT_PLAYBACK_JWT_SECRET: signing secret for protected playback token issuanceMEDIAVAULT_MEDIA_KEY_DERIVATION_SECRET: secret used to derive per-video encryption keysMEDIAVAULT_AUTH_CLIENT_COOKIE_SECRET: signing secret for client identity cookies- at least one SQLite auth account, or first-run bootstrap through
MEDIAVAULT_ADMIN_API_MODE=bootstrapandMEDIAVAULT_ADMIN_API_TOKEN
Generate deployment-specific secret values before starting the full vault path. The database encryption key, media key derivation secret, playback JWT secret, auth client cookie secret, and admin API token are free-form strings, but they should be cryptographically random. They do not need to be hex-encoded.
The app requires the database encryption key before opening the primary SQLite database. In production, all secret values must be present and non-blank before the app starts. Runtime preflight does not score secret strength, so weak values are an operator mistake, not something the app can reliably fix at startup.
openssl rand -base64 32
bun -e "const { randomBytes } = await import('node:crypto'); console.log(randomBytes(32).toString('base64url'))"Optional:
MEDIAVAULT_MEDIA_KEY_DERIVATION_SALT: salt value used during playback key derivationMEDIAVAULT_STORAGE_DIR: override the unified storage root fordb.sqlite, committed media, and staged uploadsMEDIAVAULT_ADMIN_API_MODE: account management API mode, one ofdisabled,bootstrap, oralwaysMEDIAVAULT_ADMIN_API_TOKEN: bearer token for the operator-only account management APIMEDIAVAULT_AUTH_CLIENT_COOKIE_NAME: override the client identity cookie nameMEDIAVAULT_AUTH_SESSION_COOKIE_NAME: override the auth session cookie nameMEDIAVAULT_AUTH_SESSION_TTL_MS: session lifetime in millisecondsMEDIAVAULT_AUTH_TRUST_PROXY_HEADERS: trust forwarded client IP headers for rate limitingMEDIAVAULT_AUTH_FAILED_LOGIN_BLOCK_DURATION_MS: failed-login block durationMEDIAVAULT_AUTH_FAILED_LOGIN_DELAY_MS: invalid-login response delayMEDIAVAULT_AUTH_FAILED_LOGIN_WINDOW_MS: failed-login tracking windowMEDIAVAULT_AUTH_MAX_FAILED_LOGIN_ATTEMPTS: failed-login threshold before blockingFFMPEG_PATH: override the FFmpeg binary pathFFPROBE_PATH: override the ffprobe binary pathSHAKA_PACKAGER_PATH: override the Shaka Packager binary path
Notes:
- Use
/loginfor the site auth flow. - Production Docker readiness requires the full vault configuration, writable storage, at least one auth account or bootstrap admin API token, and runnable FFmpeg, ffprobe, and Shaka Packager.
- In
bootstrapmode,POST /api/admin/usersis available only until the first account exists. Usealwaysonly when you intentionally want ongoing account automation. Rotate the token by updating the environment and restarting the container. - Back up
MEDIAVAULT_MEDIA_KEY_DERIVATION_SECRETwith the storage volume and primary SQLite database. Existing encrypted media depends on preserving that value. - Back up
MEDIAVAULT_DATABASE_ENCRYPTION_KEYwith the primary SQLite database. Existing database contents cannot be opened if that value is lost or changed. MEDIAVAULT_MEDIA_KEY_DERIVATION_SALTis optional. If you customize it, preserve it with the media key derivation secret and storage backup.- Video segments use DASH/CENC/ClearKey with a per-video
key.bin. Thumbnails use the same per-video key and are stored atthumbnail.jpgas a Mediavault AES-128-GCM envelope; authenticated HTTP responses expose only normalimage/jpegbytes. - The default Compose port binding
3000:3000is for simple reachability. For a hardened deployment, restrict direct HTTP access with firewall rules, bind the port to loopback, or place the app behind a private proxy network. - The protected playback path issues
/videos/:videoId/token, resolves/videos/:videoId/manifest.mpd, and serves/videos/:videoId/clearkeyfor the browser license flow.
For the current architecture and repo state, start here:
docs/roadmap/current-refactor-status.mddocs/roadmap/personal-video-vault-rearchitecture-phases.mddocs/architecture/personal-video-vault-target-architecture.md
- Frontend: React Router v7 with SSR
- Runtime: Bun (pure Bun, no Node.js)
- Styling: TailwindCSS v4
- Persistence: Primary SQLite database for auth sessions, canonical video metadata, playlists, and ingest state
- Video: FFmpeg for thumbnails and streaming
- Streaming: DASH with JWT token issuance, ClearKey license delivery, and encrypted media packaging
- Thumbnail storage: AES-128-GCM envelope at
thumbnail.jpg, decrypted server-side after authentication
The test suite is split by scope:
bun run test:modules: colocated module and policy testsbun run test:integration: route and auth integration testsbun run test:ui-dom: jsdom + React Testing Library component testsbun run vitest:ui: interactive Vitest UI for local debugging onlybun run test: all Vitest projectsbun run test:runtime:smoke: runtime smoke against the dev server and built Bun serverbun run check:fast: quick local check for iteration, not a completion gatebun run check: hermetic lint + typecheck + calibrated coverage + changed-file mutation + runtime smokebun run check:runtime:checkplus required browser smoke and Docker Compose smokebun run check:docker-compose-smoke: Docker Compose production readiness contractbun run test:e2e:smoke: required browser smoke command
Why the smoke layers exist:
- Vitest runs in Node for this repo
bun run devandbun run startdo not execute route code the same way- runtime-only regressions, such as unsupported module schemes, must be caught separately
Runtime smoke verifies that the dev server and built Bun server preserve the critical startup, auth, protected route, token, thumbnail, and owner playlist contracts that ordinary Vitest coverage does not prove.
Browser verification remains a separate step for UI and playback flows. See docs/E2E_TESTING_GUIDE.md.
If Playwright playback fixtures need to be rebuilt for the browser-safe H.264 policy, refresh them with:
bun run backfill:browser-playback-fixturesThe script rebuilds only the known Playwright fixture video IDs and leaves already-compatible manifests untouched.
Built with β€οΈ using React Router and Bun.