Production policy

Decision date: 2026-07-16. Run it on the company's self-hosted cluster, or buy it from Cloudflare. The common architectural rule: PostgreSQL owns authorization and lifecycle state; Cloudflare or R2 owns media bytes; expiring capabilities are resolved at the edge and are never synchronized through Zero.

Executive Recommendation

PartOne-line recommendationProduction homeConfidence
A — durable jobsAdopt pg-boss, atomically projecting outbox deliveries into execution jobs in PostgreSQL; use Graphile Worker as the fallback if the Bun spike fails.PostgreSQL and workers self-hostedHigh after one runtime spike
B — live and VODKeep Cloudflare Stream for recorded broadcast, course video, and message video; use self-hosted LiveKit for interactive rooms and RTMPS composite egress into Stream for large audiences.Cloudflare + self-hostedStream: decided; interactive layer: medium-high
C — filesUse private R2 as canonical object storage, Cloudflare Images/Workers/CDN as the delivery and transformation plane, and a thin TypeScript control plane plus self-hosted processing workers.Cloudflare + self-hostedHigh after two narrow spikes

Part A — Durable Jobs

A1. Decision

Recommendation: pg-boss on the existing PostgreSQL cluster. Use PostgreSQL as both the transaction boundary and the durable execution queue. A domain transaction writes the mutation and canonical outbox event. The dispatcher then inserts the pg-boss jobs for that event and marks their delivery rows dispatched in one PostgreSQL transaction; a direct domain-transaction enqueue is also valid for single-purpose commands that have no fan-out or audit value. The worker validates the payload with the same kind of Zod contract TRW used in defineTask, performs an idempotent effect, and records the effect receipt before acknowledging the job.

pg-boss uses PostgreSQL SKIP LOCKED, supports retries, priorities, cron, delayed jobs, queue policies, job dependencies, and dead-letter behavior. Its current package also includes ORM transaction adapters and an optional dashboard and proxy. The project currently documents Node 22.12 or newer, so Bun worker compatibility is a required spike rather than an assumption.

A2. What the Queue Must Do

The serf successor needs:

These bounded jobs run as short, idempotent handlers over explicit PostgreSQL state machines.

A3. The Transactional Boundary

Same-database enqueue: outbox plus pg-boss

The preferred two-transaction sequence is:

  1. the domain transaction updates domain rows and inserts one immutable outbox event;
  2. a dispatcher claims that event;
  3. in one second PostgreSQL transaction, it inserts one stable-ID pg-boss job per handler and records the corresponding delivery rows as dispatched;
  4. a retry repeats that transaction without losing or multiplying a logical delivery.

pg-boss documents transaction adapters for Kysely, Prisma, and other common clients; its db option lets the enqueue participate in the caller's transaction. For a one-off command such as "expire this reservation at T," the domain transaction may insert the pg-boss job directly through the same adapter.

The rows have different responsibilities:

Derive a stable pg-boss UUID from (outbox_event_id, handler_name) because pg-boss custom job IDs are UUIDs. The job payload should normally be { eventId, taskVersion }, not a second copy of a large mutable domain object. The worker reloads the authoritative state and refuses stale or unauthorized work.

External effect idempotency

The outbox does not make an external side effect exactly once. A worker can send an email, publish a push notification, or call Stream, then crash before its job acknowledgement commits. Every effect still needs a stable provider idempotency key or an application receipt table.

Same-database enqueue: outbox plus Graphile Worker

Graphile Worker exposes graphile_worker.add_job(...) as a PostgreSQL function. Calling it inside the domain transaction gives the same atomicity without an application-level relay. It supports delayed execution, priorities, attempts, flags, named queues, and job keys. Its job-key modes are useful for debounce and replacement, but completed jobs are deleted and the key is not a permanent execution receipt. Under high contention there is also a documented race for a particular job key that callers must detect and retry.

A4. pg-boss Operating Requirements and Graphile Fallback

pg-boss strengths

pg-boss risks

For correctness-sensitive per-entity serialization, prefer key_strict_fifo over a best-effort group-concurrency setting. Mitigations:

Graphile Worker fallback

Graphile Worker is the documented fallback. It is mature, MIT-licensed, and PostgreSQL-native, using LISTEN/NOTIFY for wakeups and SKIP LOCKED for safe concurrent claims. It provides SQL enqueue and cron backfill after downtime. Named queues serialize jobs such as one reconciliation per stream and require bounded cardinality. Its OSS crash-recovery contract permits a crashed worker's active jobs to remain locked for up to four hours, which the fallback harness must account for.

