Skip to content

Deployment ​

Staging — a fully isolated staging environment (own Workers, DB, buckets, Redis, trigger.dev project) driven by the staging branch. See Staging Environment. api-edge and tus-edge deploy 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):

bash
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 deploy

trigger.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:

bash
npx trigger.dev@latest deploy --dry-run

2. Set TRIGGER_SECRET_KEY on the Worker ​

Get the prod secret key (tr_prod_...) from the trigger.dev dashboard's API Keys page, then:

bash
cd apps/api-edge
printf '%s' 'tr_prod_...' | npx wrangler secret put TRIGGER_SECRET_KEY

3. Deploy apps/api-edge ​

bash
cd apps/api-edge
npx wrangler deploy

The 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:

TaskSchedulePurpose
analytics-daily0 2 * * *Daily analytics rollup
analytics-hourly*/5 * * * *Hourly analytics flush
analytics-cleanup0 3 * * *Analytics retention cleanup
db-backup0 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:

bash
DATABASE_URL='postgresql://...' pnpm --filter @strum-vod/db run migrate

Every 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:

bash
# 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.

bash
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:full

To run pieces individually (dashboard/player work, or when the full edge stack isn't needed):

bash
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 :42911

Requires Node.js >= 20, pnpm >= 9, Go 1.23+ (apps/transcoder), FFmpeg + ffprobe, and yt-dlp (URL imports) on top of Docker.

Stopping ​

bash
docker compose down           # stop infra, keep data
docker compose down -v        # stop infra + delete volumes

Production: Split Deployment ​

In production, each app runs on the platform that suits it best.

AppPlatformCommand
apps/api-edgeCloudflare Workerspnpm --filter @strum-vod/api-edge run deploy
apps/tus-edgeCloudflare Workerspnpm --filter @strum-vod/tus-edge run deploy
apps/trigger-taskstrigger.dev cloudsee §1–2 above
apps/transcoderFly.io / dedicated serverfly deploy --config fly.transcoder.toml
apps/dashboardCloudflare Workers + Assetswrangler deploy --config apps/dashboard/wrangler.toml
apps/playerCloudflare Pagespnpm 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):

env
DATABASE_URL=postgresql://user:password@ep-xyz.region.aws.neon.tech/neondb?sslmode=require
REDIS_URL=redis://default:password@my-redis.upstash.io:6379

Neon: use the direct endpoint (drop -pooler) — see docs/configuration.md.

2. Storage — Cloudflare R2 ​

Create R2 buckets and enable public access:

bash
wrangler r2 bucket create strum-vod
wrangler r2 bucket create strum-vod-backups

Apply CORS rules from the repo (required for browser-direct presigned uploads):

bash
pnpm r2:cors:set

R2 does not support per-object ACL: public-read or PutBucketCors via the S3 API. Bucket-level CORS is applied with wrangler (see infra/r2/strum-vod-cors.json).

env
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.dev

3. 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.

bash
# 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.toml

Runtime 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) ​

bash
# 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.toml

5. Player App (Cloudflare Pages) ​

bash
# Create apps/player/.env with:
# VITE_API_BASE_URL=https://vapi.usestrum.app

pnpm run deploy:player    # vite build + wrangler pages deploy --project-name=player

6. 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:

bash
# Create the Pages project (first time only)
wrangler pages project create strum-vod-docs --production-branch main

Then set the custom domain (e.g. docs.strum-vod.dev) in the Cloudflare dashboard → Pages → strum-vod-docs → Custom domains.

Required GitHub Actions secrets:

SecretValue
CLOUDFLARE_API_TOKENAPI token with Account → Cloudflare Pages → Edit permission
CLOUDFLARE_ACCOUNT_IDCloudflare 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):

bash
pnpm run deploy:docs      # pnpm docs:build + wrangler pages deploy

Once deployed, point the dashboard sidebar's "Documentation" link at it with VITE_DOCS_URL=https://docs.strum-vod.dev at 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 Pages

Caddy example ​

caddyfile
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):

bash
# Fly.io
fly scale count 3 --app strum-vod-transcoder

Each 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:

PlatformHow to deploy
EasyPanelAdd Docker app → your-org/strum-vod
DokployImport from Docker Hub
CoolifyOne-click from Docker image
PortainerCreate stack from compose
RailwayDeploy from Docker image

STRUM Proprietary License — © 2026 Strum. All rights reserved.