Skip to content

Audio Proxy — API v1 (draft)

An imgproxy-style on-the-fly audio transcoding proxy. Sources live in S3 (or any HTTP-reachable store); variants are rendered on demand, streamed to the first requester, and written back to a variant bucket for cached, range-capable serving thereafter.

Design principles, in order: URLs are the entire API (no request bodies, no state); every variant is fully described by its processing options, which double as the cache key; everything is signed.


GET /{signature}/{options}/{source}
  • signaturebase64url(HMAC-SHA256(key, salt ‖ path)) over everything after the signature segment: the exact byte sequence following /{signature}, leading / included, taken from the raw (still percent-encoded) request path. Signatures are emitted unpadded; the canonical padded form (one trailing =) is accepted, but non-canonical spellings (over-padding, variant final characters) are rejected, so each signature has exactly two accepted spellings. In dev mode the literal insecure is accepted (disabled by default in prod).
  • options — ordered, /-separated key:value segments (see §3). Order is normalized before hashing into the cache key, so f:opus/br:96 and br:96/f:opus yield the same variant.
  • source — one of:
    • plain/local://{path} — file below AP_LOCAL_ROOT (URL-escaped path)
    • plain/s3://{bucket}/{key} — S3 object (URL-escaped key)
    • plain/{https-url} — an https:// source, URL-escaped; subject to an allowlist. http:// is refused
    • enc/{base64url(source)} — encoded form, avoids escaping headaches

local://{path} names a path relative to AP_LOCAL_ROOT. The root is deployment configuration and does not participate in identity: the canonical source string is local://{path} with no root in it, so the same relative path is the same variant however a deployment mounted it, and variants survive a root move.

When AP_LOCAL_ROOT is unset, local sources are disabled — the root is the allowlist for disk, so nothing mounted means nothing served.

Confinement is over paths, and uniform. After the source has been decoded exactly once (never before — a check on a half-decoded string proves nothing), the path must be relative, must not climb out of the root with .., and must still resolve inside the root once every symlink on it has been followed. A path that fails any of these is refused, never normalized and retried. Every refusal is 404, the same status as a missing file: §5 has no 403, and a distinct status would turn the root into an existence oracle for the filesystem around it.

Two things sit outside a path-based check, and both are deployment assumptions rather than gaps the proxy can close: a hardlink inside the root pointing at an inode outside it is indistinguishable from an ordinary file, and the window between resolving a path and ffmpeg opening it (TOCTOU) allows a file to be swapped for a symlink. Both require write access to the root. Mount the root read-only and do not let untrusted code write into it.

Paths are bounded before resolution: at most 64 components and 1024 bytes, refused as 404. The bound is a denial-of-service control — the confinement primitive is superlinear in component count.

Metadata comes from the filesystem: regular files only (a directory, FIFO or device is a 404, as is a missing file), size checked against AP_MAX_SRC_BYTES for the 413, and size-plus-mtime as the ETag material behind conditional requests on /info.

s3://{bucket}/{key} names an object; both halves are required, and the key is kept as its raw decoded bytes, since that is what S3 stores. https://{host}/{path} names a URL. Bounds are the stores’ own: 63 bytes of bucket and 1024 of key (S3’s maxima), 2048 bytes of URL and 253 of host (the de-facto URL maximum and DNS’s name limit).

The canonical string for an HTTPS source folds every second spelling of one resource — case, a trailing root dot, an explicit :443, an absent path (rendered /), an empty query, any fragment, and an IP literal’s spelling ([0:0:0:0:0:0:0:1][::1]) — because each survivor would buy one object a second cache key. It preserves the URL’s own percent-encoding and its dot segments, because only the origin knows whether a%2Fb and a/b, or a/../b and b, are the same object. IP literals fold through strict parsing only: the lenient parser reads 01.2.3.4 as 1.2.3.4, and folding that would let one allowlist entry admit two subjects.

http:// and userinfo (https://user:pass@…) are refused at the grammar rather than left to the allowlist, which keeps the allowlist single-axis: host, and nothing else.