A5. Operating Shape

Part A spike

1. Run pg-boss producers and workers under the selected Bun version. 2. Prove enqueue and delivery-state changes roll back together in a Kysely/PostgreSQL transaction. 3. Kill workers during execution and verify lease recovery and duplicate-effect protection. 4. Exercise delayed work, cron after downtime, per-queue concurrency, and graceful shutdown. 5. Measure 10× the estimated first-year peak while observing PG connections, WAL, and vacuum. If the Bun harness fails its runtime gate, run the same harness against Graphile Worker.

Part B — Live Streams and Video

B1. Decision

Recommendation: Cloudflare Stream remains the video backbone. Use Stream Live's production RTMPS/SRT-to-HLS/DASH path for one-to-many broadcasts and automatic recording, Stream VOD for course lessons and message video attachments, and a LiveKit SFU self-hosted for interactive rooms. When an interactive room becomes a broadcast, a self-hosted LiveKit Egress deployment composites the room or stage and sends RTMPS to a Cloudflare Stream Live Input. The audience watches Stream; only speakers and active participants remain in LiveKit.

B2. Product Boundary Map

Product laneOwnsDoes not ownTypical audience
Cloudflare Stream VODUpload, encoding, ABR renditions, HLS/DASH delivery, signed playback, course and chat videoRoom signaling, participant state, sub-second conversationAny asynchronous viewer
Cloudflare Stream Live, recorded laneRTMPS/SRT ingest, one-to-many HLS/DASH, automatic recording to VOD, live lifecycleMulti-party room semantics, stage moderation, data channelsLarge broadcast audience
Cloudflare Stream WebRTC beta lanePaired WHIP ingest and WHEP sub-second playbackRecording, HLS/DASH crossover, analytics, viewer count, restreamingSpike only, not the recorded backbone
LiveKit self-hostedWebRTC rooms, speakers, stage, calls, tracks, data, sub-500 ms interactionEconomical mass fan-out and the canonical VOD librarySmall/medium interactive room
LiveKit Egress self-hostedComposite or track output from a room; RTMPS/SRT handoffCDN fan-outBridge from interactive stage to Stream
Cloudflare Realtime / RealtimeKitCloudflare-hosted WebRTC building blocks or higher-level roomsProven parity with the chosen LiveKit operating modelCandidate replacement requiring a spike

B3. Important 2026 Correction: WHIP is a Separate Beta Lane

WHIP / RTMPS are not interchangeable

As of Cloudflare's April 2026 Stream WebRTC beta documentation: WHIP ingest must be paired with WHEP playback; WHIP cannot feed HLS/DASH playback; RTMPS/SRT cannot feed WHEP playback; the WebRTC beta does not support recording; it also omits simulcast/restreaming, analytics, and viewer counts.

This distinction should appear in product and infrastructure schemas so a future beta upgrade does not silently change recording or entitlement guarantees.

B4. Interactive-Room Handoff

LiveKit remains responsible for rooms where participants need to hear and respond to each other in under roughly 500 ms: calls, office hours, stage speakers, classrooms, and green rooms. For a large event:

  1. hosts and invited speakers join a LiveKit room;
  2. the application selects a composite layout;
  3. LiveKit RoomComposite Egress renders that layout;
  4. Egress publishes RTMPS to the Stream Live Input;
  5. Stream provides ABR delivery to the large view-only audience;
  6. chat, reactions, entitlement, and viewer presence remain application features;
  7. the resulting Stream recording becomes the replay asset.

LiveKit Egress officially supports RTMP/RTMPS and SRT stream outputs. Budget self-hosted infrastructure CPU for the headless-browser composite and encoder; it is not free SFU forwarding. LiveKit's self-hosting guidance starts Egress around four CPU cores and 4 GB per instance and defaults to one room-composite job per instance, so scale and alert it independently.

B5. Signed Playback and Entitlement

All authorization lives in the application database. Stream tokens are short-lived delivery capabilities, not the entitlement source of truth. The playback endpoint should:

  1. authenticate the Better Auth session;
  2. load course, group, purchase, moderation, and territorial entitlement;
  3. select the Stream video or live input UID;
  4. mint a short-lived token with the minimum claims needed;
  5. return a player URL or HLS/DASH manifest URL;
  6. avoid persisting that URL in Zero, messages, or durable API caches.

