RTMP restreamer and Ice/ShoutCast relay, formerly slay-rtmp-server and slay-shout-relay. https://slay.tools/
  • Rust 58.8%
  • Shell 28.5%
  • Dockerfile 12.7%
Find a file
Slaygon c53e69c6ce CI: delete-then-upload ffmpeg packages to avoid 409 on re-publish
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.
2026-06-21 02:12:48 +02:00
.forgejo/workflows CI: delete-then-upload ffmpeg packages to avoid 409 on re-publish 2026-06-21 02:12:48 +02:00
relay Align relay publish token key to registry_token_public 2026-06-21 02:00:53 +02:00
rtmp Repoint CI/publish to slayradio-public and consolidate docs 2026-06-20 17:24:04 +02:00
.env.json.example Read relay config directly from .env.json instead of env vars 2026-06-20 16:44:26 +02:00
.gitignore Unify deployment into one compose with root-level config 2026-06-20 16:19:55 +02:00
docker-compose.yml Read relay config directly from .env.json instead of env vars 2026-06-20 16:44:26 +02:00
generate-config.sh Unify deployment into one compose with root-level config 2026-06-20 16:19:55 +02:00
README.md Align relay publish token key to registry_token_public 2026-06-21 02:00:53 +02:00

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 by generate-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 ffmpeg exec command in the live application; receives a -f tee "..." line with all non-YouTube destinations (each wrapped in a fifo muxer so a failing destination retries independently) plus the local yt_relay entry.
  • #=#=# YOUTUBE PUSHES WILL BE PLACED HERE #=#=# - inside the yt_relay application; receives one push <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

  1. ffmpeg reads the live RTMP stream, strips video, and encodes audio to MP3.
  2. Once the first audio bytes arrive, the relay connects to the audio server and performs the handshake (Shoutcast v1 or Icecast2).
  3. ffmpeg's raw MP3 output is piped directly into the server TCP connection.
  4. 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:

  1. Bump version in relay/Cargo.toml.
  2. Provide a token with package:write on slayradio-public, either as REGISTRY_TOKEN_PUBLIC=<token> in the environment or as appconfig.registry_token_public in the root .env.json.
  3. Run relay/publish.sh. It builds a release binary and uploads it as both /<version>/slay-restream-linux-amd64 and /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