AP_SOURCE_ALLOWLIST gates both forms. An entry is an exact name, a trailing-* prefix glob for buckets, a leading-*. label-anchored suffix glob for hosts, or a bare *; a * anywhere else matches nothing. Buckets match case-sensitively and hosts fold case, as S3 and DNS respectively do; an IP-literal host is matched bracketless. Unset accepts S3 sources and refuses HTTPS ones — bucket credentials are already a gate, an outbound fetch has none. A source failing the allowlist is the same 404 as a missing one.

Example:

/aG1hYy.../f:opus/br:96/t:12.5:30/fade:0.5:1/plain/s3://masters/2026/piece-final.wav

→ a 30-second Opus preview at 96 kbps, starting at 12.5 s, with a 0.5 s fade-in and 1 s fade-out.


Endpoint Purpose
GET /{sig}/{options}/{source} Rendered audio variant (the core resource)
GET /{sig}/info/{source} Probe metadata as JSON (no processing options)
GET /{sig}/f:peaks/…/{source} Waveform peaks (a format, not a separate resource — see §3.3)
GET /health Liveness (unsigned) — 200 whatever the load; a busy proxy is a working proxy
GET /ready Readiness (unsigned) — 200 while the node should receive new work, 503 once queue depth reaches AP_READY_QUEUE_THRESHOLD, recovering at half of it. Body: {"status": "ready"|"not_ready", "queued": n, "threshold": n}
GET /metrics Prometheus metrics (unsigned, bind-address-restricted) — see below
GET /hls/{sig}/{options}/{source}/index.m3u8 Reserved for v2 — segmented streaming
GET /hls/{sig}/{options}/{source}/seg-{n}.m4s Reserved for v2

/metrics is on a listener of its own, bound to AP_METRICS_BIND:AP_METRICS_PORT (default 127.0.0.1:9568), and the main listener answers 404 for the path. The restriction is a bind rather than a peer-address check on the shared listener, because a bind is a guarantee the kernel makes: behind a proxy the peer address is the proxy’s, and every check that reads one has to decide how much of X-Forwarded-For to believe. AP_METRICS_BIND takes an address literal and refuses a hostname — the access control of an unsigned endpoint must not depend on DNS. The response is text/plain; version=0.0.4 and no-store; every label value is a bounded enum, so nothing a client sends can grow a scraper’s series count.

/ready’s 503 is a verdict, not an error, and three consequences follow from that. Its body is {"status", "queued", "threshold"} rather than the {"error", "message"} envelope of §5 — a client generated from the error table must not expect that shape here. It carries no Retry-After, unlike the 429 that backpressure produces: a balancer reading this reroutes to another node immediately rather than waiting, so the header would be advice nobody acts on. And it reports live capacity numbers to an unsigned caller, which is deliberate — they are what make a failing probe diagnosable — but it does mean /ready is the one unsigned endpoint that discloses current load, and an operator who cares can strip the body at the edge without affecting the status.

No write endpoints in v1: variant write-back to S3 is a side effect of a GET render, not a client-facing API. Methods other than GET answer 404, everywhere: the signed space is GET-only, and a 405 would confirm a route’s shape without telling a client anything useful.


Option Values Notes
f mp3 opus ogg aac m4a flac wav peaks aac = ADTS stream (streamable); m4a = fragmented MP4, cut on duration (-movflags empty_moov+default_base_moof -frag_duration 1000000) — frag_keyframe cuts at video keyframes, and audio has none, so it yields one fragment flushed at EOF; default mp3
br integer, kbps CBR/ABR bitrate for lossy formats
q codec-specific number VBR quality; mutually exclusive with br. The codec’s own scale and range: mp3 0–9, ogg −1–10, aac/m4a 0.1–2, opus 0–10, flac 0–12 (the last two are compression_level). Out of range is a 422 — f:flac/q:13 is rejected by ffmpeg itself
sr Hz (44100, 48000, …) Resample; default: source rate, capped at 48 kHz for lossy
ch 1 | 2 Downmix (defensible defaults: >2ch → 2)
bd 16 | 24 | 32f Bit depth, lossless formats only; default: the source’s depth, as sr defaults to its rate. 32f is wav-only (flac encodes integers)