Set requireSignedURLs on private videos and live inputs. Current custom-token controls include: exp (documented maximum 24 hours), optional nbf, downloadable when downloads are explicitly allowed, up to five accessRules, and allowed origins as a separate defense-in-depth control.

TTL guidance

TRW's historical 48-hour or 72-hour signed HLS patterns exceed the current documented 24-hour maximum and should not be copied. Recommended TTLs: 5–15 minutes for ordinary protected playback; up to 60 minutes when offline recovery or long sessions justify it. Do not bind normal mobile playback to an unstable client IP.

B6. Webhooks, Reconciliation, and the Outbox

Cloudflare has two relevant webhook families:

Cloudflare permits one account-level processing webhook subscription, so route it through one verified ingress and fan out internally through the outbox. Normalize provider payloads into internal events:

Webhook receipt flow:

  1. verify the exact raw body (processing webhooks use the Webhook-Signature HMAC, while Live Notifications can carry the configured cf-webhook-auth secret);
  2. store the raw receipt hash and provider identifiers idempotently;
  3. update video_assets or stream_sessions state;
  4. insert an internal outbox event in the same PostgreSQL transaction;
  5. let pg-boss perform captions, notifications, replay publication, and reconciliation;
  6. return quickly and never perform fan-out inline.

Webhooks are the fast path, not the only truth. Run a scheduled reconciler for uploads stuck in processing, missed disconnects, and live inputs whose provider state disagrees with the database.

B7. Upload Paths

Course lessons and message video attachments should upload directly to Stream through a one-time Direct Creator Upload URL. The application reserves an internal asset ID first and stores the Stream UID against that row. Cloudflare requires tus for uploads larger than 200 MB and recommends it for unreliable connections; the application can cap duration in the reservation. The resumable protocol requires chunks of at least 5 MiB except for the final chunk.

Do not route video bytes through the self-hosted infrastructure app or through R2 merely to hand them to Stream later. That adds failure states and bandwidth without adding value. Stream does not return the exact source upload; it provides playable renditions and can produce an encoded downloadable MP4. Duplicate the original into R2 only if "download the exact uploaded file" is a real requirement.

B8. Playback on Web and React Native

ClientDefaultWhen to use custom playbackNotes
WebCloudflare Stream PlayerUse hls.js for a deeply custom UX, analytics, or synchronized overlaysThe hosted player is the lowest-maintenance path
iOS React NativeNative player around AVPlayer using signed HLSUse a WebView Stream Player for fastest parityRefresh token before expiry; support background/route changes explicitly
Android React NativeMedia3/ExoPlayer using signed HLS or DASHUse a WebView Stream Player for fastest parityTest signed-manifest refresh, casting, and DRM expectations
Web liveStream Player, including supported LL-HLS behaviorhls.js only when custom controls justify ownershipKeep product chat and reactions separate from media playback

Do not proxy or cache manifests in the application. Let Stream control segment URLs, token handling, and rendition changes.

B9. Limitations and Capacity Boundaries

Latency

Standard HLS is a broadcast path, not an interactive path. Design to a several-second glass-to-glass budget; a 3–10 second product budget is reasonable for LL-HLS validation. Cloudflare notes that a newly connected input may take up to roughly 30 seconds to become playable, which is startup time rather than steady-state glass-to-glass latency. The WebRTC beta offers sub-second playback but has the no-recording boundary described above.

Recording and file limits

Long events need deliberate segmentation and replay assembly UX.

Economics

Public Stream pricing is based on minutes stored and minutes delivered. The enterprise contract, not the public page, governs this company. The capacity model still needs: monthly uploaded and recorded minutes; retained minutes by course and creator cohort; live viewer-minutes at p50, p95, and headline-event scale; VOD viewer-minutes and completion rates; replay retention and deletion policy; player preload behavior; and negotiated storage, delivery, and commit tiers.

Capacity model

At creator-platform scale, viewer-minutes dominate. Do not approve a launch model using upload count alone. Concurrent live inputs and account-level limits are contract/account settings. Get the exact enterprise quota, burst procedure, API rate limits, and support escalation in writing. The launch runbook should alert before 70% and 90% of the agreed concurrent-input limit.

B10. Could Cloudflare Realtime Replace LiveKit?

The answer is possibly, but not yet by paper decision. Cloudflare's current Realtime suite has two materially different surfaces:

