Infrastructure Services
pg-boss jobs, Cloudflare Stream + LiveKit video, private R2 files plane.
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
| Part | One-line recommendation | Production home | Confidence |
|---|---|---|---|
| A — durable jobs | Adopt 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-hosted | High after one runtime spike |
| B — live and VOD | Keep 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-hosted | Stream: decided; interactive layer: medium-high |
| C — files | Use 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-hosted | High 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:
- a typed task registry with versioned, schema-validated payloads;
- immediate, delayed, scheduled, and recurring jobs;
- retries with bounded exponential backoff and explicit terminal failure;
- queue-wide and task-specific concurrency limits;
- deterministic idempotency keys;
- graceful worker shutdown and safe lease recovery;
- inspection, replay, cancellation, and dead-letter operations;
- metrics for wait time, execution time, retries, failures, and stale jobs;
- atomic enqueue with the domain transaction where correctness requires it;
- Bun compatibility, or a deliberately isolated Node/Go worker runtime;
- no non-Cloudflare production SaaS dependency.
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:
- the domain transaction updates domain rows and inserts one immutable outbox event;
- a dispatcher claims that event;
- in one second PostgreSQL transaction, it inserts one stable-ID pg-boss job per handler and records the corresponding delivery rows as dispatched;
- 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:
- the outbox is the durable fact that something happened, suitable for multiple consumers, audit, replay, Zero projections, search, notification fan-out, and integrations;
- the job row is an execution lease with attempts, scheduling, concurrency, and failure state.
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.
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
- one existing durable dependency;
- atomic insertion using the caller's transaction;
- broad queue policy vocabulary: singleton, debounce, throttle, stateful, exclusive, and strict keyed-FIFO controls;
- cron, deferred jobs, retries, dependencies, priorities, and dead-letter patterns;
- current dashboard and proxy packages;
- typed JavaScript/TypeScript integration.
pg-boss risks
- official runtime requirements name Node, not Bun;
- queue load shares PostgreSQL I/O, vacuum, connections, and failure domain with product data;
- the team must set capacity thresholds rather than assuming PostgreSQL is infinite;
- its "exactly once" phrasing describes delivery/locking, not arbitrary external effects.
For correctness-sensitive per-entity serialization, prefer key_strict_fifo over a best-effort group-concurrency setting. Mitigations:
- use a dedicated schema, connection pool, metrics, and autovacuum review;
- keep payloads small and retention bounded;
- use separate worker deployments and resource limits;
- archive business audit data in the outbox, not completed queue rows;
- establish measured PostgreSQL/WAL/autovacuum capacity thresholds and isolate worker resource use.
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
- Package task contracts separately from worker implementations.
- Give every payload an explicit schema version and Zod validator.
- Use task names such as
attachments.scan.v1, not implementation class names. - Insert
{ eventId, taskVersion }in pg-boss inside the outbox-delivery transaction; use a direct domain transaction only for non-event commands. - Use an effect-receipt unique key such as
(event_id, effect_kind, destination). - Use stable vendor idempotency keys wherever the destination supports them.
- Set attempts, backoff, timeout, retention, and concurrency per task definition.
- Quarantine terminal failures; do not silently discard them.
- Export queue depth, oldest-ready age, attempts, execution latency, and terminal failures.
- Run jobs in a separately scalable worker deployment self-hosted.
- Keep presence Redis ephemeral and independently configured.
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 lane | Owns | Does not own | Typical audience |
|---|---|---|---|
| Cloudflare Stream VOD | Upload, encoding, ABR renditions, HLS/DASH delivery, signed playback, course and chat video | Room signaling, participant state, sub-second conversation | Any asynchronous viewer |
| Cloudflare Stream Live, recorded lane | RTMPS/SRT ingest, one-to-many HLS/DASH, automatic recording to VOD, live lifecycle | Multi-party room semantics, stage moderation, data channels | Large broadcast audience |
| Cloudflare Stream WebRTC beta lane | Paired WHIP ingest and WHEP sub-second playback | Recording, HLS/DASH crossover, analytics, viewer count, restreaming | Spike only, not the recorded backbone |
| LiveKit self-hosted | WebRTC rooms, speakers, stage, calls, tracks, data, sub-500 ms interaction | Economical mass fan-out and the canonical VOD library | Small/medium interactive room |
| LiveKit Egress self-hosted | Composite or track output from a room; RTMPS/SRT handoff | CDN fan-out | Bridge from interactive stage to Stream |
| Cloudflare Realtime / RealtimeKit | Cloudflare-hosted WebRTC building blocks or higher-level rooms | Proven parity with the chosen LiveKit operating model | Candidate replacement requiring a spike |
B3. Important 2026 Correction: WHIP is a Separate Beta Lane
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.
- Production recorded broadcast: RTMPS or SRT → Stream → HLS/DASH → VOD
- Experimental sub-second broadcast: WHIP → Stream beta → WHEP, with no recording promise
- Interactive conversation: LiveKit, not Stream HLS
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:
- hosts and invited speakers join a LiveKit room;
- the application selects a composite layout;
- LiveKit RoomComposite Egress renders that layout;
- Egress publishes RTMPS to the Stream Live Input;
- Stream provides ABR delivery to the large view-only audience;
- chat, reactions, entitlement, and viewer presence remain application features;
- 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:
- authenticate the Better Auth session;
- load course, group, purchase, moderation, and territorial entitlement;
- select the Stream video or live input UID;
- mint a short-lived token with the minimum claims needed;
- return a player URL or HLS/DASH manifest URL;
- 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.
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:
- Stream Live Input webhooks with exact event types
live_input.connected,live_input.disconnected, andlive_input.errored; - video lifecycle webhooks reporting upload/processing readiness and errors.
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:
stream.ingest_connected.v1stream.ingest_disconnected.v1stream.ingest_error.v1video.processing_ready.v1video.processing_failed.v1
Webhook receipt flow:
- verify the exact raw body (processing webhooks use the
Webhook-SignatureHMAC, while Live Notifications can carry the configuredcf-webhook-authsecret); - store the raw receipt hash and provider identifiers idempotently;
- update
video_assetsorstream_sessionsstate; - insert an internal outbox event in the same PostgreSQL transaction;
- let pg-boss perform captions, notifications, replay publication, and reconciliation;
- 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
| Client | Default | When to use custom playback | Notes |
|---|---|---|---|
| Web | Cloudflare Stream Player | Use hls.js for a deeply custom UX, analytics, or synchronized overlays | The hosted player is the lowest-maintenance path |
| iOS React Native | Native player around AVPlayer using signed HLS | Use a WebView Stream Player for fastest parity | Refresh token before expiry; support background/route changes explicitly |
| Android React Native | Media3/ExoPlayer using signed HLS or DASH | Use a WebView Stream Player for fastest parity | Test signed-manifest refresh, casting, and DRM expectations |
| Web live | Stream Player, including supported LL-HLS behavior | hls.js only when custom controls justify ownership | Keep 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
- A continuous live recording is truncated after the first seven days.
- MP4 downloads for live recordings are limited to recordings shorter than four hours.
- The documented default maximum upload is 30 GB and the default concurrent upload/encoding queue is 120 videos; enterprise support can clarify or raise relevant limits.
- Stream currently produces adaptive H.264 playback up to 1080p.
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.
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:
- the raw Realtime SFU, which exposes sessions and tracks but deliberately has no room abstraction or client SDK; the application owns signaling, membership, authorization, presence, and track discovery;
- RealtimeKit, a higher-level beta with meetings, participants, presets, stage concepts, prebuilt UI/SDKs, recording, chat, polls, breakouts, and transcription.
| Dimension | LiveKit self-hosted | Cloudflare Realtime SFU | Cloudflare RealtimeKit beta |
|---|---|---|---|
| Product abstraction | Mature rooms, tracks, tokens, SDKs | Raw sessions/tracks; build room control plane | Higher-level meeting/session abstraction |
| Operational burden | Team owns SFU, TURN, Redis, ingress/egress, upgrades | Cloudflare owns media network; team owns most application signaling | Cloudflare owns media and more room behavior |
| Data/feature control | Maximum | High, but substantial custom code | More opinionated vendor surface |
| Maturity risk | Established open-source deployment model | Primitive is usable but low-level | Promising, still beta in 2026 |
| Mass broadcast bridge | Proven RTMPS composite egress to Stream | Must design and validate | Recording/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:
- a Cloudflare Images delivery URL (for image/attachment display), or
- a short-lived Worker-signed URL (for binary downloads, video, audio, and documents).
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:
boja-media: permanent storage for course and live content;boja-attachments: retention-class-aware; hard-deleted afterdeleted_at + retention_days;boja-uploads-tmp: 48-hour TTL; reservation-gated; swept by lifecycle rule;boja-exports: 7-day TTL; user-initiated data exports.
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:
- images are stored inside Cloudflare Images, not in R2 (separate store);
- image variants (size, quality, format) are declared on the account and named for the URL;
- delivery URLs are public and content-addressed by default; use signed delivery URLs for private content;
- the Direct Creator Upload path gives a one-time URL; PAN/CVV/PHI must never be in image metadata.
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:
- client calls
POST /api/v1/attachments/reservewith media type, byte size, and checksum; - API validates actor capability, quota, rate limit, and type policy;
- API inserts
attachmentsrow in statereservedand returns a short-lived signed upload URL; - client PUTs the file directly to the signed URL (R2 or Cloudflare Images direct upload);
- the provider confirms receipt via webhook or polling; the row advances to
uploaded; - processing workers run scan and transform jobs; the row advances to
readyorfailed; - the client includes the
attachment_idin 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:
- image attachment: 50 MB maximum;
- document attachment: 100 MB maximum;
- audio attachment: 500 MB maximum;
- video attachment: 2 GB maximum (tus resumable required above 200 MB).
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.
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.