Audio only, enforced rather than implied. Every format above is audio, and so is every input this proxy accepts: a source carrying a genuine video stream is refused with 415, by an ffprobe gate that runs before any render starts. It is a reject, not a strip — extracting the audio track from arbitrary video would make this a free transcoding service at video’s cost profile and video’s CVE exposure, which is a different product. Embedded cover art (an attached_pic stream, which virtually every tagged mp3, flac and m4a carries) is metadata, not video, and renders normally.

Two things hold underneath that gate, for every render and independently of it. The argv disables non-audio streams (-vn -sn -dn), and ffmpeg runs under a -protocol_whitelist derived from the resolved source’s type: file for a local source, https,tls,tcp for a remote one (plus http only where a plaintext development endpoint is configured). The two sets are disjoint, so a local render cannot reach the network, a remote one cannot read the filesystem, and neither can reach concat:, subfile: or any other pivot. No processing option and no environment variable widens either — that is the point of deriving it from the source.

One consequence of the remote set carrying no http: an HTTPS origin that answers with a redirect to a cleartext URL fails to open, as a source error. That is the policy working rather than a gap — http:// is not a scheme this proxy serves in the first place (§1) — but it is worth knowing before debugging a source whose origin downgrades on redirect.

The gate does not run on a cache hit, because a hit is immutable bytes that already passed it, and it does not run on HEAD, which spawns no subprocess at all. Both are stated here because they are the two ways a 200 can answer a URL whose GET on a miss would be a 415.

Option Values Notes
t start[:duration] seconds, decimals ok Trim. t:30 = from 30 s to end; t:30:15 = 15 s from 30 s
fade in[:out] seconds Applied inside the trimmed region
gain dB, signed Static gain
norm ebu[:I[:TP[:LRA]]] Loudness normalization via loudnorm; default -16:-1.5:11. Note: proper two-pass loudnorm requires a full first pass — v1 does single-pass (good enough for previews), flag in docs
Option Values Notes
pts integer Number of min/max pairs (default 800)
pk_fmt json | dat JSON or compact binary; both are audiowaveform’s formats (default json)
ch 1 | 2 Default 1, unlike every other format — peaks downmix rather than follow the source. ch:2 gives per-channel pairs. The default is materialized into the cache key

Peaks respect t, ch and fade, ignore encoding options. Cheap enough to render eagerly alongside any audio variant later, but v1 renders on request.

Both serializations carry the same numbers: version 2, channels, sample_rate, samples_per_pixel, bits (always 16), length (always exactly pts), and length × 2 × channels signed 16-bit values — a minimum and a maximum per pixel per channel, interleaved. pk_fmt:json is application/json with those field names; pk_fmt:dat is application/octet-stream, a little-endian header of version, flags, sample rate, samples-per-pixel, length and channel count, then the values as int16.

Peaks are a format, so they participate in the cache key, the write-back and the HIT redirect exactly as audio variants do.

Option Values Notes
dl filename (URL-escaped) Sets Content-Disposition: attachment
cb opaque string Cache-buster, participates in cache key

{
"format": "wav",
"duration": 3612.44,
"sample_rate": 96000,
"channels": 4,
"bit_depth": 24,
"bitrate": 9216000,
"size": 4161273856,
"tags": { "title": "", "artist": "" }
}

Derived from ffprobe -show_format -show_streams, filtered to the fields above — the first audio stream is what the object describes. Every stream is requested rather than just a:0 because the audio-only policy applies here too, and a gate cannot refuse a stream ffprobe was told to hide. info takes no processing options: any option segment alongside it is a 422.

The mapping is explicit rather than a passthrough, because ffprobe’s output is verbose, version-dependent and inconsistent across containers while this object is none of those.

