A fast, self-hosted RTMP / RTMFP media server written in C++23. Publish from OBS or ffmpeg, relay live to any number of viewers, and record to FLV, over every Flash-era transport from one small binary.
docker build -t fms-m .
docker run --rm -p 1935:1935 -p 1935:1935/udp -p 8080:8080 fms-m
ffmpeg -re -i input.mp4 -c copy -f flv rtmp://127.0.0.1/media/mystream # publish
ffplay rtmp://127.0.0.1/media/mystream # watch- Every transport, one set of streams. RTMP, RTMPE, RTMPS, RTMPT, RTMPTS and UDP-based RTMFP all serve the same applications: publish over one, play over any other. RTMFP in particular is rarely implemented anywhere else.
- Uses every core. One Boost.Asio
io_contextper thread, each connection pinned to one of them. No JVM, no license server. - Built for hostile input. Bounded queues, decode budgets and handshake timeouts throughout; unit tests under ASan, TSan and UBSan, AMF fuzzers, and an interop matrix against ffmpeg, rtmpdump and rtmfp-cpp.
- Portable. Builds warning-free on macOS and Linux, from GCC 12 to 16 and Clang 16 to 22 (see Verified build environments).
Version 2.0.2 · C++23 · CMake · Boost · OpenSSL 3 · Speex · POSIX only
- Live relay — one publisher fans out to many subscribers with per-client queueing and QoS notifications, and a bounded send queue that sheds video rather than growing without limit behind a slow client (see Slow consumers).
- Origin pull — play
stream@rtmp://origin/appand, with no local publisher, the server bridges the stream in from the remote origin using the bundledfms_helperrelay (see Stream relay). - Recording — publish a stream as type
recordand the server writes it to<output-folder>/<stream>.flv. - Multiple transports for the same content:
- RTMP — plain TCP (default port
1935) - RTMPE — encrypted RTMP (RC4 + Diffie-Hellman handshake), same port
- RTMPS — RTMP over TLS (opt-in; needs a cert/key — see TLS)
- RTMPT — RTMP tunnelled over HTTP (default port
80) - RTMPTS — RTMPT tunnelled over HTTPS (TLS); opt-in, shares the TLS cert/key
- RTMFP — Adobe's UDP real-time protocol (default port
1935/udp)
- RTMP — plain TCP (default port
- Codec pass-through for H.264 video and AAC audio, plus built-in Speex audio support used by the call application.
- Admin application — a password-protected control app exposing live application/client/stream statistics and the ability to disconnect clients.
- Multi-threaded — a pool of I/O threads, one
io_contextper thread, with each connection pinned to a single thread.
RTMP organises streams under a named application. A client connects to
rtmp://host/<app>/<stream>. Three applications are registered:
| App name | Purpose |
|---|---|
media |
The main A/V app: publish + live fan-out, VOD playback, and recording. |
video_call |
Multi-party video-call variant of media (adds call signalling / audio mixing). |
admin |
Administrative control and monitoring (requires a password file). |
The publish/play/record URL for the media app is
rtmp://host:1935/media/<streamName>.
- A C++23 compiler (Apple clang 16+, or GCC 12+)
- CMake ≥ 3.16
- Boost —
date_time,log,log_setup,program_options,thread; no version pin. Boost.System is header-only and deliberately not linked. - OpenSSL 3 —
libcrypto(the legacy provider must be available for RTMPE, which uses RC4) andlibssl(for the TLS transports, RTMPS / RTMPTS) - Speex — audio codec
pkg-config
brew install cmake boost openssl@3 speex pkg-config
cmake -S . -B build
cmake --build build -j8Homebrew's boost installs into the prefix CMake already searches; the
keg-only openssl@3 and speex are located automatically.
sudo apt-get install cmake g++ libboost-all-dev libssl-dev libspeex-dev pkg-config
cmake -S . -B build
cmake --build build -j"$(nproc)"The resulting binary is build/fms-m.
The server has been built warning-free and its full RTMP / RTMPE / RTMPS / RTMPT / RTMPTS / RTMFP functional suite run successfully on the following combinations, spanning Boost 1.81 → 1.92, OpenSSL 3.0 → 3.6, GCC 12 → 16 and Clang 16 → 22:
| OS | Compiler(s) | Boost | OpenSSL | Build file |
|---|---|---|---|---|
| macOS 15 | Apple Clang 16 | 1.92 | 3.6 | (Homebrew) |
| Debian 12 (bookworm) | GCC 12.2 | 1.81 | 3.0 | (host / apt) |
| Debian 13 (trixie) | GCC 14.2 | 1.83 | 3.5 | Dockerfile |
| Ubuntu 26.04 | GCC 15.2 · Clang 21 | 1.90 | 3.5 | Dockerfile.ubuntu |
| Arch Linux (rolling) | GCC 16.1 · Clang 22 | 1.91 | 3.6 | Dockerfile.arch |
Where two compilers are listed, both produce a clean build. The Ubuntu 26.04 and Arch Linux images are additionally validated clean under AddressSanitizer (including 24 concurrent RTMFP clients).
All three build files take --build-arg RUN_TESTS=1 to run the unit suite as
part of the build. They differ otherwise, because they exist for different
reasons:
| Build file | Purpose | Build args |
|---|---|---|
Dockerfile |
Ships the server: two-stage, non-root, runtime image carries only the binary and the libraries it links. | RUN_TESTS, SANITIZE=address|thread|undefined |
Dockerfile.ubuntu |
Compiles against newer toolchains; single-stage on purpose. | RUN_TESTS, CXX, CXXFLAGS_EXTRA, LDFLAGS_EXTRA |
Dockerfile.arch |
Same, against the newest GCC/Clang/Boost/OpenSSL. | RUN_TESTS, CXX, CXXFLAGS_EXTRA, LDFLAGS_EXTRA |
The bundled client tools (rtmp_client, fms_helper) build alongside the
server on every combination above. The fms_helper origin-pull relay is
additionally verified end-to-end — remote publish → relay → local play — on
macOS and Linux.
Quick start — listen for RTMP on 1935, write recordings and logs to the current directory:
./build/fms-mA more typical invocation:
./build/fms-m \
--bind-address 0.0.0.0 \
--rtmp-port 1935 \
--rtmpt-port 8080 \
--threads 4 \
--output-folder /var/lib/fms/recordings \
--log-path /var/log/fmsPorts below 1024 (such as the default RTMPT port
80) require elevated privileges on Linux. Use--rtmpt-port 8080(or similar) when running as a normal user.
The TLS transports are opt-in: they are armed only when a certificate and private key are supplied and the corresponding port is set. RTMPS wraps the RTMP state machine in a TLS stream; RTMPTS runs the RTMPT HTTP tunnel over TLS. Both share the one cert/key.
A half-configured TLS setup is a startup error, not a silent downgrade — the
server refuses to run rather than leave you with a closed port and no
explanation. That covers a TLS port without both --tls-cert and --tls-key, a
cert without its key, a cert/key pair with neither TLS port set, and a cert that
is present but fails to load.
# a throwaway self-signed cert for local testing
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem \
-days 365 -nodes -subj "/CN=localhost"
./build/fms-m \
--rtmps-port 443 --rtmpts-port 8443 \
--tls-cert cert.pem --tls-key key.pemThen rtmpdump -r rtmps://host/app/stream (or rtmpts://…) plays over TLS; use
a CA-signed cert in production (clients that verify will reject self-signed).
| Short | Long | Default | Description |
|---|---|---|---|
-h |
--help |
Print help and exit. | |
-v |
--version |
Print version information and exit. | |
-b |
--bind-address <addr> |
0.0.0.0 |
Address to bind the listeners to. |
-R |
--rtmp-port <port> |
1935 |
RTMP (and RTMPE) TCP listen port. |
-T |
--rtmpt-port <port> |
80 |
RTMPT (HTTP-tunnelled RTMP) listen port. |
-K |
--rtmfp-port <port> |
1935 |
RTMFP (UDP) listen port. |
--rtmps-port <port> |
(off) | RTMPS (RTMP over TLS) listen port; empty = disabled. | |
--rtmpts-port <port> |
(off) | RTMPTS (RTMPT over TLS) listen port; empty = disabled. | |
--tls-cert <path> |
(none) | PEM certificate chain; required for RTMPS/RTMPTS. | |
--tls-key <path> |
(none) | PEM private key; required for RTMPS/RTMPTS. | |
-t |
--threads <n> |
1 |
Number of I/O threads (one io_context per thread). |
-o |
--output-folder <path> |
. |
Destination folder for recorded .flv files. |
-c |
--config-file <path> |
Optional config file (same keys as CLI options). | |
-P |
--log-path <path> |
. |
Directory for the rotating server log files. |
-f |
--log-file <name> |
(auto) | Explicit log file name. |
-l |
--log-level <n> |
3 |
Log verbosity level. |
-a |
--auth-plugin <path> |
(none) | Path to an authentication plugin shared library. |
-F |
--password-file <path> |
./passwd |
Password file used by the admin application. |
-H |
--helper-app <path> |
(none) | Relay helper spawned for origin-pull play (fms_helper). |
-q |
--quality <0..10> |
6 |
Speex encoder quality. |
-e |
--max-queue-bytes <n> |
10485760 |
Queued outbound bytes per connection before a slow consumer is shed, then dropped (0 = unbounded). See Slow consumers. |
Options may also be supplied through a config file (--config-file) using the
long option names as keys.
The examples use ffmpeg to publish/play plain RTMP and
rtmpdump for encrypted RTMPE.
ffmpeg -re -i input.mp4 -c copy -f flv rtmp://127.0.0.1:1935/media/mystreamffmpeg -i rtmp://127.0.0.1:1935/media/mystream -c copy out.flv
# or watch it live:
ffplay rtmp://127.0.0.1:1935/media/mystreamrtmpdump -r "rtmpe://127.0.0.1:1935/media/mystream" --live -o out.flvRTMPE reuses the RTMP port — the client selects encryption via the rtmpe://
scheme. The server negotiates the RC4/Diffie-Hellman handshake automatically.
ffmpeg -i rtmpt://127.0.0.1:8080/media/mystream -c copy out.flvRTMFP is Adobe's UDP real-time media protocol. Its original client was Flash
Player, but the server also interoperates with the open-source
rtmfp-cpp client suite (the RFC 7016
author's implementation). Using its test tools, built from that repository:
# play a live stream over RTMFP
tcconn -4 'rtmfp://127.0.0.1:1935/media#mystream'
# publish an FLV over RTMFP
tcpublish -4 'rtmfp://127.0.0.1:1935/media#mystream' input.flv-H -S relax the per-packet session HMAC and sequence-number requirement; the
server implements both, so they are not needed and the interop matrix runs
without them.
The stream name is passed as the URL fragment (#mystream), and RTMFP listens on
UDP --rtmfp-port (default 1935/udp). The server implements the 1024-bit MODP
Diffie-Hellman group (RFC 2409 group 2); modern clients negotiate down to it
automatically. Both directions work — publish over RTMFP and play over RTMP (or
RTMPE/RTMPT), and vice versa — since all transports share the same applications.
A subscriber that stops reading (or reads slower than the stream) applies TCP
backpressure, and the frames the publisher keeps producing pile up in that
connection's send queue. --max-queue-bytes (default 10 MB) bounds that queue
per connection, in three tiers:
| Queued | Behaviour |
|---|---|
| ≤ half the limit | normal — nothing happens |
| over half | shed droppable video down to a quarter, and keep streaming |
| still over the limit afterwards | nothing left to give — disconnect the client |
Shedding drops inter frames first, then whole GOPs, oldest first: a client that is behind wants the freshest content. Audio, codec sequence headers and all control messages are never shed — audio is a small fraction of the bytes but far more noticeable when it gaps, and losing a sequence header makes the stream undecodable for the rest of the session. Shed and disconnect decisions are logged (rate-limited per connection) and counted into the admin app's dropped message stats.
Setting --max-queue-bytes 0 disables the bound entirely, which lets one stuck
client grow the server's memory without limit. It exists for testing and A/B
measurement, not for production.
For reference, Adobe FMS 4.5 bounds the same backlog by bytes (measured at ~8–10 MB, independent of bitrate) and then simply disconnects, without sending anything at RTMP level and without thinning the stream first. This server keeps that shape but sheds before it drops, which is gentler and invisible on the wire.
Recording is triggered by the RTMP publish type: a client that calls
NetStream.publish(name, "record") causes the server to write the incoming
stream to <output-folder>/<name>.flv.
Note that many encoders (including ffmpeg's -f flv output) publish with type
"live", which relays but does not record. To record, the publishing client
must request the record type. The recorded file is a standard FLV containing
the published audio/video and can be played back with any FLV-capable player.
A client can request a stream that lives on another server by playing the
<stream>@rtmp://<origin-host>/<app> syntax. When the local server has no
publisher (and no recorded VOD) for <stream>, it bridges the stream in from
the origin by spawning the program given by --helper-app:
fms_helper -r rtmp://<origin-host>/<app> -l rtmp://localhost:<rtmp-port>/<app> -s <stream>
The helper plays <stream> from the origin and republishes it to the local
server under the same name, feeding the waiting subscriber. The bundled
fms_helper tool (built alongside the client, from client/helper_main.cpp)
implements exactly this contract — point --helper-app at its full path:
./build/fms-m --helper-app "$PWD/build/fms_helper" ...The relay carries H.264 / AAC (and onMetaData) straight through, keeps no
state, and exits when the origin stream ends; the server re-spawns it on the
next remote-stream play.
- Admin app — connect to the
adminapplication to query live statistics (applications, clients, streams, queue stats) and to disconnect clients. Access is authenticated against the file given by--password-file(default./passwd). Each line isuser:sha256hash, optionally salted asuser:salt$sha256(salt+password). - Auth plugin (interface only) — a loader and plugin ABI exist for an
external shared-library authenticator (
--auth-plugin <lib>), intended to approve/reject connections and publishes against your own user directory or token system. The loader works in isolation, butauthentication_manageris not instantiated anywhere, so no plugin is loaded or consulted at runtime. The plugin interface is also synchronous, so a plugin that returnsindeterminatehas no way to complete.
cmake -S . -B build -DBUILD_TESTS=ON # unit tests
cmake --build build -j8
ctest --test-dir build --output-on-failureBUILD_CLIENT (on by default) builds rtmp_client and fms_helper, and with
them every test that links the client library: the end-to-end b2b_test, the
interop matrix below, and the handshake, handshaker, rtmfp_session,
rtmpt_manager, chunk_parser and mixer tests. The throughput benchmark
needs it too. Turning it off leaves only the tests that link the server
sources.
Two matrices drive real reference clients, so both need those binaries on
PATH — interop is registered only if CMake finds rtmpdump, ffmpeg and
ffprobe at configure time, and prints why it skipped otherwise:
| Script | Runs under ctest? |
What it covers |
|---|---|---|
test/interop/interop.sh |
yes, as test interop |
Protocol shape: which RTMP user-control events the server sends and the client consumes. Drives rtmpdump, ffmpeg, and rtmfp-cpp's tcpublish/tcconn (set RTMFP_CPP to their build directory). |
test/interop/realworld.sh |
no — run it by hand | Outcome: real encodes surviving the round trip (H.264 profiles, AAC/MP3, audio-only, video-only, HEVC/AV1), concurrency and mid-stream failure, each transport carrying media, a 45s durability pull, and the slow-consumer shed. Takes ~4 minutes, which is why it is not registered. |
RTMPE skips on ARM (rtmpdump bus-errors on the encrypted handshake); HEVC and
AV1 skip because Enhanced RTMP is not implemented yet — both are deliberate, and
the scripts say so rather than passing quietly.
Other options:
| Option | Effect |
|---|---|
-DSANITIZE=address,undefined |
Build with the named sanitizers. Also sets halt_on_error for every registered test, so a TSan/UBSan finding fails the run instead of printing a warning into a green log. |
-DWERROR=ON |
Treat warnings as errors. |
-DBUILD_FUZZERS=ON |
AMF fuzzers; fuzz_run is registered as a ctest. |
The unit suite covers the codecs under a sanitizer, but the server's own threads —
the io_context pool, the stats timer, the RTMFP reaper — exist only in a running
server, so test/interop/sanitize.sh drives a sanitizer build with real clients:
cmake -S . -B build-tsan -DSANITIZE=thread -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build-tsan -j8
test/interop/sanitize.sh build-tsan/fms-m # exits non-zero on any reportIt runs an RTMP publisher with three subscribers, an RTMFP publish/play pair, and
fifteen connections abandoned mid-handshake. LeakSanitizer is unavailable on
Apple platforms, so leak checking needs a Linux build — the Dockerfile takes
--build-arg SANITIZE=address.
bench_rtmp_throughput spawns a server and loads it over RTMP. It is a manual
benchmark, not a test:
# 1 publisher -> 32 subscribers on ONE stream (fan-out), 14 server threads
./build/bench_rtmp_throughput 16384 12 60000 32 14 27000 512 0 1The trailing 1 selects fan-out; without it the run is N independent
publisher/subscriber pairs instead. On Linux it also reports how many cores the
server burned versus the whole box, which is what shows where it saturates.
The server writes rotating log files to --log-path (default: current
directory). Verbosity is controlled with --log-level (higher is more verbose).