Deployment
Staging — a fully isolated staging environment (own Workers, DB, buckets, Redis, trigger.dev project) driven by the
stagingbranch. See Staging Environment.api-edgeandtus-edgedeploy automatically via.github/workflows/deploy-backend.yml; frontends via.github/workflows/deploy-frontends.yml.
Deploying apps/api-edge + apps/trigger-tasks (production path)
1. Provision trigger.dev's prod environment
The trigger.dev CLI has no env set command — the only non-dashboard way to push env vars is the syncEnvVars build extension in apps/trigger-tasks/trigger.config.ts, which reads them from this process's own env at deploy time (DEPLOY_*-prefixed, never committed):
cd apps/trigger-tasks
DEPLOY_DATABASE_URL='postgresql://...' # Neon prod URL
DEPLOY_S3_ENDPOINT='https://<account>.r2.cloudflarestorage.com' \
DEPLOY_S3_ACCESS_KEY_ID='...' \
DEPLOY_S3_SECRET_ACCESS_KEY='...' \
DEPLOY_DEEPGRAM_API_KEY='...' # optional (Deepgram provider)
DEPLOY_FLY_API_TOKEN='...' # optional (Fly wake strategy)
npx trigger.dev@latest deploytrigger.config.ts also pushes fixed non-secret values every deploy (S3_BUCKET=strum-vod, S3_REGION=auto, S3_FORCE_PATH_STYLE=false, API_PUBLIC_URL, TRANSCRIPTION_PROVIDER) — edit that file directly to change them.
Use --dry-run first to validate the build without deploying:
npx trigger.dev@latest deploy --dry-run2. Set TRIGGER_SECRET_KEY on the Worker
Get the prod secret key (tr_prod_...) from the trigger.dev dashboard's API Keys page, then:
cd apps/api-edge
printf '%s' 'tr_prod_...' | npx wrangler secret put TRIGGER_SECRET_KEY3. Deploy apps/api-edge
cd apps/api-edge
npx wrangler deployThe manual wrangler deploy works in a pinch; the normal path is CI: push to main and .github/workflows/deploy-backend.yml deploys it (and tus-edge). Push to staging to deploy the isolated staging env — see Staging Environment.
This step flips production dispatch behavior — once live, every POST /v1/assets/:id/process triggers the transcode-orchestration trigger.dev task. Do steps 1–2 first, or every new transcode 500s (triggerTask throws with no fallback).
Cron triggers: there are no Cloudflare Cron Triggers on api-edge (wrangler.toml sets crons = []). Every scheduled job runs on trigger.dev instead:
| Task | Schedule | Purpose |
|---|---|---|
analytics-daily | 0 2 * * * | Daily analytics rollup |
analytics-hourly | */5 * * * * | Hourly analytics flush |
analytics-cleanup | 0 3 * * * | Analytics retention cleanup |
db-backup | 0 5 * * * | Daily Neon backup |
reclaim-stale-dispatch-jobs | */2 * * * * | Reaps stuck claimed/processing leases + orphaned queued jobs (replaces the old in-process dispatch-reaper) |
schedules.task(...) in apps/trigger-tasks/src/trigger/*.ts lists them; deploy with step 5's trigger deploy. Keeping crons off Cloudflare avoids the Workers Free 5-cron-per-account cap.
4. Run the production migration
Migrations run automatically in CI — .github/workflows/deploy-backend.yml runs pnpm db:migrate against the production environment's DATABASE_URL GitHub secret right before deploying api-edge/tus-edge, so landing a packages/db change reaches the database without a manual step. The migration is skipped when a push didn't touch packages/db/** (and always runs on a manual workflow_dispatch). A failed migration aborts the deploy. You can still run it by hand (e.g. to catch up a database that predates CI), which is safe to re-run:
DATABASE_URL='postgresql://...' pnpm --filter @strum-vod/db run migrateEvery statement is CREATE TABLE IF NOT EXISTS / ALTER TABLE ... ADD COLUMN IF NOT EXISTS, safe to re-run. bootstrapDefaultOrg() (also run by this script) is a no-op once every assets row has a real org_id.
5. Mint a machine token and run a worker
A "worker" is the Go binary (apps/transcoder/cmd/transcoder) running anywhere — an operator's laptop, a VPS, or (eventually) the Fly fleet. No Fly deploy required. See Worker Setup for the full runbook (systemd unit, Docker, monitoring, revoking access) — quick version:
# Get a superadmin JWT (login or OTP), then:
curl -X POST https://vapi.usestrum.app/v1/admin/machine-tokens \
-H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \
-d '{"name":"my-machine"}'
# → { "data": { "token": "mt_live_..." } }
# For an ephemeral worker (laptop, burst box), prefer a bounded lease so a
# stolen token dies on its own: add "ttlDays":7 (1-365) to the JSON above.
cd apps/transcoder && go build -o strum-transcoder ./cmd/transcoder
./strum-transcoder --api-url https://vapi.usestrum.app --token mt_live_... --no-gui--hwaccel disabled is worth passing explicitly on a machine whose VAAPI stack is present but broken — auto (the default) happily selects a non-functional /dev/dri device and the encode dies mid-job with a cryptic ffmpeg filter-graph error, not an obvious "no hardware accel" message.
Local Development
apps/api (Fly, Fastify) is deleted entirely — apps/api-edge (Cloudflare Workers) is the only backend. docker-compose.yml at repo root now only provides infra (postgres, redis, minio, minio-init, neon-proxy, srh, glitchtip) — not app processes.
The supported local dev workflow is pnpm dev (alias for pnpm dev:edge:full), which boots the full edge stack — api-edge (via Miniflare/Wrangler, proxied to the local neon-proxy container so it speaks to Postgres the same way it does in prod), tus-edge, worker, transcoder, and a local QStash emulator — see LOCAL_EDGE_DEV.md for the full setup.
git clone https://github.com/your-org/strum-vod.git
cd strum-vod
pnpm install
cp .env.example .env
docker compose up -d # infra only: postgres, redis, minio
pnpm dev # = pnpm dev:edge:fullTo run pieces individually (dashboard/player work, or when the full edge stack isn't needed):
pnpm run build -w @strum-vod/db # must build first
pnpm run dev -w @strum-vod/worker # Worker (Node: AI + analytics)
pnpm run dev:transcoder # Transcoder (Go: ffmpeg ladder)
pnpm run dev -w @strum-vod/dashboard # Dashboard on :42901
pnpm run dev:player # Player app on :42911Requires Node.js >= 20, pnpm >= 9, Go 1.23+ (apps/transcoder), FFmpeg + ffprobe, and yt-dlp (URL imports) on top of Docker.
Stopping
docker compose down # stop infra, keep data
docker compose down -v # stop infra + delete volumesProduction: Split Deployment
In production, each app runs on the platform that suits it best.
| App | Platform | Command |
|---|---|---|
apps/api-edge | Cloudflare Workers | pnpm --filter @strum-vod/api-edge run deploy |
apps/tus-edge | Cloudflare Workers | pnpm --filter @strum-vod/tus-edge run deploy |
apps/trigger-tasks | trigger.dev cloud | see §1–2 above |
apps/transcoder | Fly.io / dedicated server | fly deploy --config fly.transcoder.toml |
apps/dashboard | Cloudflare Workers + Assets | wrangler deploy --config apps/dashboard/wrangler.toml |
apps/player | Cloudflare Pages | pnpm deploy:player (wrangler pages) |
Resumable (TUS) video upload is served by apps/tus-edge, a separate Cloudflare Worker — not part of api-edge.
1. Database & Redis
Use a managed PostgreSQL 16+ instance (Neon, Supabase, AWS RDS) and a managed Redis (Upstash, Redis Cloud):
DATABASE_URL=postgresql://user:password@ep-xyz.region.aws.neon.tech/neondb?sslmode=require
REDIS_URL=redis://default:password@my-redis.upstash.io:6379Neon: use the direct endpoint (drop
-pooler) — seedocs/configuration.md.
2. Storage — Cloudflare R2
Create R2 buckets and enable public access:
wrangler r2 bucket create strum-vod
wrangler r2 bucket create strum-vod-backupsApply CORS rules from the repo (required for browser-direct presigned uploads):
pnpm r2:cors:setR2 does not support per-object
ACL: public-readorPutBucketCorsvia the S3 API. Bucket-level CORS is applied with wrangler (seeinfra/r2/strum-vod-cors.json).
S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
S3_REGION=auto
S3_BUCKET=strum-vod
S3_ACCESS_KEY_ID=<R2 key id>
S3_SECRET_ACCESS_KEY=<R2 secret>
S3_FORCE_PATH_STYLE=false
S3_PUBLIC_BASE_URL=https://pub-<hash>.r2.dev3. Transcoder (Fly.io)
apps/api (Fly, Fastify) is deleted — dispatch/wake is handled by apps/trigger-tasks (trigger.dev) waking machines rows with wakeStrategy='fly_api' directly via the Fly Machines API. See "Worker unification" and "Orchestration migration" in root CLAUDE.md for the current design.
# Authenticate
fly auth login
# Create apps (first time)
fly apps create strum-vod-transcoder --machines
# Set secrets
fly secrets set \
DATABASE_URL="postgresql://..." \
REDIS_URL="redis://..." \
S3_ENDPOINT="https://..." \
S3_ACCESS_KEY_ID="..." \
S3_SECRET_ACCESS_KEY="..." \
S3_PUBLIC_BASE_URL="https://..." \
--app strum-vod-transcoder
# Deploy
fly deploy --config fly.transcoder.tomlRuntime model: Transcoder runs on fly.transcoder.toml — scale-to-zero (min_machines_running=0). Woken by apps/trigger-tasks' transcode-orchestration task via Fly Machines API on job dispatch; stops itself via internal/selfstop once idle (see unified worker-fleet design in root CLAUDE.md).
4. Dashboard (Cloudflare Workers + Assets)
# Build with production env vars
VITE_API_BASE_URL=https://vapi.usestrum.app \
VITE_TUS_SERVER_URL=https://vapi.usestrum.app \
VITE_PLAYER_BASE_URL=https://player.usestrum.app \
pnpm run build -w @strum-vod/dashboard
# Deploy
wrangler deploy --config apps/dashboard/wrangler.toml5. Player App (Cloudflare Pages)
# Create apps/player/.env with:
# VITE_API_BASE_URL=https://vapi.usestrum.app
pnpm run deploy:player # vite build + wrangler pages deploy --project-name=player6. Docs Site (VitePress, Cloudflare Pages)
The documentation site (docs/, built with VitePress) deploys to Cloudflare Pages automatically via CI — no manual step needed.
One-time setup:
# Create the Pages project (first time only)
wrangler pages project create strum-vod-docs --production-branch mainThen set the custom domain (e.g. docs.strum-vod.dev) in the Cloudflare dashboard → Pages → strum-vod-docs → Custom domains.
Required GitHub Actions secrets:
| Secret | Value |
|---|---|
CLOUDFLARE_API_TOKEN | API token with Account → Cloudflare Pages → Edit permission |
CLOUDFLARE_ACCOUNT_ID | Cloudflare account ID (dashboard → right sidebar) |
Trigger: .github/workflows/docs.yml rebuilds and redeploys on every push to main touching docs/** or the root markdown the site imports. Pull requests get a unique preview URL; workflow_dispatch forces a redeploy.
Manual deploy (local):
pnpm run deploy:docs # pnpm docs:build + wrangler pages deployOnce deployed, point the dashboard sidebar's "Documentation" link at it with
VITE_DOCS_URL=https://docs.strum-vod.devat dashboard build time.
Reverse Proxy (nginx / Caddy)
For the all-in-one Docker image or self-hosted VPS deployment:
api.yourdomain.com → api-edge (:3000 local dev)
dashboard.yourdomain.com → Cloudflare Workers + Assets
player.yourdomain.com → Cloudflare PagesCaddy example
api.yourdomain.com {
reverse_proxy localhost:3000
}Scaling the Transcoder
The Go transcoder is stateless. Run multiple instances to process videos in parallel — each picks jobs from the same Redis Stream consumer group (XREADGROUP, with XCLAIM-based recovery of orphaned messages):
# Fly.io
fly scale count 3 --app strum-vod-transcoderEach instance auto-detects its own CPU/RAM (cgroup-aware) and adjusts its FFmpeg concurrency/thread count accordingly. The Node worker (bridge + AI) scales the same way.
One-Click Deploy
For the all-in-one Docker image:
| Platform | How to deploy |
|---|---|
| EasyPanel | Add Docker app → your-org/strum-vod |
| Dokploy | Import from Docker Hub |
| Coolify | One-click from Docker image |
| Portainer | Create stack from compose |
| Railway | Deploy from Docker image |