- Rust 58.8%
- Shell 28.5%
- Dockerfile 12.7%
Forgejo's generic registry rejects overwriting an existing version (HTTP 409). The relay publish.sh already deletes first; mirror that in the ffmpeg workflow so re-runs at the same FFMPEG_VERSION succeed. |
||
|---|---|---|
| .forgejo/workflows | ||
| relay | ||
| rtmp | ||
| .env.json.example | ||
| .gitignore | ||
| docker-compose.yml | ||
| generate-config.sh | ||
| README.md | ||
slay-restream
Combined RTMP multistream rebroadcast server and optional audio relay for SLAY Radio. Accepts a single incoming RTMP stream, transcodes the video with NVIDIA NVENC, and pushes the result simultaneously to any number of destinations (Twitch, Kick, YouTube, ...) -- like restream.io, but on your own hardware -- with an optional Shoutcast/Icecast2 audio relay.
This repository consolidates two formerly separate projects:
rtmp/- RTMP ingest, stream-key validation, and multistream push (nginx-rtmp + NVENC ffmpeg). Requires an NVIDIA GPU.relay/- optional audio relay: pulls the RTMP feed, encodes MP3, and sources it to a Shoutcast/Icecast2 server. Off by default.
Architecture
OBS / encoder
|
| RTMP :1935/live
v
nginx-rtmp (application: live)
| validates stream key via on_publish callback
| copies raw stream to :1935/relay (consumed by the audio relay)
| transcodes video with ffmpeg + h264_nvenc
|
ffmpeg (-f tee)
|-> fifo -> rtmp:// Twitch, etc. (plain RTMP)
|-> fifo -> rtmps:// Kick, etc. (TLS via ffmpeg's OpenSSL)
+-> fifo -> rtmp://127.0.0.1:1935/yt_relay (local nginx-rtmp app)
|
nginx-rtmp push |-> rtmp:// YouTube (channel 1)
+-> rtmp:// YouTube (channel 2, optional)
rtmp/nginx/nginx.conf is generated from the root .env.json by
generate-config.sh and volume-mounted into the container at runtime, so
changing destinations requires no rebuild.
Prerequisites
- Docker Engine 24+ with the Compose plugin (
docker compose) jq(used bygenerate-config.sh)- NVIDIA Container Toolkit, so NVENC encoding works inside the container. The container uses the host's NVIDIA driver -- no CUDA libraries are bundled.
# Full guide: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker
# Verify
docker run --rm --gpus all nvidia/cuda:13.0.3-base-ubuntu24.04 nvidia-smi
Configure
All configuration lives in a single repo-root .env.json:
cp .env.json.example .env.json
$EDITOR .env.json
./generate-config.sh # renders rtmp/nginx/nginx.conf from .env.json
Re-run ./generate-config.sh whenever destinations change.
Destinations
Each entry in destinations needs a friendly_name, a key, and a
destination URL. Both rtmp:// and rtmps:// are supported. YouTube
destinations are identified by their URL (youtube.com) and routed through the
yt_relay application instead of the tee muxer; multiple YouTube entries are
supported (each becomes an additional push directive).
{
"destinations": [
{ "friendly_name": "Twitch", "key": "live_xxxx", "destination": "rtmp://live-lhr.twitch.tv/app/" },
{ "friendly_name": "Kick", "key": "sk_us-west-2_xxxx", "destination": "rtmps://fa723fc1b171.global-contribute.live-video.net/app/" },
{ "friendly_name": "YouTube", "key": "xxxx-xxxx", "destination": "rtmp://x.rtmp.youtube.com/live2/" }
],
"appconfig": { "on_publish_url": "https://www.example.org/rtmp_check_key.php" }
}
appconfig.on_publish_url is the stream-key validation endpoint nginx-rtmp calls
on each publish.
Relay (optional)
Add a relay section to enable the audio relay. Only host and password are
required; everything else has a default.
| Field | Required | Default | Description |
|---|---|---|---|
host |
Yes | -- | Audio server hostname or IP |
password |
Yes | -- | Source password |
server_type |
No | shoutcast |
shoutcast or icecast |
port |
No | 8000 |
Source port (Shoutcast v1 is typically base port + 1) |
mount |
No | /stream.mp3 |
Icecast2 mountpoint (ignored for Shoutcast) |
icy_name |
No | RTMP Relay |
Station name in ICY headers |
icy_genre |
No | Various |
Genre in ICY headers |
icy_url |
No | http://localhost |
Station URL in ICY headers |
bitrate |
No | 128 |
MP3 bitrate in kbps |
rtmp_url |
No | rtmp://rtmp:1935/relay/stream |
RTMP source |
rtmp_stats_url |
No | http://rtmp:8080/stats |
Stats page polled for liveness |
{
"relay": {
"server_type": "shoutcast",
"host": "shoutcast.example.org",
"port": 8801,
"password": "source_password_here",
"icy_name": "SLAY Radio",
"icy_genre": "c64 remixes",
"icy_url": "http://shoutcast.example.org:8800",
"bitrate": 320
}
}
rtmp_url/rtmp_stats_url default to the rtmp service on the shared compose
network, so no extra wiring is needed. Missing or malformed config is reported on
stdout at startup (e.g. missing field 'host') and the relay exits non-zero.
Run
RTMP server only (default):
docker compose up -d --build
With the audio relay as well:
docker compose --profile relay up -d --build
Apply destination changes without a rebuild:
./generate-config.sh && docker compose restart rtmp
RTMP server internals
Why the YouTube detour? ffmpeg's -f tee muxer is incompatible with
YouTube's RTMP ingest -- the stream connects and shows "Excellent" quality but
never transitions from "Preparing stream" to Live. Routing through a local
nginx-rtmp application and using nginx's own push directive sidesteps this.
Nginx config
rtmp/nginx/nginx.conf.template is the editable base config. generate-config.sh
splices content into two markers and writes the result to rtmp/nginx/nginx.conf:
#=#=# DESTINATIONS WILL BE PLACED HERE #=#=#- inside the ffmpegexeccommand in theliveapplication; receives a-f tee "..."line with all non-YouTube destinations (each wrapped in afifomuxer so a failing destination retries independently) plus the localyt_relayentry.#=#=# YOUTUBE PUSHES WILL BE PLACED HERE #=#=#- inside theyt_relayapplication; receives onepush <url>;line per YouTube destination.
Edit the template directly to change ffmpeg parameters, the stream-key validation URL, chunk size, or anything else -- no recompilation needed.
ffmpeg transcoding parameters
-c:v h264_nvenc NVIDIA NVENC hardware H.264 encoder
-preset p4 Encoding preset (p1=fastest ... p7=slowest, p4 ~ medium)
-profile:v high H.264 High profile
-pix_fmt yuv420p YUV 4:2:0 -- maximum player compatibility
-bf 2 Up to 2 B-frames between keyframes
-b:v 8000k Target bitrate: 8 Mbps
-minrate 8000k \
-maxrate 9000k Near-CBR rate control (suitable for live streaming)
-bufsize 4000k /
-c:a copy Audio passed through without re-encoding
-g 30 Keyframe every 30 frames
Stats
An HTTP stats page is served on port 8080 (/stats) inside the container and
mapped to the host. The audio relay polls it to detect a live broadcast.
ffmpeg binary
The ffmpeg binary is built from source with only the features this pipeline needs
(RTMP, RTMPS/TLS, FLV, h264_nvenc, audio copy) and published to the public
package registry at git.c64.org/slayradio-public.
rtmp/docker/Dockerfile.nginx downloads it at build time -- no third-party
static builds involved.
To build it yourself (two-stage: a CUDA devel image compiles ffmpeg, then the
binary is exported from a scratch stage), from the repo root:
docker build --target artifact --output type=local,dest=./dist rtmp/ffmpeg-build/
The resulting binary is at dist/ffmpeg.
The Forgejo Actions workflow at .forgejo/workflows/build-ffmpeg.yml builds and
publishes the ffmpeg-nvenc (Linux) and ffmpeg-windows binaries automatically
on pushes to main that modify rtmp/ffmpeg-build/. It needs a self-hosted
runner with Docker (no GPU -- NVENC headers are compile-time only). Required
repository secrets:
| Secret | Purpose |
|---|---|
REGISTRY_USER |
Username the tokens belong to |
REGISTRY_TOKEN |
package:write on slayradio-public (build-cache image) |
REGISTRY_TOKEN_PUBLIC |
package:write on slayradio-public (publishes the binaries) |
To bump the ffmpeg version, update FFMPEG_VERSION in both
.forgejo/workflows/build-ffmpeg.yml and rtmp/ffmpeg-build/Dockerfile, then
push.
Audio relay internals
- ffmpeg reads the live RTMP stream, strips video, and encodes audio to MP3.
- Once the first audio bytes arrive, the relay connects to the audio server and performs the handshake (Shoutcast v1 or Icecast2).
- ffmpeg's raw MP3 output is piped directly into the server TCP connection.
- If the RTMP stream goes offline or the server connection drops, the relay retries every 5 seconds.
The relay polls the rtmp server's /stats page to detect a live broadcast, then
reads from the fixed relay endpoint that nginx-rtmp pushes every active broadcast
to, regardless of stream key. It reads its config from the root .env.json
(relay section); the compose file mounts that file at /config/.env.json and
points RESTREAM_CONFIG at it.
The relay image is a two-stage Docker build: a rust:1 builder compiles the
binary (no ffmpeg dev libraries needed), and a debian:bookworm-slim runtime
adds the ffmpeg package for a small final image.
Publishing a relay release
The relay binary is published as a prebuilt Linux/amd64 generic package
(slay-restream) on slayradio-public:
- Bump
versioninrelay/Cargo.toml. - Provide a token with
package:writeonslayradio-public, either asREGISTRY_TOKEN_PUBLIC=<token>in the environment or asappconfig.registry_token_publicin the root.env.json. - Run
relay/publish.sh. It builds a release binary and uploads it as both/<version>/slay-restream-linux-amd64and/latest/slay-restream-linux-amd64.
Published URL pattern:
https://git.c64.org/api/packages/slayradio-public/generic/slay-restream/<version>/slay-restream-linux-amd64
Layout
slay-restream/
README.md this file
.env.json(.example) single source of config (destinations + relay)
generate-config.sh renders rtmp/nginx/nginx.conf from .env.json
docker-compose.yml both services; relay behind the "relay" profile
rtmp/ nginx-rtmp + NVENC ffmpeg (RTMP ingest + multistream)
relay/ optional Rust audio relay (Shoutcast/Icecast2)
.forgejo/workflows/ CI that builds and publishes the ffmpeg binaries