Field Source Rule
format format.format_name, refined by stream.codec_name Whichever name in ffprobe’s comma-separated list is a §3.1 token, so the mov,mp4,m4a,3gp,3g2,mj2 family is m4a and Ogg is opus or ogg by codec. Membership, not the whole string: the list’s contents and order are ffprobe’s business and change between versions. A container §3.1 has no token for falls through to the first name (matroska,webmmatroska) — format describes the source, and a source may be in a container this proxy cannot emit
duration format.duration, else stream.duration Seconds, float
sample_rate, channels the audio stream Integers
bit_depth stream.bits_per_raw_sample, else stream.bits_per_sample Never sample_fmt: a lossy stream decodes to a float format and has no depth to report
bitrate format.bit_rate, else stream.bit_rate Integer, bits per second
size the storage backend’s stat, else format.size The store is authoritative for the object
tags format.tags String-valued entries only, keys lowercased, capped in count and length. Arbitrary bytes from a file the operator may not control

Each fallback is tried on the extracted value: ffprobe writes "N/A" rather than omitting a field it cannot answer, so taking the first key that is present would stop at the "N/A" and never reach the section that knows.

A field ffprobe cannot answer is omitted, never null and never a zero standing in for “unknown”. "bit_depth" in info is therefore a true answer for every source. A source with no audio stream at all — a video-only MP4, a text file — is a 415, not an object with everything missing.

The audio-only policy (§3.1) covers this endpoint as well: a source carrying a genuine video stream is 415 video_source here exactly as it is on a render, so the policy has no endpoint-shaped exception and /info is not a metadata-extraction service for arbitrary video. Cover art is not video, so a tagged mp3 with artwork is described normally. The check costs nothing extra — it reads the probe the endpoint had already run.

ETag is hash(canonical-source ‖ source ETag): it changes exactly when the object does, and If-None-Match is answered 304 after the source stat and before the probe, which is the expensive half.

Cache-Control is public, max-age=3600not immutable, unlike a variant’s. A variant’s URL describes its bytes completely, so it can never become stale; /info describes a mutable source, and the same URL answers differently after a re-upload. immutable there would tell caches never to revalidate a document that has no other way of being corrected. An hour plus a cheap 304 is the aggressive caching this endpoint can honestly offer. A backend with no ETag material to give gets public, max-age=60 and no validator, since nothing could correct it early.

A HEAD answers what the check chain determines — 401, 404, 413 and the caching headers — and stops there, so it never spawns a probe and therefore answers 200 where a GET would answer 415. That is the same discipline HEAD follows on the render endpoint, for the same reason: diagnosing 415 is the work HEAD exists to skip.

AP_MAX_SRC_BYTES does not apply to /info, unlike every other signed request: a probe reads container headers and never decodes, so a source too large to render still costs a probe nothing to describe — and the client most in need of the endpoint is precisely the one holding a long source it means to ask a trimmed preview of.

A probe reads container headers and stops; it never decodes. It therefore does not take an AP_MAX_CONCURRENCY slot — that cap exists to bound encoders pinning cores, and queueing probes behind renders would make the endpoint a client calls before it knows what to request the slowest thing in the proxy. AP_PROBE_TIMEOUT is what bounds one probe’s lifetime, and it is separate from and shorter than AP_RENDER_TIMEOUT.

What a probe does take is a slot in a pool of its own, AP_MAX_PROBE_CONCURRENCY, which defaults to four times AP_MAX_CONCURRENCY. There are two subprocess pools, and this is the second: renders are rationed by one counter and probes by another, so a probe never waits behind an encoder and neither pool is unbounded. A probe that finds no slot is answered 429 with Retry-After, the same shape a full render queue produces. There is no queue behind the probe ceiling — a probe is tens of milliseconds, so telling a client to come back is worth more than making it wait.

