Configuration
All configuration is done through environment variables. Copy .env.example to .env and adjust as needed.
cp .env.example .envApplication
| Variable | Default | Description |
|---|---|---|
PORT | 3000 | API server port |
CORS_ORIGIN | * | Allowed origins for CORS. * for all, or comma-separated list. Fallback only — when a global CORS allowlist is configured in Admin → CORS & Embeds (GET/PATCH /v1/admin/settings/cors, stored on the global settings row), the DB list takes precedence and applies without a redeploy |
DASHBOARD_URL | http://localhost:3001 | Dashboard base URL (always allowed as a CORS/embed origin) |
PLAYER_BASE_URL | — | Standalone player app base URL (always allowed as a CORS/embed origin) |
Timezone
Set the TZ environment variable to control the timezone used by all processes (API, worker, transcoder) and PostgreSQL sessions. The value is passed as the Postgres TimeZone connection parameter by packages/db/src/client.ts and apps/transcoder/internal/db/db.go, ensuring now(), CURRENT_TIMESTAMP, and all timestamp comparisons are consistent.
| Variable | Default | Description |
|---|---|---|
TZ | America/Sao_Paulo | OS-level timezone identifier (e.g. America/Sao_Paulo, UTC, Europe/London). Brazil abolished DST in 2019, so America/Sao_Paulo is UTC-3 year-round |
In Docker Compose and Fly.io deployments, TZ is set per-service/environment automatically. For self-hosted or alternative deployments, set TZ in your container/environment config.
CORS & embed allowlists (Admin → CORS & Embeds)
Superadmins can manage two instance-wide lists from the dashboard instead of touching CORS_ORIGIN:
- Allowed origins (API CORS) — which browser origins may call the API. Empty = falls back to
CORS_ORIGIN;*= allow all. - Allowed embed domains — which origins may embed the player. Enforced as
frame-ancestorson the/embedand/watchpages (API-served pages viaapps/api-edge's response hook, the standalone player app via its Pages middlewarefunctions/_middleware.ts— set the player'sAPI_BASEvar) and as anOrigin/Referercheck on the public/v1/playback/*endpoints. Empty = unrestricted.
Entries accept exact origins (https://example.com), subdomain wildcards (*.example.com), or *. The platform's own frontends (DASHBOARD_URL / PLAYER_BASE_URL) are always allowed — set those env vars on the API to match the real deployed origins, or add them to the lists manually (otherwise a restricted embed list blocks the platform's own apps). Changes apply immediately (5s cache in apps/api-edge/src/services/cors-config.ts).
Scope note: by default, this protects the API metadata surface (playback JSON, audio/download URLs) and iframe framing (
frame-ancestors). The HLS segments themselves are served from the public R2 bucket (S3_PUBLIC_BASE_URL). However, if you configure the Cloudflare Integration (usingCLOUDFLARE_API_TOKENandCLOUDFLARE_ZONE_ID), the platform will automatically synchronize your allowed embed list with a Cloudflare WAF Custom Rule on your media custom domain, blocking byte-level hotlinking of segments and playlists for unauthorized referers.
Database
| Variable | Default | Description |
|---|---|---|
DATABASE_URL | — | PostgreSQL connection string (Neon-compatible): postgresql://user:pass@host:5432/database?sslmode=require |
Neon pooled endpoints: point
DATABASE_URLat the direct endpoint (drop-poolerfrom the host). The API manages its own pool, and pgBouncer's transaction mode can hand out connections with an emptysearch_path. SeeDEPLOYMENT-juninho.mdfor the full gotcha.
Redis
| Variable | Default | Description |
|---|---|---|
REDIS_URL | redis://redis:6379 | Redis connection string for BullMQ + Redis Streams |
S3 / R2 Storage
| Variable | Default | Description |
|---|---|---|
S3_ENDPOINT | — | S3-compatible API endpoint |
S3_REGION | us-east-1 | Region (auto for Cloudflare R2) |
S3_BUCKET | strum-vod | Bucket name |
S3_ACCESS_KEY_ID | — | Access key |
S3_SECRET_ACCESS_KEY | — | Secret key |
S3_FORCE_PATH_STYLE | false | Use path-style URLs. true for MinIO; false for AWS S3 and Cloudflare R2 |
S3_PUBLIC_ENDPOINT | same as S3_ENDPOINT | Public endpoint used when generating presigned upload URLs |
S3_PUBLIC_BASE_URL | — | Public URL prefix for HLS playback (e.g. https://pub-hash.r2.dev) |
S3_BACKUP_BUCKET | — | Bucket for daily pg_dump backups (optional) |
Cloudflare R2 notes:
- R2 does not support per-object
ACL: public-readorPutBucketCorsvia the S3 API.- Enable public access and configure CORS rules via
pnpm r2:cors:set(config ininfra/r2/).- Set
S3_FORCE_PATH_STYLE=falseandS3_REGION=autofor R2.
Cloudflare Integration (Optional)
Automates Cloudflare caching (Tiered Cache) and WAF protection rules to prevent HLS hotlinking. When configured, changing allowed embed domains in the Dashboard (Admin -> CORS & Embeds) automatically synchronizes the WAF custom rules on your R2 custom domain in the background.
| Variable | Default | Description |
|---|---|---|
CLOUDFLARE_API_TOKEN | — | Cloudflare API token with Zone Settings:Edit and Zone WAF:Edit permissions |
CLOUDFLARE_ZONE_ID | — | Cloudflare Zone ID for the custom domain |
CLOUDFLARE_MEDIA_DOMAIN | — | R2 Custom Domain (e.g. media.yourdomain.com). Optional: parsed from S3_PUBLIC_BASE_URL if not set |
CLOUDFLARE_ALLOWED_DOMAINS | — | Comma-separated domains always allowed to play HLS. Optional: parsed from DASHBOARD_URL/VITE_API_BASE_URL |
A manual synchronization can be run using the CLI command pnpm cf:configure or by sending a POST request to /v1/admin/settings/cloudflare/sync (requires superadmin token).
Security
| Variable | Default | Description |
|---|---|---|
JWT_SECRET | — | Secret for signing session JWTs (required for auth). Generate with openssl rand -hex 32 |
SHARED_AUTH_SECRET | — | Base64-encoded 32-byte key the API uses to sign and verify the resumable TUS upload JWT (both ends live in apps/api now — no second service to share it with). Required to enable the resumable TUS upload mode. Generate with: openssl rand -base64 32 |
TRIGGER_SECRET_KEY | — | trigger.dev prod secret key (tr_prod_..., from the trigger.dev dashboard's API Keys page) — set on apps/api-edge (Cloudflare Workers secret). Without it, POST /v1/assets/:id/process and every AI-pipeline trigger 500 immediately (no fallback path). See Deployment |
SUPERADMIN_BOOTSTRAP_KEY | — | Optional. Gates POST /v1/admin/bootstrap-superadmin (X-Bootstrap-Key header) — the only way to flip users.is_superadmin without already holding a superadmin JWT, for bootstrapping the first superadmin on a fresh stack. Left unset the route 501s (disabled). apps/cli's strum dev promote/demote/whoami use it. Generate with openssl rand -base64 32; set on apps/api-edge only |
Unified worker fleet (machine tokens)
The transcode/AI worker binary (apps/transcoder/cmd/transcoder) authenticates to the broker (apps/api-edge/src/routes/worker-agent.ts, /v1/worker-agent/*) with a machine token (mt_live_...), not any of the env vars above — there's no static credential to put in an env file. A superadmin mints one via POST /v1/admin/machine-tokens, then the binary is started with --api-url <api base> --token mt_live_.... One token can be shared across many physical machines (each self-registers its own machines row on first heartbeat/claim, keyed by a locally-generated machine ID persisted to disk). See Deployment.
apps/trigger-tasks's own env vars (separate from everything above)
apps/trigger-tasks runs inside trigger.dev's own hosted environment, not this app's — its env vars (DATABASE_URL, S3_*, DEEPGRAM_API_KEY, API_PUBLIC_URL, TRANSCRIPTION_PROVIDER, etc.) are configured on the trigger.dev dashboard (or synced at deploy time via trigger.config.ts's syncEnvVars extension — the CLI has no env set command), not in this repo's .env. See Deployment for the exact deploy command.
MCP Server (optional)
Exposes the API as a Model Context Protocol server so AI assistants/clients can read and manage your org's assets through the same API keys and JWTs the dashboard uses. See the MCP Server page for the full tool reference and client setup.
| Variable | Default | Description |
|---|---|---|
MCP_ENABLED | false | Set to true to register the MCP endpoint (fastify-mcp-server plugin) |
MCP_ENDPOINT | /mcp | HTTP path the MCP streamable-HTTP endpoint is mounted at |
Dashboard Build-Time Variables (Vite)
These are injected at build time by Vite. They must be set before running pnpm run build -w @strum-vod/dashboard.
| Variable | Default | Description |
|---|---|---|
VITE_API_BASE_URL | http://localhost:13002 | API base URL |
VITE_TUS_SERVER_URL | — | Base URL the TUS client appends /upload/videos to — normally the same as VITE_API_BASE_URL, since TUS is served by the API. When set, enables the "Resumable (TUS)" upload toggle in the dashboard |
VITE_PLAYER_BASE_URL | — | Player app URL (e.g. https://player.strum-vod.dev). Used to generate embed codes pointing to the player app |
VITE_SENTRY_DSN | — | Browser error reporting DSN (GlitchTip/Sentry-compatible) |
Player App Build-Time Variables (Vite)
| Variable | Default | Description |
|---|---|---|
VITE_API_BASE_URL | — | API base URL. Set in apps/player/.env or at build time |
Registration
| Variable | Default | Description |
|---|---|---|
REGISTRATION_ENABLED | true | Set to false to disable new account creation |
REGISTRATION_ALLOWED_DOMAINS | — | Comma-separated allowed email domains (e.g. company.com,partner.org) |
AI Processing (optional — now a trigger.dev project, not apps/worker)
Omit all AI variables to disable AI features entirely. These run inside the apps/trigger-tasks tasks, so they're set as trigger.dev project env vars (dashboard or trigger.config.ts's syncEnvVars), not this app's .env — the legacy apps/worker AI path that read them from a local env was deleted (2026-08-17). They can also be set (or overridden) from the dashboard's Settings → AI Provider Configuration, which writes the DB config packages/ai-pipeline's resolveAiConfig merges over these env-var defaults.
| Variable | Description |
|---|---|
TRANSCRIPTION_PROVIDER | local (default) | deepgram | assemblyai | 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 API key (when TRANSCRIPTION_PROVIDER=deepgram) |
DEEPGRAM_MODEL | Deepgram model (e.g. nova-2) |
ASSEMBLYAI_API_KEY | AssemblyAI API key (when TRANSCRIPTION_PROVIDER=assemblyai) |
LLM_PROVIDER | Chapter generation: openai, anthropic, groq, or custom |
LLM_API_KEY | API key for LLM service |
LLM_MODEL | Model name (e.g. gpt-4o-mini, llama-3.3-70b-versatile) |
LLM_API_URL | Custom LLM endpoint (leave empty for provider default) |
LLM_CHAPTERS_MODEL | Per-step override for chapter generation — falls back to LLM_MODEL when unset |
LLM_HIGHLIGHTS_MODEL | Per-step override for highlight generation (single-pass and long-video Scout/Curator) — falls back to LLM_MODEL |
LLM_CONTENT_TYPE_DETECTION_MODEL | Per-step override for the content-type auto-detect classification pass — a cheaper model is a good fit here (e.g. gpt-4o-mini) |
LLM_SMART_METADATA_MODEL | Per-step override for smart metadata generation — falls back to LLM_MODEL |
LLM_TRANSLATION_MODEL | Per-step override for subtitle translation — falls back to LLM_MODEL |
AI_ENABLED | Set to false to disable AI even if keys are configured |
Async (modal) transcription — extra vars
| Variable | Description |
|---|---|
WHISPER_WEBHOOK_SECRET | HMAC secret Modal signs the callback with (X-Signature). Must match the whisper-webhook-secret Modal Secret. Generate with openssl rand -hex 32 |
API_PUBLIC_URL | Publicly reachable base URL of apps/api-edge — Modal calls back to <API_PUBLIC_URL>/v1/ai/whisper-callback from its own cloud (tunnel or deployed API required; localhost won't work). Set on apps/api-edge and the trigger.dev project |
Subtitle Translation (optional — requires LLM)
| Variable | Default | Description |
|---|---|---|
AI_AUTO_TRANSLATE | false | Global setting: enable auto-translation to English when AI processing runs. Can be overridden per-asset via aiOptions.translations |
aiOptions.translations (API) | — | Per-asset target languages for subtitle translation (ISO-639-1 codes, e.g. ["en", "es", "fr"]). Requires subtitles + LLM configured |
aiOptions.translationGlossary (API) | — | Optional glossary for domain-specific terms (term → translation mapping) |
Transcoding Ladder (Go transcoder)
| Variable | Default | Description |
|---|---|---|
RENDITION_CODEC | h264 | h264 or hevc — base codec stamped on every rendition |
MAX_RENDITION_HEIGHT | 0 | Cap the tallest rendition (0 = full 360p–4320p). E.g. 2160 drops the 4320p rung. Code default is 0; the production deploy and Docker Compose cap at 2160 (cost cut — see docs/costs.md Fase 4) |
HEVC_MIN_HEIGHT | 0 | Hybrid ladder: renditions at/above this height encode in HEVC, lower rungs keep the base codec (0 = disabled) |
FFMPEG_HWACCEL | auto | auto | vaapi | disabled — hardware acceleration for the ladder |
Audio Extraction (optional)
| Variable | Default | Description |
|---|---|---|
AUDIO_PLAYBACK_BITRATE_KBPS | 128 | AAC bitrate for playback audio (32–320) |
AUDIO_PLAYBACK_SAMPLE_RATE | 48000 | Sample rate in Hz (8000–96000) |
AUDIO_PLAYBACK_CHANNELS | 2 | 1=Mono, 2=Stereo |
AUDIO_AI_BITRATE_KBPS | 64 | MP3 bitrate for Whisper AI (32–128) |
AUDIO_AI_SAMPLE_RATE | 16000 | Sample rate in Hz (8000–48000) |
Scaling (auto-detected)
The Go transcoder auto-detects CPU cores and RAM (cgroup-aware) at startup. Override only if the auto-detected values are wrong.
| Variable | Used by | Default | Description |
|---|---|---|---|
WORKER_CONCURRENCY | Transcoder (Go) | auto | Concurrent transcode jobs |
FFMPEG_THREADS | Transcoder (Go) | auto | Threads per FFmpeg process |
DB_POOL_SIZE | API, Transcoder | auto | PostgreSQL connection pool size |
AI_WORKER_CONCURRENCY | Worker (Node) | — | Removed (Phase 4) — no BullMQ worker concurrency anymore |
QStash Job Layer (replaces BullMQ queues + Redis Streams)
| Variable | Default | Description |
|---|---|---|
QSTASH_TOKEN | — (required) | Publish authorization token (console.upstash.com/qstash) — env/config validation fails at boot without it |
QSTASH_CURRENT_SIGNING_KEY / QSTASH_NEXT_SIGNING_KEY | — (required) | Verify inbound Upstash-Signature headers (current + next for rotation) |
QSTASH_URL | https://qstash.upstash.io | SDK base URL override (local dev/tests point this at a stub) |
TRANSCODER_PUBLIC_URL | https://<FLY_TRANSCODER_APP>.fly.dev | QStash publish target for the transcoder's /qstash/jobs |
WORKER_PUBLIC_URL | https://<FLY_WORKER_APP>.fly.dev | QStash publish target for the worker's /qstash/* consumers |
API_PUBLIC_URL | — | QStash publish target for the API's own consumers/schedules |
Local dev without a real Upstash account: pnpm dev:qstash runs @upstash/qstash-cli dev, an in-memory local QStash emulator that pushes straight to localhost — no tunnel needed. It prints 4 sets of test credentials on startup; paste one set's token/signing keys into .env for all three processes (api/worker/transcoder — the signing keys must match to verify inbound requests), point QSTASH_URL at it (http://localhost:42932), and point the *_PUBLIC_URL vars above at each process's local port instead of a Fly hostname. Full recipe, including the port-collision note (worker/transcoder both default WORKER_HTTP_PORT / TRANSCODER_HTTP_PORT to 8080, same as the emulator's own default) and the docker-compose variant, is in .env.example's "QStash local dev" block.
Fly.io Worker / Transcoder Wake (optional)
Scale-to-zero machines are woken on every job enqueue. The API (apps/api) fires worker + transcoder wakes in parallel (prewarmFleet in services/fly-machine.ts), preferring the Fly Machines API (POST /machines/:id/start) when FLY_API_TOKEN is set and falling back to the public /wake proxy. apps/worker's transcode bridge re-POSTs /wake after each relay as a belt-and-suspenders fallback.
| Variable | Description |
|---|---|
FLY_WORKER_APP | Fly app name of apps/worker (set on apps/api) — used to construct the /wake URL / Machines API app |
FLY_TRANSCODER_APP | Fly app name of apps/transcoder (set on apps/api and apps/worker) — enables the parallel transcoder wake on job enqueue |
WORKER_HTTP_PORT | Internal HTTP port (fly.worker.toml, default 8080) |
FLY_API_TOKEN | Fly Machines API token — enables direct machine start (parallel wake) + /diagnostics machine listing/force-start |
Email / OTP (optional)
| Variable | Default | Description |
|---|---|---|
EMAIL_PROVIDER | auto-detect | resend | smtp | console |
EMAIL_FROM | noreply@strum-vod.local | From address |
RESEND_API_KEY | — | Resend API key |
SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS / SMTP_SECURE | — | SMTP server config |
Billing (optional)
Adding Stripe keys enables tiered billing, usage metering, and plan limits. Without Stripe, all features are available with no usage restrictions.
| Variable | Description |
|---|---|
STRIPE_SECRET_KEY | Stripe secret key |
STRIPE_WEBHOOK_SECRET | Stripe webhook secret |
STRIPE_PRO_PRICE_ID / STRIPE_BUSINESS_PRICE_ID | Plan price IDs |
Webhooks (optional)
| Variable | Default | Description |
|---|---|---|
WEBHOOK_URL | — | Default org webhook target |
Error Tracking (optional — GlitchTip via Docker)
| Variable | Description |
|---|---|
GLITCHTIP_SECRET_KEY | GlitchTip Django secret |
GLITCHTIP_DB_PASSWORD | GlitchTip Postgres password |
GLITCHTIP_DOMAIN | GlitchTip public URL |
SENTRY_DSN | API error reporting DSN |
VITE_SENTRY_DSN | Dashboard build-time DSN |
Example Configurations
Docker Compose (local dev — MinIO)
DATABASE_URL=postgresql://strum_vod:strum-vodpassword@postgres:5432/strum_vod
PORT=3000
S3_ENDPOINT=http://minio:9000
S3_REGION=us-east-1
S3_BUCKET=strum-vod
S3_ACCESS_KEY_ID=minioadmin
S3_SECRET_ACCESS_KEY=minioadmin
S3_FORCE_PATH_STYLE=true
S3_PUBLIC_ENDPOINT=http://localhost:42943
S3_PUBLIC_BASE_URL=http://localhost:42943/strum-vod
DASHBOARD_URL=http://localhost:13003
CORS_ORIGIN=http://localhost:13003,http://localhost:13002
VITE_API_BASE_URL=http://localhost:13002
JWT_SECRET=dev-secret-change-meProduction — Cloudflare R2 + Neon
DATABASE_URL=postgresql://user:pass@ep-xyz.region.aws.neon.tech/neondb?sslmode=require
S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
S3_REGION=auto
S3_BUCKET=strum-videos
S3_ACCESS_KEY_ID=<R2 key>
S3_SECRET_ACCESS_KEY=<R2 secret>
S3_FORCE_PATH_STYLE=false
S3_PUBLIC_BASE_URL=https://media.usestrum.app
DASHBOARD_URL=https://dashboard.strum-vod.dev
CORS_ORIGIN=https://dashboard.strum-vod.dev,https://player.strum-vod.dev
VITE_API_BASE_URL=https://api.strum-vod.fly.dev
VITE_TUS_SERVER_URL=https://api.strum-vod.fly.dev
VITE_PLAYER_BASE_URL=https://player.strum-vod.dev
JWT_SECRET=<openssl rand -hex 32>
SHARED_AUTH_SECRET=<openssl rand -base64 32>Production — AWS S3 + RDS
DATABASE_URL=postgresql://admin:password@mydb.us-east-1.rds.amazonaws.com:5432/strum_vod
REDIS_URL=redis://my-redis.cache.amazonaws.com:6379
S3_ENDPOINT=https://s3.us-east-1.amazonaws.com
S3_REGION=us-east-1
S3_BUCKET=my-strum-vod-bucket
S3_ACCESS_KEY_ID=AKIA...
S3_SECRET_ACCESS_KEY=...
S3_FORCE_PATH_STYLE=false
S3_PUBLIC_BASE_URL=https://my-strum-vod-bucket.s3.us-east-1.amazonaws.com
VITE_API_BASE_URL=https://api.example.com
JWT_SECRET=<openssl rand -hex 32>Validation
apps/worker validates its env vars at startup via Zod (apps/worker/src/env.ts) — a missing/invalid required variable fails startup with a descriptive error. apps/api-edge (Cloudflare Workers) has no such runtime validation; its vars come from wrangler.toml [vars]/wrangler secret, checked only implicitly by whatever code path reads them.
TUS resumable upload (apps/tus-edge/src/index.ts) is optional: without SHARED_AUTH_SECRET set, the API logs a warning at startup and doesn't register /upload/videos at all (404, not 401) — the dashboard falls back to presigned-URL upload automatically.