DimensionLiveKit self-hostedCloudflare Realtime SFUCloudflare RealtimeKit beta
Product abstractionMature rooms, tracks, tokens, SDKsRaw sessions/tracks; build room control planeHigher-level meeting/session abstraction
Operational burdenTeam owns SFU, TURN, Redis, ingress/egress, upgradesCloudflare owns media network; team owns most application signalingCloudflare owns media and more room behavior
Data/feature controlMaximumHigh, but substantial custom codeMore opinionated vendor surface
Maturity riskEstablished open-source deployment modelPrimitive is usable but low-levelPromising, still beta in 2026
Mass broadcast bridgeProven RTMPS composite egress to StreamMust design and validateRecording/export behavior must be validated against Stream workflow

Run a LiveKit-vs-Cloudflare-Realtime spike only if the team actively considers the operational trade-off and has a concrete feasibility plan for the raw SFU's missing room abstraction. Do not decide by paper analysis alone.

Part C — Files Plane

C1. Decision

Recommendation: private R2 as canonical object storage with Cloudflare Images and Workers for transformation and delivery. The files plane has three concerns: durable canonical storage, privacy-controlled short-lived access, and efficient media transformation and delivery. Cloudflare R2 is the right canonical store because it has no egress fee, sits within the Cloudflare enterprise DPA, and has proven path from storage to Workers/Images.

C2. Private R2

All R2 buckets are private. No client ever receives a pre-signed R2 URL directly; all access goes through either:

The R2 r2_object_key column is server-only and excluded from the Zero publication. Never log, expose in errors, or return it in API responses. Use consistent key naming: {server_id}/{year}/{month}/{attachment_id}/{filename}.

Retention and deletion policy per R2 bucket:

C3. Cloudflare Images

Cloudflare Images handles upload, storage, and transformation for user avatars, server icons, banners, thumbnails, attachment image previews, and emoji. Key product facts:

Declared variants for v1: avatar_32, avatar_64, avatar_128, avatar_256, icon_32, icon_64, icon_128, banner_640, banner_1280, thumbnail_200, thumbnail_400. Avoid dynamic transforms on hot paths; lock transforms to declared variants.

C4. File State Machine

Every attachments row progresses through a typed lifecycle:

reserved
  -> upload_pending   (client has a signed upload URL)
  -> uploaded         (R2/Images confirmed receipt)
  -> processing       (scan, transform, transcode workers running)
  -> ready            (all required variants confirmed)
  -> failed           (terminal; manual review)
  -> deleted          (soft-delete; blobs eligible for sweep)

Transitions are driven by webhook receipts from R2/Images or by self-hosted processing workers, both of which write into the same outbox. The controlling invariant: a message cannot display an attachment that has not reached ready. Workers that scan for malware or content policy may insert an intermediate quarantine state before ready.

C5. Upload Flow

All attachment uploads (non-video) use a reservation-first pattern:

  1. client calls POST /api/v1/attachments/reserve with media type, byte size, and checksum;
  2. API validates actor capability, quota, rate limit, and type policy;
  3. API inserts attachments row in state reserved and returns a short-lived signed upload URL;
  4. client PUTs the file directly to the signed URL (R2 or Cloudflare Images direct upload);
  5. the provider confirms receipt via webhook or polling; the row advances to uploaded;
  6. processing workers run scan and transform jobs; the row advances to ready or failed;
  7. the client includes the attachment_id in the message send command.

The server must refuse to send a message that references an attachment not in ready state for the same actor. Upload URLs are single-use and expire in 15 minutes. Preserve local file references (device URIs) in the outbox until the attachment is confirmed ready; do not assume a successful upload before the webhook confirms it.

C6. Serving, Access Control, and Sizing

Image attachments serve through Cloudflare Images delivery URLs. Documents, audio, and non-image files serve through short-lived signed download URLs generated by a Worker at request time. These URLs are never stored in the database, messages, notifications, or Zero rows. They are resolved on the client at render/download time through a narrow GET /api/v1/attachments/{id}/url endpoint that checks current authorization and moderation state.

Sizing quotas enforced at reservation:

Per-server and per-user daily upload quotas are enforced through atomic counter rows on the attachments table, reset by a scheduled job. Enterprise server quotas are configurable through the admin API.

Part C spikes

Spike C1: prove reservation, signed upload, webhook confirmation, processing, and state transition with a realistic payload mix self-hosted + R2. Measure failure rates, orphan cleanup, and latency under burst uploads. Spike C2: prove short-lived signed download URL generation for a moderated attachment does not return a URL after the moderation state change, even with a cached token.