Distribute WebRTC Video with MediaMTX and rstream
Add an on-demand MediaMTX backend to the adaptive rstream video platform, share one protected device uplink across viewers, and keep direct WebRTC as a selectable path.
This is the third guide in the adaptive video series. The first guide built the Go producer and qualified its direct device-to-browser path. The second guide placed the same producer behind a Next.js product control plane. This final part adds multi-viewer distribution without creating a second capture implementation or browser player.
The producer continues to expose one WHEP source through its outbound rstream tunnel. On the first viewer request, MediaMTX starts a lightweight Go adapter. The adapter obtains short-lived source and destination material from Next.js, pulls the producer through WHEP, repairs the shared source leg, and publishes a fresh H.264 track into MediaMTX. Later viewers reuse that publisher. When the last viewer leaves, MediaMTX stops the adapter and the source resource is deleted.
Direct WebRTC remains a first-class backend. It keeps the shortest path for one-to-one viewing and provides a useful diagnostic reference. Fan-out becomes valuable when several viewers need the same device: producer bandwidth stays close to one encoded stream instead of growing once per browser.
rstream examples / webrtc-videoOpen the shared producer, Next.js platform, and MediaMTX distributor.git clone https://github.com/rstreamlabs/rstream-examples.git
cd rstream-examples/webrtc-videoChoose the distribution path
The stable boundaries are deliberate.
producer/owns capture, encoding, TWCC/GCC adaptation, bounded pacing, NACK/RTX, optional FlexFEC, ICE recovery, and producer OpenMetrics.platform/owns device identity, user authorization, short-lived credentials, live tunnel state, and theVideoDistributorselection.shared/whep-client.tsis the browser session implementation used for direct producer and MediaMTX playback.distributor/contains MediaMTX and the optional on-demand adapter.
VIDEO_DISTRIBUTOR=direct returns the producer WHEP endpoint. VIDEO_DISTRIBUTOR=mediamtx returns a MediaMTX WHEP endpoint for the same device. The React player consumes the same viewer contract in both cases, applies the same bounded ICE-restart and reconnect policy, and requests fresh authorization before replacing a failed resource.
| Path | Source sessions | Source protection | Source discovery | Best fit |
|---|---|---|---|---|
| Direct | one per viewer | TWCC, NACK/RTX, FlexFEC, bounded pacing | Next.js | one viewer, minimum latency, end-to-end adaptation |
| MediaMTX native pull | one shared | NACK and TWCC with fixed source pacing | static MediaMTX path | a small fixed deployment with the reduced profile |
| MediaMTX with adapter | one shared | TWCC, NACK/RTX, FlexFEC, bounded pacing | static URL or Next.js resolver | dynamic devices and protected multi-viewer fan-out |
Native pull uses the same producer binary with an explicit compatibility option. The option accepts the two bounded MediaMTX 1.20 offer differences while continuing to require BUNDLE and RTCP multiplexing. It disables adaptive source encoding, RTX, and FlexFEC for that session; it never weakens the strict default WHEP profile silently.
The adapter is the complete fan-out path. One protected producer-to-adapter session carries the device stream, while MediaMTX gives every browser a separate NACK/TWCC session. The adapter can resolve a fixed source URL or ask Next.js for fresh per-device credentials on demand.
All three paths share the capture code and browser player. Choose direct delivery for one viewer or the shortest path, native pull for a fixed source that fits its reduced profile, and the adapter when a product needs dynamic resolution and complete source-leg repair.
Keep feedback local to each media leg
Direct playback has one congestion domain. Browser feedback reaches the producer, and the producer adjusts its encoder and pacer for that viewer's path.
Fan-out has two. The adapter receives and repairs the shared device stream. It then publishes a new RTP flow to MediaMTX, with new transport-wide sequence numbers and downstream feedback state. Each browser negotiates its own MediaMTX WebRTC session. Loss and delay on one viewer leg therefore remain distinguishable from the device uplink and cannot directly control the encoder shared by every viewer.
NACK, RTX, and FlexFEC are terminated on the source leg. The adapter reorders packets inside a 300 ms window, retries missing packets at a bounded cadence, expires missing media after one second, and forwards PLI/FIR recovery requests to the producer. It stops on overflow or peer failure rather than accumulating an unbounded live-stream backlog.
MediaMTX does not transcode this stream. That keeps the deployment lightweight and avoids concealing an expensive codec pipeline behind a configuration flag. Products that need resolution or codec ladders can add measured transcoding workers later without changing the producer, player, or VideoDistributor contract.
Respect the single-rendition boundary
The reference distributor carries one H.264 rendition. It is efficient when viewers can receive that rendition, but it is not a substitute for a bitrate ladder. Downstream TWCC terminates at MediaMTX. It describes the viewer leg, but the current single-rendition path has no alternate bitrate to select and does not send that estimate back to the producer encoder shared by every viewer.
This distinction changes the deployment gate. Use the direct backend when one viewer needs end-to-end encoder adaptation or the shortest path. Use the current fan-out backend only for a viewer population whose qualified capacity fits the source rendition. A deployment that must serve heterogeneous links needs measured multi-rendition selection or transcoding before it can claim the same continuity. Reducing queue limits, hiding drops, or weakening the playback gate does not solve that architectural constraint.
MediaMTX can carry multiple tracks from compatible publishers, and external
workers can produce additional renditions. Neither capability is enabled by
this reference because a source ladder needs its own encoder budget, selection
policy, observability, and congestion qualification. The VideoDistributor
boundary leaves room for that profile without changing the producer-facing
product API or the browser player.
Run the complete stack locally
The local launcher starts Next.js, the combined MediaMTX and adapter container,
and the temporary rstream resources needed by the platform. It owns their
lifecycle and removes them together on Ctrl-C.
cd platform
npm install
cp .env.example .env.local
# Fill the required product, database, and rstream settings.
npm run mediamtx:localThis default exposes MediaMTX directly on localhost. The alternative publishes its HTTP signaling listener through an authenticated rstream tunnel while ICE continues to select the media path independently.
npm run mediamtx:local -- --exposure rstreamRun the producer in provisioning mode with the device secret created by the platform. The same producer command works for direct and adapter-backed playback; the platform selects the distribution backend.
cd ../producer
make build-provisioning
API_URL=http://localhost:3000 \
DEVICE_SECRET=dev_... \
./webrtc-video-producer -config ./config.provisioning.h264.yamlBuild the combined distributor
The distributor image contains the pinned MediaMTX binary and the Go adapter in one unprivileged container. The official MediaMTX image is distroless and cannot execute an external runOnDemand command; the supplied image adds only a shell, CA roots, the adapter, and a non-root runtime.
cd distributor
docker build -t rstream-video-distributor:local .It exposes three listeners.
| Listener | Purpose | Exposure |
|---|---|---|
8889/tcp | WHEP/WHIP HTTP surface | expose through public HTTPS or an authenticated rstream tunnel |
8189/udp | WebRTC ICE media | advertise a reachable address or configure STUN/TURN |
9998/tcp | MediaMTX OpenMetrics | keep on loopback or a private monitoring network |
The included configuration disables RTSP, RTMP, HLS, SRT, MoQ, playback, the API, and pprof. Only the WebRTC and metrics surfaces required by this deployment remain enabled.
Configure Next.js and MediaMTX
Generate the MediaMTX access key and one named distributor identity from the platform directory.
cd ../platform
npm run mediamtx:key -- mediamtx-oneThe command emits distinct material for the two trust boundaries. The platform keeps the MediaMTX access private key and the distributor's public resolver key. The distributor keeps its resolver private key; MediaMTX receives the platform's public access keys through JWKS.
VIDEO_DISTRIBUTOR="mediamtx"
MEDIAMTX_EXPOSURE="public"
MEDIAMTX_PUBLIC_URL="https://media.example"
MEDIAMTX_TUNNEL_NAME=""
MEDIAMTX_JWT_PRIVATE_KEY_BASE64="..."
MEDIAMTX_JWT_ADDITIONAL_JWKS='{"keys":[]}'
MEDIAMTX_JWT_ISSUER="rstream-webrtc-video-platform"
MEDIAMTX_JWT_AUDIENCE="rstream-mediamtx"
MEDIAMTX_SOURCE_RESOLVER_JWKS='{"keys":[...]}'
MEDIAMTX_SOURCE_RESOLVER_ISSUER="rstream-video-distributor"
MEDIAMTX_SOURCE_RESOLVER_AUDIENCE="rstream-video-source-resolver"
MEDIAMTX_TOKEN_TTL_SECONDS="300"MediaMTX 1.20 refreshes a remote JWKS at most once per hour. A key rotation
therefore has two phases: publish the next public key through
MEDIAMTX_JWT_ADDITIONAL_JWKS while the current signer remains active, wait
for every MediaMTX instance to refresh, then switch the signer and retain the
old public key for at least the longest token lifetime plus one complete
refresh window. This order prevents a newly signed viewer token from reaching
an instance that has not learned its key.
Give the distributor the public platform endpoints and the private key for its
named resolver identity. MediaMTX accepts its normal MTX_... environment
overrides.
MTX_AUTHJWTJWKS="https://platform.example/api/video/distributor/jwks"
MTX_AUTHJWTISSUER="rstream-webrtc-video-platform"
MTX_AUTHJWTAUDIENCE="rstream-mediamtx"
MTX_WEBRTCALLOWORIGINS="https://platform.example"
MTX_WEBRTCADDITIONALHOSTS="media.example"
RSTREAM_SOURCE_RESOLVER_URL="https://platform.example/api/video/distributor/source"
RSTREAM_SOURCE_RESOLVER_INSTANCE_ID="mediamtx-one"
RSTREAM_SOURCE_RESOLVER_PRIVATE_KEY_BASE64="..."
RSTREAM_SOURCE_RESOLVER_ISSUER="rstream-video-distributor"
RSTREAM_SOURCE_RESOLVER_AUDIENCE="rstream-video-source-resolver"
RSTREAM_MEDIAMTX_URL="http://127.0.0.1:8889"
MTX_PATHDEFAULTS_MAXREADERS="8"Start the supplied single-service Compose deployment with 8189/udp reachable from its intended viewers. Keep metrics private. The compose file also runs the combined container read-only, drops Linux capabilities, and enables no-new-privileges.
cp distributor.env.example distributor.env
# Replace every example origin, host, and secret before continuing.
docker compose up --build -d
docker compose psMediaMTX and the adapter still run in one container. MediaMTX creates the adapter only while a device path has demand, owns its signals, and reaps it during shutdown; Compose does not add another media service.
Choose the signaling exposure independently from the distribution mode. With
public exposure, terminate HTTPS in front of 8889/tcp, set
MEDIAMTX_EXPOSURE=public, and set MEDIAMTX_PUBLIC_URL to that public origin.
No rstream tunnel is required for this leg.
With rstream exposure, keep 8889/tcp on loopback, set
MEDIAMTX_EXPOSURE=rstream, clear MEDIAMTX_PUBLIC_URL, and set
MEDIAMTX_TUNNEL_NAME to the exact authenticated tunnel name. Publish it from
the same host.
rstream forward 8889 \
--name webrtc-video-mediamtx \
--token-auth \
--label service=webrtc-video-distributorIn the rstream profile, the distributor tunnel carries the browser's WHEP
requests. In both profiles, the adapter reaches the producer WHEP resource
through its device tunnel and publishes WHIP to MediaMTX over loopback. WebRTC
media still follows ICE. A publicly reachable 8189/udp address is the
shortest path; NATed or restricted deployments should configure
webrtcICEServers2 with their STUN/TURN policy. The platform also supplies TURN
credentials to the browser, but that does not replace server-side MediaMTX
candidate configuration.
Let demand own the source lifecycle
MediaMTX maps a viewer path such as devices/<device-id> to one runOnDemand process. The process receives that path through MTX_PATH; it never accepts a viewer-supplied source URL.
The lifecycle is short and deterministic.
- The first browser requests
devices/<device-id>/whep. - MediaMTX starts one adapter and waits up to 15 seconds for its publisher.
- The adapter signs a short-lived resolver request with its named Ed25519 identity.
- Next.js verifies the device, confirms its rstream tunnel is online, and returns short-lived source WHEP, destination WHIP, and TURN material.
- The adapter pulls, repairs, and publishes the source. Additional viewers share it.
- Five seconds after the final reader leaves, MediaMTX sends
SIGINT; the adapter cancels its workers and deletes both HTTP media resources.
runOnDemandRestart is disabled. Recovery stays inside the single adapter process owned by the active demand: a failed source or destination attempt closes both peer connections and HTTP resources, resolves fresh credentials, then retries after a jittered exponential delay bounded between one and 15 seconds. Thirty seconds of stable forwarding resets that delay, and MediaMTX SIGINT interrupts either forwarding or backoff immediately. This avoids an uncontrolled process restart loop while allowing an existing demand to recover when a device or path returns.
The player has a separate five-attempt reconnect budget with bounded jitter. Every full reconnect asks Next.js for fresh viewer authorization, and thirty seconds of stable playback resets that budget.
The supplied MediaMTX profile admits eight readers per device path. That is a measured reference boundary rather than an audience claim: at 8 Mbit/s, the qualification exercises one, four, and eight simultaneous viewers, rejects a ninth while the existing eight keep receiving media, and repeatedly replaces readers without creating a second producer session. Repeat the qualification on the deployment class before raising the limit. A per-path limit does not replace a fleet-wide CPU, memory, and egress admission policy.
Separate product, edge, and media authorization
The request crosses three different trust boundaries.
| Credential | Holder | Scope |
|---|---|---|
| rstream viewer token | browser | connect to one online distributor tunnel and one device WHEP path |
| MediaMTX read JWT | browser | read on one devices/<device-id> path |
| resolver identity | adapter | resolve one path and purpose as one registered distributor instance |
| rstream source token | adapter | connect to one online producer tunnel and its WHEP resource paths |
| MediaMTX publish JWT | adapter | publish on the same device path |
MediaMTX verifies read and publish JWTs from the platform JWKS endpoint. Issuer, audience, action, and exact path are checked. Each resolver call also contains the distributor instance, exact path, purpose, nonce, and a lifetime of at most 20 seconds. The platform verifies it against the instance public key, while the private key stays inside that distributor deployment. Browser credentials cannot publish, publisher credentials cannot read another path, and the adapter never receives the platform's rstream application secret. CORS is restricted to the product origin.
The browser needs both edge and media authorization on the distributor request. Its short-lived rstream token is carried as rstream.token; the engine validates its tunnel and path scope, removes it, and forwards the separate MediaMTX JWT in Authorization. This keeps the distributor tunnel protected without forcing both trust boundaries into one bearer or exposing the MediaMTX surface without edge policy.
Observe source and distribution independently
The producer exporter remains the source of truth for capture freshness, frame cadence, encoder output, TWCC/GCC estimate, pacer wire rate, queue residence, NACK/RTX, and FlexFEC on the device uplink. A collector beside the device scrapes its private OpenMetrics listener.
MediaMTX exposes path readiness, reader count, and inbound/outbound bytes on its private metrics listener.
GET http://127.0.0.1:9998/metrics?type=paths&path=devices/<device-id>The pair answers different operational questions. A stale producer frame timestamp identifies capture or encoding failure. Healthy producer output with a missing MediaMTX publisher isolates source resolution or the shared WebRTC leg. A ready path with rising outbound bytes and a single failing browser points to that viewer's ICE or access link.
The on-demand adapter writes bounded repair totals in its structured shutdown record. It separates recovery packets received from repairs delivered before the reorder window closes, and identifies late RTX/FlexFEC packets instead of reporting them as successful repairs. It deliberately does not start one metrics server per device: transient processes would collide on listeners and turn device identity into an unbounded metric dimension. Stable fleet identity belongs in collector target labels; session and viewer ids stay out of metric labels.
Use native WHEP pull where it fits
MediaMTX can pull another WHEP source without the adapter.
paths:
cameras/warehouse:
source: wheps://source.example/whep
whepBearerToken: source-token
sourceOnDemand: true
sourceOnDemandStartTimeout: 15s
sourceOnDemandCloseAfter: 5sEnable the producer's explicit MediaMTX compatibility profile for that source.
web:
whep:
allowMediaMTXNativeOffer: trueThis profile fits a static source when NACK/TWCC with fixed producer pacing is
enough. MediaMTX 1.20 omits rtcp-mux-only and a common msid from its source
offer and does not complete a 406 counter-offer exchange. The opt-in accepts
only those known differences. It does not enable the adapter's RTX/FlexFEC
repair, adaptive source encoding, or per-device resolver. The strict producer
profile remains the default.
Technical qualification
With one, four, and eight readers, the distributor must retain one producer WHEP resource and one MediaMTX publisher. The source leg is then subjected to packet loss: FlexFEC covers the recoverable case, RTX covers loss of the first repair packet, and TWCC feedback must continue to reach the producer.
Failure tests cancel negotiations, stall HTTP operations, expire credentials, reject incorrect JWT paths or actions, and stop MediaMTX. Active viewers must remain isolated, the final reader must release the source, and every failure must converge on a clean state.
A real browser completes the path through Next.js and rstream. It starts through MediaMTX, falls back to a newly authorized direct session when the distributor stops, then returns to MediaMTX after recovery. Source and viewer links are shaped independently. The current fan-out carries one rendition; heterogeneous viewers require an explicit rendition or transcoding strategy.
The distribution evidence retains the measurements and thresholds. The standalone producer guide covers adaptation and playback quality on the shared source leg.
For a device fleet, regional MediaMTX selection, viewer policy, or media topology tailored to your product, contact us.
Troubleshooting
If direct playback works but fan-out does not, inspect the MediaMTX path state and adapter logs before the browser. A missing publisher points to source resolution, WHEP authorization, or the producer-to-adapter ICE path. A ready path with no browser media points instead to MediaMTX read authorization or viewer ICE reachability.
The metrics listeners distinguish those legs: producer metrics describe the device upstream, while MediaMTX reports publisher readiness, readers, and distribution bytes. A weak viewer link cannot lower the shared source rendition in this reference profile; select direct delivery or add a qualified rendition strategy for that case.