This deployment runs the StreamForge control plane, its pinned ZLMediaKit media
service, and a network-isolated recording-retention worker as one isolated
Compose project. ZLMediaKit is built from
lite-tx/ZLMediaKit@9f90548a67df0a9f1425a1c884186bf48eb7be21; no floating
upstream image is used.
From the repository root, copy the example environment file and replace all
six token/secret placeholders with independent random values of at least 16
characters using only A-Z, a-z, 0-9, ., _, ~, and -:
Copy-Item deploy/.env.example deploy/.envThe default bind address is 127.0.0.1. Set STREAMFORGE_BIND_ADDRESS=0.0.0.0
only when other machines must publish or play media and the host firewall has
been configured.
The default is one demo channel. Its OBS adapter uses the channel-scoped
STREAMFORGE_PRODUCER_TOKEN; the global operator token is reserved for
administrative API access. For multiple channels, set
STREAMFORGE_CHANNELS_JSON to a one-line JSON object following the shape in
.env.example. When that object is present, the single-channel identity and
publish/play/producer fields are ignored. Never reuse one role's credential for
another role. The bundled media service disables ZLMediaKit virtual hosts, so
every channel must use __defaultVhost__; configuration rejects other values
instead of silently routing them to the wrong channel.
The same deployment accepts RTMP as its only primary ingest schema. RTSP remains
a playback endpoint; it is not a configurable primary source in this release.
The control plane requires both schema=rtmp and originTypeStr=rtmp_push, so
publishing with RTSP cannot become LIVE through ZLMediaKit's derived RTMP muxer.
Compose passes one ZLM_MEDIA_SERVER_ID value to both the media and control-plane
containers. It must be a non-empty URL-safe value of at most 128 characters and
must not contain configured credential material. Both services reject
ZLMediaKit's published default API secret; the media entrypoint also checks the
ID against the ZLM and hook secrets it receives, while the control plane checks
it against every configured role credential. Credentials, ZLM_BASE_URL, and
ZLM_MEDIA_SERVER_ID are validated as supplied, so surrounding whitespace is
rejected rather than silently normalized.
ZLM_BASE_URL defaults to http://media:8080. An override may include a reviewed
reverse-proxy path prefix, which is preserved for calls such as
/prefix/index/api/getMediaList. The value is limited to 2,048 characters and
must be an absolute HTTP(S) URL with a valid host and optional valid port, with
no user information, query, fragment, whitespace, or C0 control characters. Raw,
normalized, and recursively percent-decoded URL views are checked against all
configured credentials before startup.
docker compose --env-file deploy/.env -f deploy/compose/compose.yaml config --quiet
docker compose --env-file deploy/.env -f deploy/compose/compose.yaml build
docker compose --env-file deploy/.env -f deploy/compose/compose.yaml up -d
docker compose --env-file deploy/.env -f deploy/compose/compose.yaml psPublished endpoints are:
- control API:
http://127.0.0.1:8081 - HTTP/HTTP-FLV and ZLMediaKit REST:
http://127.0.0.1:8080 - RTMP ingest:
rtmp://127.0.0.1:1935 - RTSP playback:
rtsp://127.0.0.1:8554
The control plane reaches ZLMediaKit over the internal product bridge. A
separate project-scoped ingress bridge permits the explicitly published host
ports while the internal network remains isolated. Publishing and playback are
authorized by the control plane through non-empty on_publish and on_play
hooks. ZLMediaKit has no hook-header setting, so the hook token is placed in the
Compose-DNS hook URL; the control plane disables access logging and compares it
in constant time. The pinned media patch assigns mediaServerId and
hook_index only on a hook's first send. A recursive retry reuses the exact
request identity, so the control plane can deduplicate a request it committed
when the response was lost. The control-plane receipt is not a digest of the raw
request: it hashes the hook name plus canonical recursively redacted JSON. It is
TTL-bound and the receipt table keeps only its newest configured N entries, so
bounded storage takes precedence over remembering every retry forever.
mediaServerId contributes to the receipt but is not an authentication factor;
the private hook token remains the authorization boundary.
MP4 files persist in the project-scoped Docker volume streamforge-recordings
by default. The media image owns its seed /data/recordings directory as the
unprivileged runtime UID/GID 10001, so Docker initializes a new named volume with
writable ownership without a privileged initializer or a host-path permission
change. Control-plane state and media HTTP data use the named volumes
streamforge-control-data and streamforge-media-www by default. A different
COMPOSE_PROJECT_NAME gives each deployment its own three volumes.
ZLM_CONTINUE_PUSH_MS defaults to 15000; shorter recovery fixtures can override
it without changing the pinned media configuration template.
ZLMediaKit rolls MP4 output into five-minute segments (mp4_max_second=300). It
writes an active segment with a dot-prefixed filename and atomically renames it
to a non-hidden lowercase *.mp4 only after closing it. The independent
recording-pruner container scans only those finalized files. By default it
deletes files older than 24 hours, then deletes the oldest remaining finalized
files until their aggregate size is at most 10 GiB. Either threshold can
therefore expire a recording first. It never counts or deletes a dot-prefixed
file or a file below a dot-prefixed directory. Configure the policy with:
STREAMFORGE_RECORDING_RETENTION_SECONDS(default86400)STREAMFORGE_RECORDING_RETENTION_BYTES(default10737418240)STREAMFORGE_RECORDING_PRUNE_INTERVAL_SECONDS(default300)
The pruner has no network namespace and mounts only the recording volume, read-write. It shares media's unprivileged UID/GID 10001, drops all Linux capabilities, disables privilege escalation, and uses a read-only root filesystem. Its Python base is pinned by registry digest.
An abrupt media-process exit can leave a dot-prefixed, unplayable temporary MP4.
The media entrypoint removes those orphan files before it starts a new
MediaServer writer, logging only the removed count and never a recording path.
This recovery contract assumes the Compose deployment's single media service
is the only writer for its project-scoped recording volume; do not attach a
second MediaServer container to that volume.
All three long-running services run without Linux capabilities, with privilege
escalation disabled and a read-only root filesystem. Only the control-plane data
volume, the two media data volumes, the pruner's shared recording volume, and
the explicit media/control /tmp tmpfs mounts remain writable.
Compose applies cgroup ceilings by default. The control plane uses a 256 MiB
memory limit (64 MiB reservation), 1 CPU, and 128 PIDs. The media service uses a
1 GiB memory limit (256 MiB reservation), 2 CPUs, 512 PIDs, and a 16,384 soft and
hard nofile limit. The pruner has its own smaller 64 MiB/32 MiB, 0.25 CPU, and
32 PID defaults. Every value can be overridden through the corresponding
STREAMFORGE_{CONTROL,MEDIA,PRUNER}_* variables in .env.example; validate the
result with docker compose ... config --quiet before deployment.
The control-plane defaults retain at most 10,000 events database-wide. Each
channel can retain up to 10,000 keyed operations during the 24-hour idempotency
window; requests without a key keep only the latest operation per action. Each
channel also keeps at most 10,000 terminal commands that are no longer
referenced by a retained operation. It admits no more than 64 pending commands
per channel, 65,536 bytes per request body, and 128 concurrent HTTP requests.
The body limit cannot be set below 32,768 bytes because that is the product
floor that safely contains the OBS producer's largest valid event envelope.
Override these with
STREAMFORGE_EVENT_RETENTION_COUNT, STREAMFORGE_OPERATION_RETENTION_COUNT,
STREAMFORGE_COMMAND_RETENTION_COUNT,
STREAMFORGE_MAX_PENDING_COMMANDS_PER_CHANNEL,
STREAMFORGE_MAX_REQUEST_BODY_BYTES, and
STREAMFORGE_MAX_CONCURRENT_REQUESTS. Idempotency keys expire after 24 hours by
default; tune that window with STREAMFORGE_IDEMPOTENCY_TTL_SECONDS. The service
also applies a five-second total request-body receive deadline, configurable with
STREAMFORGE_REQUEST_BODY_TIMEOUT_SECONDS. It validates documented bounds at
startup. Hook receipts have an independent newest-N table cap equal to
STREAMFORGE_EVENT_RETENTION_COUNT in addition to their deduplication TTL.
Docker stdout/stderr uses the local json-file driver with three files of at
most 10 MiB each per service. Override the project-wide rotation limits with
STREAMFORGE_LOG_MAX_SIZE and STREAMFORGE_LOG_MAX_FILES; the same bounded
policy applies to media, control-plane, and recording-pruner logs. Rotation does
not change the log redaction checks described above.
To export recordings, copy them into a newly created directory dedicated to
that export. docker compose cp does not require granting the container access
to an arbitrary host path and does not change host-directory ownership:
$recordingExport = Join-Path (Get-Location) 'artifacts/recording-export'
New-Item -ItemType Directory -Path $recordingExport -ErrorAction Stop | Out-Null
docker compose --env-file deploy/.env -f deploy/compose/compose.yaml cp media:/data/recordings/. $recordingExportChoose a fresh or otherwise dedicated output directory so existing files are not confused with the current export.
The media image records its exact source revision at
/opt/zlm/ZLM_SOURCE_REVISION and includes upstream license material under
/licenses. It preserves ZLMediaKit's required title, Server, and
User-Agent identifiers.
Verify the live binary revision without putting the REST secret in a URL:
docker compose --env-file deploy/.env -f deploy/compose/compose.yaml exec media cat /opt/zlm/ZLM_SOURCE_REVISION
$secret = (Get-Content deploy/.env | Where-Object { $_ -like 'ZLM_API_SECRET=*' }) -replace '^ZLM_API_SECRET=', ''
Invoke-RestMethod -Method Post -Uri http://127.0.0.1:8080/index/api/version -Body @{ secret = $secret }Stop only this deployment with:
docker compose --env-file deploy/.env -f deploy/compose/compose.yaml downDo not add --volumes unless persistent control state, media HTTP data, and
all recordings in the project recording volume are intentionally being
discarded. docker compose down --volumes deletes the recording volume; export
anything needed first.