Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 

README.md

StreamForge local deployment

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.

Configure

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/.env

The 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.

Validate and start

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 ps

Published 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.

Storage and provenance

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 (default 86400)
  • STREAMFORGE_RECORDING_RETENTION_BYTES (default 10737418240)
  • STREAMFORGE_RECORDING_PRUNE_INTERVAL_SECONDS (default 300)

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.

Resource and admission limits

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/. $recordingExport

Choose 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 down

Do 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.