Probes coalesce on the source, not the variant: concurrent requests reading one source share one ffprobe, whether they are two /info calls, two different renditions of one file, or an /info alongside a render. Only a request that actually spawns a probe takes a slot, so the ceiling counts ffprobe processes rather than requests — exactly as AP_MAX_CONCURRENCY counts encoders rather than subscribers. A request served from the variant store, or answered 304, probes not at all and takes nothing from either pool.


  • 200 OK, Transfer-Encoding: chunked, no Content-Length, no Accept-Ranges.
  • Bytes stream as the encoder produces them; simultaneously teed to the variant bucket (s3://{variant-bucket}/{cache-key}).
  • Concurrent requests for the same cache key coalesce: one render, all clients subscribe to its chunk stream.
  • Header: X-Audio-Proxy: MISS (or COALESCED).

Checked before coalescing and before the source is stat’d — a stored variant is immutable bytes that owe nothing to a source which may since have been deleted. Header: X-Audio-Proxy: HIT, in both modes.

Redirect mode (AP_SERVE_MODE=redirect, the default): 302 to a presigned URL for the variant object, valid for AP_PRESIGN_TTL seconds → S3/CDN serves Accept-Ranges, 206 and Content-Length natively and the proxy leaves the hot path. The redirect itself carries Cache-Control: no-store: its Location is a credential with an expiry, and a cached 302 hands out URLs that have already expired. The variant’s own Content-Type and Cache-Control come from the store, which holds the ones the write-back saved — so a followed redirect delivers what a proxied HIT would have sent.

Proxy mode (AP_SERVE_MODE=proxy): the proxy serves the object itself — 200 with Content-Length and Accept-Ranges: bytes, relayed as it is read, so a declared length and progressive delivery are both true and the whole object is never resident. A Range is answered with 206 and Content-Range. A syntactically valid range no byte can satisfy is a 416 with Content-Range: bytes */{size} and Cache-Control: no-store — its body depends on a request header, and nothing here sends Vary: Range. Multi-range specs, non-bytes units and malformed values are ignored and answered with the whole variant, which RFC 9110 §14.2 permits; there is no multipart/byteranges response.

The same URL is framed differently depending on what is cached, and clients must not assume one framing for a given URL:

MISS / COALESCED HIT
Framing Transfer-Encoding: chunked Content-Length
Accept-Ranges absent bytes
Range ignored 206 / 416

Both begin delivering before the variant is complete or fully read. What a client observes is a property of the cache state, never of the configured backend or serve mode: the same signed URL against a file:// deployment and an s3:// one delivers the same bytes with the same Content-Type, ETag and Cache-Control, and is range-capable on a HIT either way. Backends differ in where the bytes come from, never in what a client must implement.

Content-Type per format · Cache-Control: public, max-age=31536000, immutable, no-transform (URL encodes the variant, so it is immutable; no-transform because the bytes are the product and must survive edge features that recompress or mangle bodies) · ETag = cache key, sent quoted, since RFC 9110 defines an entity-tag as a quoted-string and a bare token is not one.

Every response, success or error, carries an explicit Cache-Control — no CDN negative-caching default ever decides retention:

  • Errors: 404/413/415max-age=10 (verdicts about the current source bytes; a re-upload changes them), 401/422max-age=60 (pure functions of the URL; only a deploy changes them), 416/429/5xxno-store (transient, or — for 416 — dependent on a request header no Vary declares). The 502 row inherits that rather than inventing it, and the inheritance is the point: a store outage is exactly the failure that must not be cached, since the retry it suppresses is the one that would have worked. /health, /ready and the unmatched-route 404 state theirs too (no-store, no-store and max-age=10).
  • Conditional requests: an If-None-Match matching the URL-derived ETag answers 304 with ETag and Cache-Control, no body, no render, no storage access. Placed after signature verification — never an existence oracle for unsigned probes.
  • HEAD on signed endpoints answers the status and headers a GET would, through the full check chain including the source stat, with an empty body and no render subprocess. Errors as GET, bodiless. No X-Audio-Proxy: that header reports a render’s outcome, and none ran. It deliberately does not consult the variant cache, so it reports the render path’s framing even where a GET would answer a HIT’s, or a 302; making HEAD the one request whose answer depends on cache state would invite clients to build on exactly what the framing contract above tells them not to.
  • Range on a MISS is ignored: the full 200 chunked stream, no Accept-Ranges, no 206/416 (RFC 9110 §14.2 permits ignoring Range). 206 semantics belong to cached variants — served by the proxy or by storage, per the serve mode.
Status Meaning
401 Invalid/missing signature
404 Source not found / not readable
413 Source exceeds AP_MAX_SRC_BYTES
415 Source not decodable (undecodable_source), or contains video (video_source — see §3.1)
416 Range unsatisfiable against a cached variant (proxy mode only)
422 Invalid or conflicting options
429 No slot in one of the two pools: either no render slot (the wait queue was full, or this request waited in it longer than AP_RENDER_TIMEOUT without reaching the front) or no probe slot (AP_MAX_PROBE_CONCURRENCY reached, §4.3 — that pool has no queue). Retry-After set either way, and which pool it was is deliberately not reported
500 The render failed for a reason that is not the client’s: no encoder, no space, a diagnostic the classifier does not recognise
500 The storage backend a source names is misconfigured: no credentials, or a store that answers a redirect because the configured region or endpoint is not the object’s. No client action can resolve either
502 The storage backend could not be reached: a transport failure, or a 5xx from the store itself
504 A render started and then exceeded AP_RENDER_TIMEOUT

The two 415s are separate error values on one status because they are different verdicts: undecodable_source says the bytes could not be read, video_source says they were read and refused. A client told “not decodable” about a file every player opens would go looking for a corrupt upload.

That makes 415 the one status that discriminates between contents, which is a deliberate exception to the blindness the 404 row enforces — and it is sound only because of where it sits. Signature verification precedes it, so the answer is available exclusively to someone the operator already handed a URL for this exact source; and 415 at all (rather than 404) already discloses that the source exists and is readable. Splitting “missing” from “unauthorized” was rejected for the opposite reason: those answers are reachable by anyone who can guess a path.

probe_failed (500) and probe_timeout (504) are two rows of their own rather than a reuse of the render pair. The bodies name the limit an operator would raise, and AP_RENDER_TIMEOUT is not that limit for a probe; an error naming the wrong variable sends them to the wrong place. Both are reachable on either endpoint: /info is a probe, and a render probes too before it starts (§3.1’s audio-only gate), so a render URL answering 504 probe_timeout means the gate’s probe ran out of AP_PROBE_TIMEOUT before any encoding began.

Every other row is something the client got wrong, which is why 500 and 502 are worth stating rather than leaving to the adapter: a render can fail with none of them true, and answering a plausible 4xx would tell a client to stop retrying something that might well work next time.

502 is deliberately not the 404. That row is blind on purpose, so no source failure can be used to probe what exists — but an outage is not a source failure. It says nothing about whether the object is there, and routing it into the 404 tells a client its object is gone when the store is merely unreachable: a deletion reported that did not happen, then cached for the ten seconds that row carries, suppressing the retry that would have worked. A store that answers 5xx or does not answer at all is 502; a store that says “no such object” — and one that refuses the credential, which stays indistinguishable from that by design — is the 404.

429 and 504 divide on whether a render ever ran, not on how long the client waited — both can take the full AP_RENDER_TIMEOUT. A request that spent that budget queued for a slot has nothing to report about a render, so it is told to come back, with the same Retry-After a full queue would have given it up front. 504 means a render held a slot and then went silent. Answering 504 for a wait would name a timeout that never happened, and would tell a client its variant is too expensive to encode when the truth is that the box is busy.

Mid-stream render failure after 200 is signaled by abnormal termination of the chunked stream (nothing better exists over plain HTTP; one more argument for HLS in v2).


Var Purpose
AP_KEY, AP_SALT Hex-encoded HMAC key/salt; key must decode to ≥ 32 bytes (generate: openssl rand -hex 32)
AP_ALLOW_INSECURE Accept unsigned URLs (dev only)
AP_SOURCE_ALLOWLIST Comma-separated bucket/host patterns; unset accepts every bucket and refuses every host (§1)
AP_LOCAL_ROOT Root directory for local:// sources; unset = local sources disabled. Must exist at boot
AP_VARIANT_STORE Variant store, scheme-tagged: file:///path or s3://bucket; unset = no cache, always render. Either scheme is proved writable at boot by performing a write and removing it — a directory that does not exist or refuses writes, or a bucket that is unreachable or refuses one, fails the container. s3:// names a bucket only — a key prefix, a port or embedded credentials are refused rather than ignored
AP_MAX_CONCURRENCY Max simultaneous ffmpeg processes (default: CPU count). Coalesced requests share one, so this counts renders and not requests
AP_MAX_PROBE_CONCURRENCY Max simultaneous ffprobe processes (default: 4 × AP_MAX_CONCURRENCY). A pool of its own so a probe never waits behind an encoder; concurrent requests for one source share a probe, so this counts probes and not requests. No queue — overflow is 429 — see §4.3
AP_QUEUE_SIZE Requests that may wait for a render slot before the next is answered 429
AP_READY_QUEUE_THRESHOLD Queue depth at which /ready answers 503, recovering at half of it rounded down (default: half AP_QUEUE_SIZE rounded down, minimum 1; 0 disables, as does AP_QUEUE_SIZE=0). Refused above AP_QUEUE_SIZE, where it could never trip
AP_MAX_SRC_BYTES, AP_RENDER_TIMEOUT Abuse limits. AP_MAX_SRC_BYTES bounds the source, checked before a render starts — the 413 above
AP_MAX_VARIANT_BYTES Bytes one render may retain, defaulting to the effective AP_MAX_SRC_BYTES. Output past it kills the render; the response has already committed to 200, so the outcome is a failed stream rather than a 413
AP_PROBE_TIMEOUT Seconds an /info probe may take before ffprobe is killed and the request answered 504 (default: 10). Separate from AP_RENDER_TIMEOUT because a probe reads headers rather than decoding — see §4.3
AP_SERVE_MODE redirect | proxy
AP_PRESIGN_TTL Seconds a HIT’s presigned URL stays valid (default: 300); redirect mode only
AP_LOG_LEVEL debug | info | warning | error (default: info)
AP_METRICS_BIND, AP_METRICS_PORT Where the /metrics listener binds (default: 127.0.0.1, 9568). The endpoint is unsigned, so the bind is its access control; AP_METRICS_BIND is an address literal and a hostname is refused at boot, as is a port equal to the listener’s
AP_S3_ENDPOINT Origin URL of an S3-compatible store (http://minio:9000); unset = AWS proper. An origin and nothing else — a path, query, fragment or embedded credentials are refused at boot
AP_S3_ADDRESSING virtual | path: whether a request names its bucket in the host or in the path. Default: virtual with no AP_S3_ENDPOINT, path with one. Signed requests and presigned URLs always use the same style, since the host is inside the signature
AP_S3_CA_BUNDLE PEM bundle to verify the store’s certificate against, replacing the system trust store; a readable file at boot. There is no way to disable verification

S3 credentials are the exception to the AP_ rule and keep the standard AWS names — AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN and AWS_REGION (or AWS_DEFAULT_REGION) — validated as a group at boot: all of key, secret and region, or none. There is no IMDS or STS lookup, so credentials come from the environment or not at all.

Redirect serving is a capability of the store’s backend: redirect answers a HIT with a 302 to a presigned variant URL, which only a backend that can presign (s3://) can produce. AP_SERVE_MODE=redirect against a store without that capability (file://) is refused at boot, with an error naming both variables — never per request.


HLS/DASH output · two-pass loudness · multi-source stitching/concat · upload endpoints · per-client auth beyond URL signing · webhooks on render completion.