Rindle ships a single optional streaming provider for v1.6: Mux. This
guide walks you through enabling signed HLS streaming end-to-end —
dependencies, signing-key creation, profile configuration, webhook plug
wiring, scheduled sync, local development, secret rotation, the
mix rindle.doctor --streaming smoke check, an operator runbook for
stuck assets, and a performance footgun note.
Adopters who only need progressive AV download stay on
Rindle.Profile.Presets.Web— streaming is opt-in. The runtime cost of:muxand:joseis zero unless you opt a profile in.
This guide covers:
- Why a streaming provider, and when not to opt in
- Adding
:muxand:joseas optional deps - Creating your Mux signing key out-of-band
- Configuring a profile via
Rindle.Profile.Presets.MuxWeb - Wiring the webhook plug end-to-end
- Scheduling the sync coordinator cron worker
- Local development with a webhook tunnel
- Webhook secret rotation workflow
- Running
mix rindle.doctor --streamingsmoke checks - Operator runbook for stuck provider assets
- A performance note on high-throughput JWT signing
For the canonical AV-progressive-download path that does NOT require a streaming provider, see Secure Delivery.
1. Why a Streaming Provider?
Rindle's default delivery is progressive download via signed storage URL:
adopters call Rindle.url/3, the storage adapter signs a time-limited URL,
and the browser plays an MP4 directly off S3 / R2 / GCS. This works well
for short clips, posters, and "click to play" flows.
When a profile opts into a streaming provider — by setting
delivery: [streaming: %{provider: ...}] (or via Rindle.Profile.Presets.MuxWeb,
which sets it for you) — the same Rindle.Delivery.streaming_url/3 call
resolves the playback URL via the provider instead of via signed storage.
For Mux, that means signed HLS playback URLs with adaptive bitrate, captions,
and global delivery; the source media still lives in your storage adapter
(Phase 33's "your bucket, our streaming" posture).
2. Add Mux to Your Dependencies
# mix.exs
defp deps do
[
# ... your existing deps ...
{:rindle, "~> 0.1"},
{:mux, "~> 3.2", optional: true},
{:jose, "~> 1.11", optional: true}
]
endBoth deps are optional: true so adopters who never opt a profile into
streaming pay zero transitive runtime cost.
Configure the runtime block (matches the Phase 34 D-29 layout exactly):
# config/runtime.exs
config :rindle, Rindle.Streaming.Provider.Mux,
token_id: System.get_env("RINDLE_MUX_TOKEN_ID"),
token_secret: System.get_env("RINDLE_MUX_TOKEN_SECRET"),
signing_key_id: System.get_env("RINDLE_MUX_SIGNING_KEY_ID"),
signing_private_key: System.get_env("RINDLE_MUX_SIGNING_PRIVATE_KEY"),
webhook_secrets:
System.get_env("RINDLE_MUX_WEBHOOK_SECRETS", "")
|> String.split(",", trim: true)Five environment variables; all required when MuxWeb is wired into a
profile. Configuration is read at the call site, not cached, so runtime
rotation works without a release restart.
3. Create Your Mux Signing Key
Signing keys are an out-of-band operation in Mux's dashboard. Rindle never auto-creates them — the adopter owns key custody.
- Sign in to your Mux dashboard.
- Navigate to Settings → Signing Keys → Create Signing Key.
- Mux returns the key id (public) and an RSA private key (PEM-encoded).
- Download the private key once — Mux does not let you re-download it. If you lose it, you must rotate.
- Store the private key in your secrets manager. Rindle reads it from
RINDLE_MUX_SIGNING_PRIVATE_KEYat runtime.
The signing key id is non-sensitive and can live in non-secret config; the private key is secret-grade and MUST live in your secrets manager (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, Fly secrets, etc.).
4. Configure Your Profile with MuxWeb
Rindle.Profile.Presets.MuxWeb is the streaming-on twin of
Rindle.Profile.Presets.Web. It inherits the canonical web_720p + poster
variants and locks the delivery.streaming block to Mux:
defmodule MyApp.Streaming do
use Rindle.Profile.Presets.MuxWeb,
storage: Rindle.Storage.S3,
allow_mime: ["video/mp4", "video/quicktime", "video/webm"],
max_bytes: 524_288_000
endMuxWeb is a thin wrapper — same opts as Web (:storage, :allow_mime,
:max_bytes), same variant set (web_720p + poster), plus a locked
:delivery block:
delivery: [
streaming: %{
provider: Rindle.Streaming.Provider.Mux,
playback_policy: :signed,
ingest_mode: :server_push,
source_variant: :web_720p
}
]There is no :scrub_strip opt-in for MuxWeb — streaming-enabled profiles
are signed-playback by definition. Mux requires the source MP4 to be
publicly fetchable for server-push ingest; Rindle generates a one-time
signed source URL via Rindle.Delivery.streaming_url/3 source-variant
resolution and hands it to Mux's create-asset call.
5. Wire the Webhook Plug
Mux notifies Rindle of asset readiness via signed webhook deliveries. The
mountable Rindle.Delivery.WebhookPlug verifies HMAC signatures and
enqueues an Oban worker for asynchronous processing.
Step 1 — install the body reader globally in endpoint.ex (BEFORE Plug.Parsers):
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
body_reader: {Rindle.Delivery.WebhookBodyReader, :read_body, []},
json_decoder: JasonStep 2 — mount the Plug in router.ex, one forward per provider:
forward "/webhooks/rindle/mux", Rindle.Delivery.WebhookPlug,
provider: Rindle.Streaming.Provider.Mux,
secrets: {:application, :rindle, [Rindle.Streaming.Provider.Mux, :webhook_secrets]}Step 3 — set RINDLE_MUX_WEBHOOK_SECRETS (comma-separated) in your runtime
config, and configure your Mux dashboard webhook to POST to
https://yourapp.example.com/webhooks/rindle/mux.
The Plug returns:
| Status | Body | When |
|---|---|---|
| 202 Accepted | empty | Verified + enqueued. |
| 200 OK | empty | Verified but dropped (event not in adapter dispatch table). |
| 400 Bad Request | provider_webhook_invalid | Signature mismatch, replay-window failure, missing secrets, callback raised. |
| 405 Method Not Allowed | method not allowed | Non-POST request. |
| 500 Internal Server Error | server_misconfigured | Body reader assign missing AND fallback empty. |
| 503 Service Unavailable | empty | Oban enqueue raised (transient downstream failure — Mux retries). |
6. Schedule the Sync Coordinator
Webhooks can be lost or delayed; Rindle ships a per-row reconciliation coordinator as a backstop. Schedule the coordinator from your Oban cron config; you do not need Rindle to supervise Oban.
config :my_app, Oban,
queues: [rindle_provider: 4],
plugins: [
{Oban.Plugins.Cron,
crontab: [
{"* * * * *", Rindle.Workers.MuxSyncCoordinator}
]}
]Cron resolution is 1 minute. The coordinator's internal query enforces a
provider_polling_floor_seconds: 30 floor so rows that were just touched
by a webhook are not redundantly polled.
The coordinator fans out per-row sync jobs only for media_provider_assets
rows in (processing, uploading) state older than the floor. Per-row
unique constraint dedupes within the 60s window so back-to-back cron ticks
don't double-fan-out the same row.
7. Local Development with a Webhook Tunnel
To exercise the full webhook path locally, expose localhost:4000 to
the public internet.
cloudflared tunnel --url http://localhost:4000
Cloudflare's TryCloudflare quick tunnel is signup-free and adequate for Mux webhook volume. See TryCloudflare docs. ngrok is a popular alternative; note that as of 2026 it requires account signup before a tunnel will start (see ngrok pricing). Update your Mux dashboard webhook URL to the tunnel-issued hostname while testing.
8. Webhook Secret Rotation Workflow
RINDLE_MUX_WEBHOOK_SECRETS is comma-separated for exactly this reason —
multiple secrets verify in parallel during rotation.
- Add the new secret to the front of the comma-separated list:
RINDLE_MUX_WEBHOOK_SECRETS=whsec_NEW,whsec_OLD. - Rotate the corresponding secret in your Mux dashboard.
- Watch telemetry. Every verified webhook emits the
[:rindle, :provider, :webhook, :verified]event with metadata%{provider, event_type, event_id, kind}; the provider-internal[:rindle, :provider, :mux, :webhook_attempt, :secret_used]event carriessecret_index(0-based offset into the secrets list). Subscribe to confirm new secrets are in active use before retiring the old one. - Wait 24 hours as a grace window for in-flight retries from Mux.
- Retire the old secret by removing it from the list:
RINDLE_MUX_WEBHOOK_SECRETS=whsec_NEW.
The grace window is a recommendation, not a contract — if you have high webhook volume and observable telemetry, you can shorten it to match your actual retry tail (Mux retries up to 24h with exponential backoff for 5xx responses).
9. Run mix rindle.doctor --streaming
The doctor task includes four streaming-aware checks. Without
--streaming, the smoke-ping check skips (offline-friendly default).
With --streaming, the doctor performs a 5-second smoke ping against
api.mux.com.
mix rindle.doctor --streaming
Expected PASS output:
[ok] doctor.streaming_credentials: All five RINDLE_MUX_* credentials are set.
[ok] doctor.streaming_signing_key: RINDLE_MUX_SIGNING_PRIVATE_KEY parses as a valid JOSE JWK.
[ok] doctor.streaming_webhook_secrets: RINDLE_MUX_WEBHOOK_SECRETS has 1 secret(s), all ≥ 32 chars.
[ok] doctor.streaming_smoke_ping: Mux.Video.Assets.list/1 returned 200 (smoke ping OK).Failure-mode taxonomy for doctor.streaming_smoke_ping:
| Result | Fix |
|---|---|
| HTTP 200 | OK — no action needed. |
| HTTP 401 / 403 | Verify RINDLE_MUX_TOKEN_ID and RINDLE_MUX_TOKEN_SECRET in your runtime config. |
| HTTP 429 | Mux rate-limited the smoke ping; retry in a few seconds. |
| Timeout / connection error | Could not reach api.mux.com within 5s; check network / proxy / DNS. |
| Other 4xx / 5xx | Fix references the response status; consult Mux status page. |
If no profile in the application opts into streaming, all four checks
return ok with summary "No streaming-enabled profiles discovered." —
mirrors the vacuous-OK posture of doctor.local_playback.
10. Operator Runbook: Stuck Assets
Mux occasionally drops a webhook; the sync coordinator reconciles. When
neither the webhook nor the cron has cleared a row within the configured
threshold (provider_stuck_threshold_seconds: 7200 default, 2 hours),
the row is considered stuck.
Inspect stuck assets:
mix rindle.runtime_status --provider-stuck
The report enumerates media_provider_assets rows in (processing,
uploading) state older than the threshold, with provider_asset_id
redacted to last-4 chars per security invariant 14. From there you can
manually re-fetch from Mux or cancel.
To cancel stuck IngestProviderWebhook jobs in Oban:
# In an IEx console:
Oban.cancel_jobs(Rindle.Workers.IngestProviderWebhook)A higher-level Rindle.cancel_provider_ingest/1 API is planned for v0.3+;
until then, use Oban's job-cancellation surface directly.
11. Performance Note: High-Throughput JWT Signing
For adopters above ~1,000 playback URLs/sec, JOSE.JWK.from_pem/1 becomes
a hot path because Rindle re-parses the PEM on every signed-URL call. The
recommended optimization is a :persistent_term cache keyed by signing
key id; an in-library cache ships in v0.3+. Until then, you can patch the
cache yourself by wrapping Rindle.Streaming.Provider.Mux.sign_playback_id/2
in your application.
For most adopters (<100 playback URLs/sec) this is below the noise floor and no action is needed.
Quick Reference
Telemetry events you can subscribe to from your application:
| Event | Payload | When |
|---|
| [:rindle, :provider, :webhook, :verified] | %{provider, event_type, event_id, kind} (kind: :enqueued | :dropped) | Successful HMAC verification. |
| [:rindle, :provider, :webhook, :rejected] | %{provider, reason} (reason: :sig_mismatch | :no_secrets_configured | :body_reader_missing | :provider_callback_raised | :method_not_allowed | :oban_unavailable) | Verification or pre-verify check failed. |
| [:rindle, :provider, :mux, :webhook_attempt, :secret_used] | %{secret_index} | HMAC verification succeeded for the given secret offset. |
| [:rindle, :provider, :mux, :webhook_attempt, :rejected] | %{secret_index, sdk_reason} | HMAC verification failed for the given secret offset. |
Configuration reference:
| Goal | Configuration |
|---|---|
| Enable Mux streaming on a profile | use Rindle.Profile.Presets.MuxWeb, storage: ..., allow_mime: [...], max_bytes: ... |
| Multi-secret rotation | RINDLE_MUX_WEBHOOK_SECRETS=whsec_NEW,whsec_OLD |
| Adjust polling floor | config :rindle, Rindle.Streaming.Provider.Mux, provider_polling_floor_seconds: 30 |
| Adjust stuck threshold | config :rindle, Rindle.Streaming.Provider.Mux, provider_stuck_threshold_seconds: 7200 |
| Local webhook tunnel | cloudflared tunnel --url http://localhost:4000 |
| Smoke check before deploy | mix rindle.doctor --streaming |