One command
pnpm dev:edge:fullThat's it. It runs, in order:
pnpm dev:edge:setup- generatesapps/api-edge/.dev.varsandapps/tus-edge/.dev.varsfrom their.dev.vars.exampletemplates (filling in two matching random secrets). Idempotent - never overwrites an existing.dev.vars, so re-running is always safe.pnpm docker:edge-infra- startspostgres,redis,minio(+minio-init, which creates thestrum-vodbucket),neon-proxy, andsrhviadocker-compose.yml. Requires Docker running.pnpm dev:edge:migrate- waits for postgres, then applies the schema (apps/api's own migration runner - idempotentCREATE TABLE IF NOT EXISTS`, safe to re-run).pnpm dev:edge- startsapi-edge,tus-edge,worker,transcoder,dashboard(:42901),player(:42911), a local QStash CLI dev server, andtrigger.dev's dev worker (trigger dev, see below), all concurrently. First step kills anything already bound to these ports (scripts/kill-edge-ports.sh) - including apnpm devyou already have running elsewhere, no warning beyond the printed PID list, so don't re-runpnpm dev/dev:edgeon top of a session you still need.
Requires on PATH: Docker (with docker compose), Node 22+, pnpm, Go 1.23+ (for apps/transcoder), openssl (used by the setup script), ffmpeg/ ffprobe if you actually want a transcode job to complete, not just boot.
trigger.dev is the one real external dependency - trigger dev needs internet + login
Every other service this stack touches has a local stand-in, but trigger.dev does not: api-edge's transcode-orchestration task (apps/api-edge/src/trigger-client.ts) is triggered over REST against the cloud platform (https://api.trigger.dev), and pnpm dev:edge's trigger pane runs the SDK CLI's trigger dev, which is a socket client of that platform - it serves no local HTTP API and opens no local port. The flow is: api-edge POSTs the trigger with the dev key → cloud schedules the run → the connected local trigger dev worker executes the task code on this machine. So:
- Requires internet, and the CLI must be logged into the account owning the project (
npx trigger.dev@latest login- checknpx trigger.dev@latestwhoami`). This machine is already logged in; a fresh clone is not. - There is no self-hosted platform in this stack - do not point
TRIGGER_API_URLathttp://127.0.0.1:3030(that's the self-hosted Docker platform's port, which we don't run). Leave it on the cloud default (https://api.trigger.dev). TRIGGER_SECRET_KEYinapps/api-edge/.dev.varsmust be the dev key fromapps/trigger-tasks/.env(tr_dev_...).pnpm dev:edge:setupcopies it over automatically; without it api-edge throws "TRIGGER_SECRET_KEY is not set" and local transcode 500s.
The status command (pnpm status) reports the trigger pane as RUNNING/STOPPED by process presence (pgrep), since it has no health port.
One command to test it, end to end
pnpm test:e2e:edgescripts/e2e-local-edge.sh does the full setup above itself (idempotent - safe even if dev:edge:full is already running elsewhere), then drives, through api-edge's actual HTTP routes and the real Go transcoder running a real ffmpeg encode (a tiny ~3s testsrc/sine clip synthesized on the fly, not a fixture file):
- Presigned-upload asset - signup → asset create → PUT to MinIO →
upload-complete→process→ poll untilready→ verify a realmaster.m3u8landed in MinIO → the authenticatedGET /v1/assets/:id/playbackand the publicGET /v1/playback/:playbackIdendpoints both return a realmanifestUrl, and that URL is fetched and confirmed to actually be a valid manifest (not just a raw MinIO file check) → delete. - TUS-upload asset - same pipeline, but the source arrives through
tus-edge's real TUS protocol implementation (POST create + PATCH upload) instead of a presigned PUT. See the caveat below - bridging the uploaded bytes into MinIO relies on Miniflare's undocumented local storage layout. - Deepgram transcription - only runs when a real
DEEPGRAM_API_KEYis present in the root.env(gitignored; this spends real API credit). Configures it through the real admin API (PATCH /v1/admin/settings/ai+POST /v1/admin/settings/ai/test, the same endpoints the dashboard's Settings → AI page uses - not an env-var shortcut) and polls the presigned-upload asset'sai_jobuntil itstranscriptionStatusreaches a terminal state. Skipped by default - the script stays fully credential-free unless you opt in.
Tears down the stack it started on exit (KEEP_STACK=1 to leave it running, KEEP_ASSET=1 to skip the final deletes). This is the closest thing to a full e2e suite for api-edge/tus-edge - they otherwise have zero automated test coverage (only typecheck); apps/worker has a real Vitest e2e suite of its own (pnpm --filter @strum-vod/worker run test:e2e) that also exercises a real transcoder build with fake AI, but doesn't touch the Workers apps at all. Requires sqlite3 on PATH in addition to the tools listed above (used by the TUS bridge, see below).
TUS uploads auto-bridge into MinIO (apps/tus-edge/src/local-bridge.ts)
The VIDEO_BUCKET binding is a genuine native Workers R2Bucket type - not an S3-compatible client - so, unlike every other local-edge storage path in this stack, it can't simply be pointed at MinIO via env vars. wrangler dev's local R2 simulation (Miniflare) is its own isolated store, completely separate from the MinIO container apps/api-edge's S3 client reads from - so without a bridge, a file uploaded via tus-edge would be invisible to api-edge's upload-complete (HeadObjectCommand) check. This is handled automatically now: index.ts's request handler checks, after every successful PATCH, whether the upload just completed (the final object appearing under its real key in VIDEO_BUCKET - reliable regardless of how many resumed PATCHes it took) and, if so, pushes it into MinIO via aws4fetch in the background (ctx.waitUntil, never blocks or can fail the actual TUS response). Gated entirely on S3_ENDPOINT being set in apps/tus-edge/.dev.vars - unset in production (tus-edge and api-edge already point at the same real R2 bucket there, nothing to bridge), so the whole module is a true no-op outside local dev. pnpm dev:edge:setup backfills these vars into an existing .dev.vars automatically; a fresh .dev.vars gets them from .dev.vars.example.
scripts/bridge-tus-upload.sh <assetId> <bearerToken> (pnpm bridge:tus-upload -- <assetId> <bearerToken>) does the same push manually as a fallback - it reads Miniflare's local R2 state directly (a per-binding SQLite DB at apps/tus-edge/.wrangler/state/v3/r2/miniflare-R2BucketObject/{hash}.sqlite, table _mf_objects, mapping keys to flat blob files under apps/tus-edge/.wrangler/state/v3/r2/strum-vod/blobs/) - verified empirically, not a documented Miniflare API, so this (and the auto-bridge above, which relies on the same VIDEO_BUCKET.get() R2-binding-level contract, not the SQLite layout specifically) is the part of the local-edge stack most likely to break on a wrangler/Miniflare upgrade.
⚠️ .dev.vars can silently hold real credentials - check before trusting a run
apps/api-edge/.dev.vars and apps/tus-edge/.dev.vars are gitignored, hand-editable files - nothing stops someone from pointing them at real Neon/R2/Upstash/QStash for a different debugging session and then leaving them that way. pnpm dev:edge:setup only writes them if they don't already exist - it will never overwrite a .dev.vars that's quietly pointing at production-adjacent infra back to local values. This happened for real in this repo's history: a .dev.vars restored from a backup of real credentials sat in place through an entire later session, and every signup/asset/upload run during that session silently hit the real Neon DB and real R2 bucket instead of MinIO - QStash publishes went to the real cloud queue too (though pointed at 127.0.0.1, so they just failed to deliver, no real transcode ran).
Before trusting a local run, eyeball apps/api-edge/.dev.vars: DATABASE_URL should say db.localtest.me, not *.neon.tech; S3_ENDPOINT should say 127.0.0.1:42943, not *.r2.cloudflarestorage.com; QSTASH_URL should say 127.0.0.1:42931, not *.upstash.io. If any of those point at a real host, delete both .dev.vars files and re-run pnpm dev:edge:setup to regenerate clean local-only ones.
Prerequisite gotcha: Docker Desktop must actually be up
pnpm docker:edge-infra fails immediately with Cannot connect to the Docker daemon if Docker isn't running. On Linux with Docker Desktop:
systemctl --user start docker-desktopGive it 10-20s to finish booting its VM before retrying.
What stands in for what
| Real service | Local stand-in | Port |
|---|---|---|
| Neon Postgres | postgres (docker-compose) | 42941 |
- HTTP driver Neon needs (@neondatabase/serverless, used by api-edge/tus-edge - Workers have no TCP sockets) | neon-proxy (docker-compose, timowilhelm/local-neon-http-proxy) in front of postgres | 42945 → proxies to postgres:5432 |
| Cloudflare R2 | minio (docker-compose, S3-compatible) | API 42943, console 42944 |
| Cloudflare Cache API & Rate Limiting | @strum-vod/edge-cache in-memory fallback (per-isolate) | n/a (built-in) |
| Trigger.dev (task orchestration) | cloud - trigger dev (part of pnpm dev:edge) runs tasks locally but schedules against https://api.trigger.dev; needs internet + CLI login, no local stand-in | n/a (no local port) |
| Resend (email) | console mode - OTP/invite emails print to the api-edge terminal instead of sending | n/a |
Ports
Every dev port in this stack is pinned to a deliberately obscure 5-digit value (the 4287x-4294x range) instead of each tool's low, well-known default - those defaults (8080, 8787, 9229, 1337, 5432, 6379, 9000, ...) kept colliding with unrelated projects/tools also running on this machine.
| Process | Port |
|---|---|
| api-edge | 42871 |
| tus-edge | 42881 |
| transcoder | 42922 |
| trigger.dev dev worker | no local port (socket client of the cloud platform) |
| postgres | 42941 |
| minio API / console | 42943 / 42944 |
| neon-proxy | 42945 |
api-edge and tus-edge both default to wrangler's own 8787 - each wrangler.toml pins an explicit [dev] port (42871 / 42881) to avoid the collision. apps/transcoder's TRANSCODER_HTTP_PORT also defaults to the same 8080 as apps/worker's WORKER_HTTP_PORT - scripts/local-edge.env (see below) pins both explicitly (42921 / 42922) for the same reason. wrangler dev also defaults every instance's Node inspector to port 9229 - api-edge and tus-edge running side by side under pnpm dev:edge collide there too (second one to start crashes outright on EADDRINUSE), so each wrangler.toml's [dev] block also pins a distinct inspector_port (api-edge 42872, tus-edge 42882).
prewarmFleet() really does call real *.fly.dev URLs unless you stop it
POST /v1/assets/:id/process (and the demo-seed/import-sample routes) call prewarmFleet() (apps/api-edge/src/services/fly-machine.ts), which wakes the Fly worker/transcoder machines in production. It no-ops when FLY_WORKER_APP/FLY_TRANSCODER_APP are falsy - but wrangler.toml's [vars] block sets both to real production app names ("strum-vod-worker"/"strum-vod-transcoder") as defaults, and .dev.vars only overrides keys it actually lists. Omitting them from .dev.vars is not the same as unsetting them - the Worker still gets the wrangler.toml value. apps/api-edge/.dev.vars.example explicitly sets FLY_WORKER_APP=/FLY_TRANSCODER_APP= (blank, not absent) for exactly this reason. Verified the hard way: without that line, a process call from a fully-local run fired a real HTTP request at https://strum-vod-worker.fly.dev/wake and https://strum-vod-transcoder.fly.dev/wake. The same blank-not-absent override is needed for S3_PUBLIC_BASE_URL/PLAYER_BASE_URL/DASHBOARD_URL (also defaulted to production hostnames in wrangler.toml) - .dev.vars.example points S3_PUBLIC_BASE_URL at local MinIO so generated playback URLs (master.m3u8, etc.) actually resolve offline instead of pointing at media.usestrum.app.
Why apps/worker/apps/transcoder need a separate env file
apps/api's and apps/worker's env.ts both load the monorepo-root .env via dotenv before validating. On a fresh clone that file is either missing or holds .env.example placeholders (real R2/Neon values) - not the local-stack addresses above - so a bare pnpm --filter @strum-vod/worker run dev would fail Zod validation or, worse for someone with a real .env already filled in (e.g. mid-migration testing against production-adjacent infra), would silently start talking to real Neon/R2/Upstash instead of the local stack.
scripts/local-edge.env fixes this without ever touching the root .env: it's a plain export FOO=bar file sourced by two wrapper scripts (scripts/dev-edge-worker.sh, scripts/dev-edge-transcoder.sh, scripts/dev-edge-migrate.sh) before the Node/Go process starts. dotenv.config() never overwrites a key already present in process.env, so these shell-exported values win over anything .env would have loaded - the root .env is never read for these values, never mutated, never even opened by this path (.env's own contents don't matter for pnpm dev:edge at all). pnpm dev:edge (and therefore dev:edge:full) calls these wrapper scripts, not the raw pnpm --filter ... run dev commands.
apps/transcoder (Go) has no dotenv equivalent at all - it only reads os.Getenv, so it depends entirely on the invoking shell's exported environment. The wrapper script is the only way it gets pointed at the local stack.
If you need to run apps/worker/apps/transcoder outside pnpm dev:edge (e.g. one at a time, for focused debugging), source the file yourself:
. scripts/local-edge.env
pnpm --filter @strum-vod/worker run dev
# or
cd apps/transcoder && go run ./cmd/transcoderneon-proxy needs a bootstrap table it doesn't ship with
local-neon-http-proxy (the neon-proxy service) uses the target Postgres itself as a mock control-plane: on every connection it looks up the connecting endpoint/role in a neon_control_plane.endpoints table - which plain postgres:16-alpine obviously doesn't have. Without it, every query through api-edge/tus-edge fails, surfacing as a bare {"error":"Internal server error"} from every route (api-edge's errorResponse() never logs the underlying cause - see apps/api-edge/src/errors.ts - so this looked like a generic 500 with no clue until the proxy's own container logs (docker logs strum-vod-neon-proxy-1) were checked directly). scripts/dev-edge-migrate.sh bootstraps this table (idempotent, safe to re-run) before running the app schema migration. The exact column shapes matter and are not obvious from the proxy's error messages alone - each was found by iterating against the real container logs:
allowed_ipsmust be plaintext, nottext[]- the proxy deserializes it into a RustStringand splits on commas itself; a real Postgres array (oid_text) fails to deserialize into thatStringat all.allowed_ipsmust be non-NULL(NULLfails the same deserialization).allowed_ipsmust be a syntactically valid CIDR/IP pattern even when "allow everything" is the intent - an empty string panics inside the proxy's own IP-matcher ("invalid IP address syntax").'0.0.0.0/0'is correct.
QStash local dev caps httpTimeout at 3600s
enqueuePlatformTranscode (apps/api-edge/src/services/process-asset.ts) publishes transcode jobs with a 2-hour QStash timeout in production (a real high-resolution encode can legitimately take that long). @upstash/qstash-cli dev rejects any publish above 3600s outright ("quota httpTimeout exceeded, current limit: 3600 sec given value: 7200 sec"), which surfaced as the same generic swallowed 500 as the neon-proxy issue above. QSTASH_TRANSCODE_TIMEOUT (optional env var, only read by enqueuePlatformTranscode) overrides the timeout; .dev.vars.example sets it to 3000s locally and leaves it unset in production, where the real 2h ceiling still applies.
Batch S3 delete needs a SHA-256 checksum against MinIO
apps/api-edge/src/s3-raw.ts's deleteObjectsRaw() (the hard-delete path, batch-deletes everything under sources/{id}/ and playback/{id}/) was sending no integrity header at all. Real R2 never enforced this, so it went undetected through every prior test of this file - MinIO does enforce it per the S3 spec and 400s with "Missing or invalid required header for this request: Content-Md5 or Amz-Content-Checksum". Content-MD5 isn't an option here - Workers' crypto.subtle has no MD5, only the SHA family - so the fix sends x-amz-checksum-sha256 instead (the error message's own "or" already allows it; S3's DeleteObjects API accepts CRC32/CRC32C/SHA1/SHA256 as Content-MD5 alternatives).
Local ffmpeg defaults to CPU-only (no VAAPI)
apps/transcoder's hardware-adaptive config defaults FFMPEG_HWACCEL to "auto", which probes for a usable /dev/dri VAAPI device and happily selects it even when the box's VAAPI stack doesn't actually work in practice (no device-cgroup access in a sandboxed shell, missing/mismatched driver, etc.) - the real-world failure mode is ffmpeg dying mid-encode with a bare Cannot allocate memory and no clearer signal. scripts/local-edge.env sets FFMPEG_HWACCEL=disabled so the local-edge transcoder always uses plain CPU libx264, matching what the repo's own CI-facing tests already do for the same determinism reason (apps/worker's node-agent E2E test passes --hwaccel disabled). Comment that line out to test the hardware-accelerated path on a machine where VAAPI genuinely works.
Worker's tsx watch can hard-crash under inotify pressure
apps/worker's normal pnpm dev uses tsx watch, which registers an fs watcher per file for hot-reload. On a dev machine already close to its fs.inotify.max_user_watches budget (many IDEs, other dev servers, other Claude Code sessions all compete for the same system-wide budget), tsx watch doesn't degrade gracefully - it throws ENOSPC and the whole process exits. EDGE_NO_WATCH=1 (set automatically by scripts/e2e-local-edge.sh, or set it yourself before pnpm dev:edge for a one-shot/CI-style run) makes scripts/dev-edge-worker.sh run plain tsx src/index.ts instead - no hot-reload, but immune to the watcher limit. Interactive pnpm dev:edge still gets hot-reload by default. wrangler dev hits the exact same ENOSPC watching wrangler.toml/source files but tolerates it (noisy warning, keeps running) - only tsx watch dies outright.
The MinIO path-style addressing caveat
R2 accepts both virtual-hosted-style (https://bucket.host/key) and path-style (https://host/bucket/key) S3 requests. MinIO's default HTTP listener only serves path-style. S3_FORCE_PATH_STYLE=true in both apps/api-edge/.dev.vars.example and scripts/local-edge.env is required for MinIO specifically - this is exactly why wrangler.toml's production [vars] default of S3_FORCE_PATH_STYLE = "false" was never wrong for R2, but is wrong for local MinIO. apps/api-edge/src/s3-raw.ts's bucketBaseUrl() derives the addressing style (and scheme) from S3_ENDPOINT/S3_FORCE_PATH_STYLE for this reason - this bug was invisible until MinIO was actually exercised for the first time, since R2 happens to tolerate the virtual-hosted-style URL the code used to hardcode.
If you ever see TypeError: Invalid URL string. from aws4fetch's AwsV4Signer while working on api-edge's S3 code locally: first suspect a stale wrangler dev process that hasn't picked up a .dev.vars edit. Wrangler's hot-reload does not reliably reload .dev.vars - kill the dev server (port 42871) and start it fresh. Confirm the fix took by checking the "Your Worker has access to the following bindings" table wrangler dev prints at boot: an overridden var shows as "(hidden)", not the literal wrangler.toml default.
tus-edge's R2/Durable Object bindings were silently dead in local dev
wrangler dev (no --remote) automatically simulates apps/tus-edge's VIDEO_BUCKET R2 binding and VideoUploadHandler Durable Object locally (SQLite-backed) - no real Cloudflare account needed to exercise the TUS protocol. But wrangler.toml's r2_buckets/durable_objects.bindings declarations used to sit directly after the [dev] table with no header in between - TOML has no block scoping, so both silently became nested inside [dev] instead of top-level config. wrangler dev even warned about it ("Unexpected fields found in dev field: r2_buckets, durable_objects"), easy to miss among normal boot output - the real effect was that env.VIDEO_UPLOAD_HANDLER was undefined at runtime, and every single TUS request 500'd with Cannot read properties of undefined (reading 'get'). Fixed by moving [dev] to the end of the file (it must be last - anything declared without its own header after a [table] belongs to that table, TOML has no other way to close one).
Even with that fixed, wrangler dev's simulated VIDEO_BUCKET is its own isolated local store, not the same MinIO bucket apps/api-edge's S3-endpoint client reads from - a file uploaded via TUS is invisible to api-edge's S3 calls unless bridged out (see the TUS-bridge section above, which scripts/e2e-local-edge.sh does automatically).
Getting OTP codes without a real email provider
RESEND_API_KEY=console in apps/api-edge/.dev.vars.example makes services/email.ts print the OTP/invite email straight to the api-edge terminal instead of calling Resend. Watch the api-edge pane of pnpm dev:edge's concurrently output for the code.
AI processing is off by default - opt in with a real Deepgram key
scripts/local-edge.env sets AI_ENABLED="${AI_ENABLED:-false}" - a default, not a hard override, so a caller that exports AI_ENABLED=true before sourcing it wins. No local Whisper/LLM provider is wired into this offline stack by default, so ai-dispatch QStash jobs no-op cleanly instead of failing on a missing provider. The rest of the pipeline (upload → transcode → HLS ladder → playback) works fully offline regardless.
scripts/e2e-local-edge.sh uses exactly this override, but not as an env-var shortcut for the provider itself: only AI_ENABLED=true (the worker's blanket kill switch) comes from the environment. The actual provider selection goes through the real production configuration surface - PATCH /v1/admin/settings/ai, the same endpoint the dashboard's Settings → AI page calls, followed by POST /v1/admin/settings/ai/test (the "Test connection" button's endpoint) - because apps/worker's provider-factory.ts resolves config as dbConfig ?? env (admin-set DB config wins over env vars), and testing only the env-var fallback would never actually exercise the path real users configure AI through. Both admin routes require requireSuperadmin() (apps/api-edge/src/middleware/auth.ts), which re-checks users.is_superadmin in Postgres on every request rather than trusting a JWT claim - so the script directly flips that flag for its freshly signed-up test user via docker exec strum-vod-postgres-1 psql ... (the local-only equivalent of an ops team promoting an account; the normal signup flow only makes the very first org's owner in the whole DB a superadmin, which is long since claimed on any local stack that's been run before - for ad-hoc/manual use instead of a script, strum dev promote <your-email> does the same thing over HTTP via POST /v1/admin/bootstrap-superadmin, gated by the SUPERADMIN_BOOTSTRAP_KEY (docs/configuration.md's Security section).
This only fires when a real DEEPGRAM_API_KEY=... line is found in the root .env (gitignored - this is the one value this local-edge stack intentionally reads from there, an explicit exception to "the root .env doesn't matter for pnpm dev:edge" above, since Deepgram needs a real paid API key that has nowhere sensible to live in a git-tracked file). This spends real Deepgram API credit - omit the key (or comment it back out) to keep the whole run credential-free.
To exercise a different provider (local/self-hosted Whisper, an LLM for chapters), set AI_ENABLED=true yourself and point TRANSCRIPTION_PROVIDER/WHISPER_API_URL/LLM_PROVIDER etc. at it - see the root .env.example for the full list of AI-related vars.
now()-based duration/lease math looks wrong locally - it's the container's TZ, not a bug
docker-compose.yml's postgres service hardcodes TZ: America/Sao_Paulo. Every raw-SQL now() call that writes into a timestamp (no time zone) column - lease_expires_at, duration_ms via EXTRACT(EPOCH FROM (now() - started_at)), etc., see apps/api-edge/src/routes/worker-agent.ts/node-agent.ts - gets the session's local wall-clock time, while Drizzle-inserted values (new Date() from Workers/Node) land as UTC digits. Locally that's a fixed 3h skew (confirm with docker exec strum-vod-postgres-1 psql ... -c "SHOW timezone;"); a duration_ms that comes out negative or a lease that looks already-expired right after claiming is this, not a broker-protocol bug. Neon/Fly Postgres in production defaults to UTC, so this doesn't reproduce there - don't chase it as an application bug, and don't "fix" it by special-casing around this container's TZ.
pnpm dev:edge's transcoder needs a machine token (worker-unification)
cmd/transcoder's content was replaced during the worker-unification migration (see root CLAUDE.md's "Worker unification" section) with cmd/node-agent's broker-pull-only logic, which needs --api-url/--token (STRUM_API_URL/STRUM_NODE_TOKEN) - local-edge.env's DATABASE_URL/S3_*/QSTASH_* vars (shared with apps/worker) don't cover this.
scripts/dev-edge-transcoder.sh now bootstraps this itself via scripts/bootstrap-local-machine-token.ts: it mints a mt_live_... machine_tokens row directly against the local DB (same HMAC-over-JWT_SECRET scheme as apps/api-edge/src/services/cloud.ts::generateMachineToken, reimplemented standalone so the script has no dependency on the api-edge Worker package) - no admin login/JWT needed. The raw token is cached at scripts/.local-machine-token (gitignored) and reused across runs; if the local DB gets reset (docker compose down -v, a fresh postgres volume), or the row is revoked, the cached token no longer resolves and the script mints a fresh one automatically. The local token is minted without an expires_at lease (dev DB, throwaway by nature). STRUM_API_URL is set from local-edge.env's API_PUBLIC_URL (the locally-running api-edge's own port).
To mint one by hand instead (e.g. to inspect it via GET /v1/admin/machine-tokens, which needs a real superadmin JWT):
# after pnpm dev:edge:setup / docker:edge-infra / dev:edge:migrate, and api-edge running:
curl -X POST http://localhost:42871/v1/admin/machine-tokens \
-H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' -d '{"name":"local-dev"}'
cd apps/transcoder && go run ./cmd/transcoder --api-url http://localhost:42871 --token mt_live_... --hwaccel disabledTroubleshooting
ENOSPC: System limit for number of file watchers reached (from tsx watch, apps/worker's dev script) - this is a Linux inotify limit, not a bug in the edge scripts. Usually only bites on a machine already running many other watchers (multiple IDEs, other dev servers, other Claude Code sessions). Fix system-wide (requires sudo, do this yourself - Claude Code won't modify system settings):
sudo sysctl fs.inotify.max_user_watches=524288If it's still saturated after that, something else on the machine is holding most of the budget - check with:
cat /proc/sys/fs/inotify/max_user_watches # the limit
# rough usage estimate (root not required, but only counts readable /proc/*/fdinfo):
grep -rc '^inotify wd:' /proc/[0-9]*/fdinfo/* `2>/dev/null` | awk -F: '{s+=$2} END{print s}'EADDRINUSE on 42921/42922/42871/42881 - a previous run's process didn't exit cleanly (common after an ENOSPC crash above, which can leave the Node process's HTTP listener bound even though the parent pnpm/tsx watch wrapper reports Exit 1). Find and kill it:
lsof -i :42921 # swap port as needed
kill -9 `{pid}`db:migrate fails with DATABASE_URL is required - the migration runner (packages/db/src/migrate.ts, run via pnpm db:migrate) only needs DATABASE_URL/DB_POOL_SIZE - no Zod schema, no QStash config. If it's failing, the local-stack Postgres URL just isn't in the environment you ran it from; use pnpm dev:edge:migrate, which sources scripts/local-edge.env first.
Verified working (2026-08-15)
pnpm test:e2e:edge passes clean end to end, both credential-free and with a real DEEPGRAM_API_KEY set:
- Presigned-upload asset: signup → asset create → PUT to MinIO →
process→ real Go transcoder → real ffmpeg HLS ladder encode →ready→ realmaster.m3u8verified directly in MinIO → the authenticated and public playback endpoints both return a workingmanifestUrlthat actually resolves → delete. - TUS-upload asset: same pipeline end to end, source arriving through
tus-edge's real TUS protocol (POST create + PATCH), bridged out of Miniflare's local R2 state into MinIO →ready→ delete. - Deepgram transcription: test user promoted to superadmin locally, configured through the real
PATCH /v1/admin/settings/ai+POST /v1/admin/settings/ai/testadmin endpoints (not an env-var shortcut), then a real (billed) API call against the extracted audio -ai_job.transcriptionStatusreachescompleted.
No Fly involvement (confirmed no outbound wake calls), no real Neon/R2/ Upstash/QStash credentials anywhere in the run - Deepgram is the one deliberate, opt-in exception.