-
Notifications
You must be signed in to change notification settings - Fork 0
INSTALL
Generated from
docs/INSTALL.md. Edit that file and re-runnode scripts/publish-wiki.mjs --push. An edit made here is a fork of the documentation that nothing reconciles, and the next run of this script will overwrite it without asking.
One binary, one dependency. The binary is self-contained — the React UI is embedded, SQLite is pure Go, there is no cgo and no runtime library to install. The dependency is FFmpeg 6.0 or newer, and it is where essentially every installation problem comes from. Read the FFmpeg part of your platform's section even if you skip the rest.
- The scripted install
- "I do not have a certificate"
- Platform maturity
- The FFmpeg requirement
- Docker
- Linux (bare metal or VM)
- macOS
- Windows
- Verifying the install
- Ports
On Linux, scripts/install.sh does everything on this page interactively —
asks how you want it installed, checks the things that actually go wrong, and
rolls back cleanly if a step fails:
curl --proto '=https' --proto-redir '=https' --tlsv1.2 -fsSLO \
https://raw.githubusercontent.com/rainmanjam/polyemesis/main/scripts/install.sh
less install.sh # it runs as root; read it first
sudo bash install.shIt offers two modes. Docker bundles a known-good FFmpeg with libsrt, so nothing on the host matters. Binary installs the static binary plus a systemd unit, and will not proceed against an FFmpeg below 6.0 — naming your distribution's actual version when it recognises it — rather than installing a service that cannot start. It offers to install a current FFmpeg for you at that point; declining is what stops the install, not the old version itself.
Everything before confirm_plan only reads the host. Nothing is written
until you have seen the plan and agreed to it, and a failure after that point
runs the rollback trap rather than leaving a half-install behind.
flowchart TD
start(["sudo bash install.sh"]) --> root{"running as root?"}
root -- no --> stopRoot["refuses"]
root -- yes --> os["detect_os"]
os --> linux{"/etc/os-release?"}
linux -- no --> stopOS["exits — this installer targets Linux"]
linux -- yes --> checkonly{"--check given?"}
checkonly -- yes --> pre["preflight only:<br/>FFmpeg floor + systemd<br/><b>installs nothing</b>"]
pre --> exitCheck(["exit 0 / exit 1"])
checkonly -- no --> gather["gather_configuration<br/>mode · ports · TLS"]
gather --> plan["confirm_plan<br/><i>last point before anything is written</i>"]
plan --> sysd{"systemd present?"}
sysd -- no --> stopSysd["dies"]
sysd -- yes --> mode{"which mode?"}
mode -- docker --> d1["install_docker"]
d1 --> d2["install_docker_mode<br/>image carries FFmpeg + libsrt"]
d2 --> d3["write_helper_scripts"]
d3 --> fw
mode -- binary --> b1["install_binary_mode"]
b1 --> b2["check_ffmpeg<br/><i>see the gate below</i>"]
b2 --> b3["verify SHA256SUMS<br/>then install unit"]
b3 --> fw
fw["configure_firewall<br/><b>udp</b>/6000 for SRT"] --> ver["verify"]
ver --> done(["print_summary"])
stopRoot:::halt
stopOS:::halt
stopSysd:::halt
classDef halt fill:#4a1d22,stroke:#c0524f,color:#ffdfe0
The --check branch is what CI runs on every distro in the matrix: it
exercises detection, the architecture gate, the init gate and the FFmpeg floor,
and installs nothing.
Only binary mode reaches this; in docker mode the image carries its own FFmpeg and the host's version is irrelevant.
flowchart TD
a["check_ffmpeg"] --> onpath{"ffmpeg on PATH?"}
onpath -- no --> none["states the problem,<br/><b>then offers to install 8.1</b><br/><i>required</i>"]
onpath -- yes --> parse{"version banner parses?"}
parse -- no --> unknown["warns and continues —<br/>unknown is not the same as too old"]
parse -- yes --> major{"major version"}
major -- "below 6" --> tooold["names your distro's actual version,<br/><b>then offers to install 8.1</b><br/><i>required</i>"]
major -- "6 or 7" --> old["<b>offers to install 8.1</b><br/><i>optional — you only lose<br/>multitrack FLV by declining</i>"]
major -- "8 or newer" --> fine["matches what the container ships"]
none --> offer
tooold --> offer
old --> offer
offer["offer_ffmpeg_upgrade<br/>static 8.1 build → /usr/local/bin<br/><b>libsrt verified before anything<br/>is displaced</b>; distro copy untouched"]
offer --> took{"installed?"}
took -- yes --> okpath["floor cleared"]
took -- "no (declined,<br/>skipped, or unattended)" --> verdict{"was it required?"}
verdict -- yes --> stop["<b>install stops here</b>"]
verdict -- no --> okpath
stop:::halt
classDef halt fill:#4a1d22,stroke:#c0524f,color:#ffdfe0
Two things about that gate are worth stating plainly, because they are the parts people get caught by:
Declining is only fatal when it was required. At 6.x or 7.x you are simply choosing to stay put, and everything except multitrack FLV behaves identically. Below the floor — or with no FFmpeg at all — declining ends the install, because polyemesis will not start against what you have.
--check never installs anything, including with --ffmpeg force. That
combination reads as two reasonable flags, and a preflight that replaced a
system binary would be a broken promise; it prints what would be installed
and exits. --ffmpeg force takes the upgrade without asking on a real run,
--ffmpeg skip declines it. Unattended runs never install FFmpeg on their own:
with no terminal to ask, the installer refuses and names the flag instead.
Nothing is deleted either way. The static build lands in /usr/local/bin ahead
of /usr/bin on a default PATH, your distribution's package is left where it
is, and the way back is rm /usr/local/bin/ffmpeg /usr/local/bin/ffprobe.
And PATH order does not decide the outcome. When the installer installs
FFmpeg itself, it writes the absolute path into the config it generates:
ffmpeg:
binary: "/usr/local/bin/ffmpeg"
probe: "/usr/local/bin/ffprobe"So polyemesis uses the build that was just verified even on a host whose PATH
puts /usr/bin first. Nothing is pinned when your own FFmpeg was accepted —
freezing a path to a distribution package would break your next apt upgrade
in a way nobody would connect back to this installer.
Clearing 6.0 and having SRT are two different questions, and an FFmpeg can pass the first and fail the second — Homebrew's does, and so do several distribution builds. That matters more than it sounds: RTMP carries one stereo pair, so per-destination audio routing has nothing to route from, which is the main reason to run polyemesis at all.
The installer offers the same static build here too, and that build is only
accepted if ffmpeg -protocols actually lists srt. You can still decline and
continue on RTMP — it works — but it is a choice made against an offer rather
than against a compile-it-yourself instruction.
A version parsed out of ffmpeg -version is prose, and some builds do not
produce a parseable one — a git or nightly build announces itself as
ffmpeg version N-119534-g..., with no major number to compare.
Rather than guess, the installer runs a ~50 ms probe that exercises what
polyemesis actually depends on: the modern channel-layout API (the same pan
shape the audio router compiles) and -progress emitting the fields the engine
reads. A build whose version cannot be parsed but which passes the probe is
accepted on the measurement; one that fails it is refused and offered a
replacement, instead of being assumed fine.
In binary mode the release archive is checked against the release's published
SHA256SUMS. A mismatch is fatal. A missing sums file is also fatal —
those are the same risk with different evidence, and installing a binary nobody
can check, as root, is not a reasonable default. If you are deliberately
installing a release that has no published sums, --allow-unverified says so
explicitly.
What it gets right that a hand-rolled docker run usually does not: /udp on
the SRT port, stop_grace_period: 30s so a recording is finalised rather than
truncated, a firewall rule for udp/6000, and CAP_NET_BIND_SERVICE on the
unit when you choose ACME, without which the :80 bind fails and issuance
never completes.
It never asks for an admin password. polyemesis has no account until you create
one on the first-run screen, so there is no credential for an installer to
handle. In binary mode it verifies the download against the release's published
SHA256SUMS and refuses to install on a mismatch.
Linux only. It is bash, systemd, apt and ufw/firewalld, and it exits
immediately anywhere else with no /etc/os-release — this installer targets Linux. macOS installs from the macOS section below; Windows has its
own scripts in deploy/windows/.
The rest of this page is the manual version, and is worth reading if the script fails or if you want to know what it did.
They come up together and neither is a blocker, so it is worth separating them.
You do not need to obtain one. tls.mode: selfsigned generates a local CA and a
leaf at startup, so the connection is encrypted immediately and the only cost is
a browser warning until you install that CA — one command on each platform, in
TLS.md. acme is the only mode that needs
a real certificate, and auto will not choose it without a public DNS name.
The released binaries are not signed with a paid certificate and macOS binaries are not notarized. Neither stops them running.
macOS. This is the one that could have been a genuine problem: Apple Silicon
requires an arm64 binary to carry a signature to execute at all, and the
release cross-compiles darwin binaries on a Linux runner. Go's linker ad-hoc
signs darwin/arm64 even when cross-compiling, which satisfies that
requirement. Verified by building in a Linux container with the release's own
flags and running the result on Apple Silicon:
$ codesign -dv polyemesis-darwin-arm64
CodeDirectory v=20400 ... flags=0x20002(adhoc,linker-signed)
$ ./polyemesis-darwin-arm64 -version
polyemesis v0.2.0What is not signed is a Developer ID, so the binary is not notarized.
spctl -a -t execute reports rejected — that answers "would Gatekeeper
approve this for distribution", not "will this run". Gatekeeper enforces on
files carrying com.apple.quarantine, which a browser download sets and curl
does not. If you hit it:
xattr -d com.apple.quarantine ./polyemesisA freshly quarantined binary can also sit for a minute or two on its first run while macOS scans it; the result is cached, and subsequent runs are immediate.
Windows. The .exe is unsigned, so SmartScreen shows a warning on first
run — More info → Run anyway, once. It does not prevent the service from
being registered or started.
Docker avoids both, on both platforms, and on macOS you probably want it anyway: Homebrew's FFmpeg has no libsrt, and multitrack ingest needs it.
These are not four equal targets, and pretending otherwise would cost you a weekend. But they are equal on one axis, and separating that from the rest is the honest way to say it. Same framing as the README.
The shared CI floor, identical on every push for all three operating systems: build, vet, the full Go test suite with FFmpeg installed, the binary started and confirmed serving, and a three-track broadcast pushed through it with per-band energy measured in each destination's output. Two destinations take different track selections and the unselected tone must measure 25–40 dB down, so a wrong mix fails rather than passing quietly.
Everything past that floor is where they diverge:
| Platform | Beyond the shared floor | Operationally |
|---|---|---|
| Linux (server) | The race detector, 11 acceptance suites and 3 container suites — none of which run on any other OS | Primary. Developed against, deployed, exercised |
| Docker | The 3 container suites run against this exact image | Primary. Built from this repo, bundling a pinned FFmpeg |
| macOS | Nothing further | Daily driver. Fine as a workstation and test rig. Homebrew's FFmpeg has no SRT — see below |
| Windows | Nothing further | Unproven. No live broadcast to a real platform, no exercise of the service wrapper or installer on a real host, and recording truncation on service stop is a known unresolved defect — see the note below |
On the recording truncation. This table filed it as Windows-only, and that
was wrong: it happened on Linux too, and had since the shared SRT listener was
introduced. Manager.Stop closed every established publisher before stopping
the engines, so the relay went quiet while the recorder's FFmpeg was still
blocked reading it — and an FFmpeg blocked in a read never reaches its SIGTERM
check. It missed the grace period, was killed, and the container's trailer was
never written. Measured on a live host: the file did not grow by one byte
across the stop, and ffprobe reported no duration on nearly two minutes of
footage.
That is fixed, and scripts/acceptance-recording-stop.sh holds it fixed. What
is not established is whether Windows was failing for the same reason. The
mechanism is not platform-specific, so it may well have been — but nobody has
run the suite there, so the row above still says what it says.
The distinction that matters for choosing: Windows is tested, not operated.
The broadcast path demonstrably works there and that job has already caught
Windows-only bugs — a file:// URL corrupted by path separators, TLS keys left
world-readable because os.FileMode does nothing on Windows. What has never
happened is somebody running a real show on it.
If you are choosing where to run this, run it on Linux.
Two separate checks, and they fail differently:
Version — 6.0 or newer, enforced. polyemesis probes ffmpeg -version before
it opens the database and refuses to start on anything older, with a message
naming the binary and the version it found. The floor is 6.0 because multi-track
MPEG-TS mapping, the channel-layout API behind the audio mix matrix, and the
-progress fields the supervisor parses are only reliable from 6.x onwards.
SRT — the reliable path to multitrack ingest, not enforced. A build without libsrt starts fine and warns. You can reach the UI and switch ingest to RTMP, but classic RTMP carries a single stereo pair, so per-destination audio routing has nothing to route from.
Enhanced RTMP is the exception, and it is a real one: it carries multiple audio tracks and polyemesis ingests them. It is not an equal substitute, though — that path is verified with FFmpeg 7.1+ as the publisher, does not work on 6.1.1, and has not been confirmed with OBS publishing. Since OBS is what most readers here are using, treat E-RTMP as a route worth trying rather than a replacement for SRT.
Check both, on every platform:
ffmpeg -version | head -1
ffmpeg -protocols | tr ' ' '\n' | grep -x srt # must print: srtUse the exact match.
ffmpeg -protocols | grep srtis misleading — every build listssrtp(Secure RTP), a different protocol that happens to contain the substring.
Checked 2026-07-26. "Ran the check" means the -protocols command above was
executed against that build. Everything else is read off a package index or a
published feature list — so run the check yourself rather than trusting the row.
| Source | libsrt |
|---|---|
| Docker image in this repo | yes — the build asserts it, and it was run |
| BtbN static builds, Linux | yes — ran the check |
Ubuntu 24.04 / Debian 13 apt install ffmpeg
|
advertised in the package build flags; not run |
| BtbN static builds, Windows | same recipe as their Linux asset, but no Windows asset was downloaded or run |
| gyan.dev Windows builds | listed among the essentials build's externals; not downloaded or run |
Homebrew ffmpeg on macOS |
no — srt is not among the formula's dependencies, and the check was run |
| johnvansickle.com static builds | not advertised; run the check before relying on it |
If your build has no SRT, polyemesis starts anyway and warns, so you can reach Settings and switch the ingest to RTMP. But classic RTMP carries a single stereo pair, so per-destination audio routing has nothing to route from.
Three ways out, in the order most people should try them: a build configured
with --enable-libsrt, one of the static builds above, or the Docker image —
which bundles one and asserts it at build time.
A fourth exists if your encoder speaks Enhanced RTMP, which does carry multiple audio tracks: polyemesis ingests those and the routing works normally, with no libsrt anywhere. The caveat is what keeps it fourth rather than first — verified with FFmpeg 7.1+ publishing, broken on 6.1.1, and unconfirmed with OBS. If your encoder is OBS, fix the FFmpeg build instead.
Hardware encoders — nothing to install, nothing to configure. Do not go
looking for a build with NVENC or VA-API compiled in on the strength of
ffmpeg -encoders: that list is what the binary was compiled with, not what
your machine can do, and a stock Ubuntu FFmpeg advertises all four vendors on a
box with no GPU. polyemesis test-encodes a frame with each one at startup and
offers only what worked. If you have a GPU you want it to reach — especially in
a container, where it has to be passed in explicitly — see
docs/HARDWARE.md.
The least that can go wrong: the image bundles a pinned FFmpeg with SRT, so nothing on the host matters except Docker itself.
Prerequisites. Docker Engine 20.10+ (or Docker Desktop). Nothing else — no Go, no Node, no FFmpeg on the host.
FFmpeg. Pinned in the Dockerfile to a specific Alpine package version, not
floating. apk add ffmpeg would resolve to whatever the branch holds on the day
you build, which makes a rebuild months later a different product under the same
tag — and the thing that drifts is the transcoder, where a behaviour change
surfaces as a broken stream rather than a build error. The Dockerfile documents
how to bump it deliberately, and fails the build if the resulting FFmpeg has no
SRT.
Install.
git clone https://github.com/rainmanjam/polyemesis
cd polyemesis
docker compose up -d
docker compose logs -fOpen http://localhost:8080 and set an admin password on the first-run screen.
What the compose file publishes, and why each one:
| Port | Why |
|---|---|
8080/tcp |
web UI and API |
6000/udp |
SRT ingest — UDP. Omitting /udp is the classic reason an SRT ingest silently receives nothing. |
1935/tcp |
RTMP ingest, the fallback for encoders that cannot do SRT |
80/tcp |
ACME only, and shipped commented out — see below |
Both ingest ports are published once, for the whole install. Adding a second
programme never means publishing another port or restarting the container: every
source arrives on the same listener and is told apart by its publish token (SRT)
or stream key (RTMP). That is the point of
DESIGN-ONE-PORT-ONLY.md — editing
docker-compose.yml to add a source was a real cost it removed.
Port 80 and Let's Encrypt. If you want polyemesis to obtain its own
certificate (tls.mode: acme, or auto resolving to it), HTTP-01 validation
must reach http://<hostname>/.well-known/acme-challenge/… on port 80 from the
public internet. That needs the port published on the host, not merely
EXPOSEd in the image. Uncomment both the - "80:80" line and the config mount
in docker-compose.yml, then put your TLS block in config.yaml.
It ships commented out because publishing :80 unconditionally makes
docker compose up fail outright on any host that already runs a web server,
and the default configuration is plain HTTP with no certificate at all — so the
port would buy nothing and cost a bind error. No other TLS mode needs it.
The container runs as UID 10001, not root, which normally raises the question of
whether it can bind a port below 1024 at all. It can: Docker sets
net.ipv4.ip_unprivileged_port_start=0 inside containers, so no added
capability and no user: root is required. (Checked against Docker Engine 29.)
Data. Everything that must survive a container replacement lives in the
polyemesis-data volume mounted at /data: the SQLite database, secret.key
(which decrypts your stored OAuth tokens), recordings, and tls/ (the local CA
your browsers have been told to trust, plus the ACME cache that keeps a redeploy
from re-issuing against Let's Encrypt's rate limits). Back it up.
As a service. restart: unless-stopped is already in the compose file; the
Docker daemon's own startup handles boot.
polyemesis needs FFmpeg 6.0+. Several current server distributions ship less
than that, and apt install ffmpeg on them produces a binary polyemesis will
refuse to start against.
| Distribution | Stock FFmpeg | polyemesis |
|---|---|---|
| Ubuntu 22.04 LTS (jammy) | 4.4.2 | refuses to start |
| Debian 12 (bookworm) | 5.1.9 | refuses to start |
| Ubuntu 24.04 LTS (noble) | 6.1.1 | works |
| Debian 13 (trixie) | 7.1.5 | works |
| Alpine 3.20 / 3.21 / 3.22 | 6.1.1 / 6.1.2 / 6.1.2 | works |
| Alpine 3.23 / 3.24 | 8.0.1 / 8.1.2 | meets the floor; FFmpeg 8.x is not yet exercised here |
| RHEL / Rocky / AlmaLinux | not in the base repositories at all | see below |
| Fedora, Arch, openSUSE | not checked | verify for your release |
Checked against the distro package indexes on 2026-07-26 (packages.ubuntu.com,
packages.debian.org, pkgs.alpinelinux.org). Versions move; if your release
is not in this table, or you are reading this much later, run
apt-cache policy ffmpeg (or your equivalent) and check the number yourself
rather than trusting the row.
On RHEL and its rebuilds FFmpeg is not shipped at all for licensing reasons — RPM Fusion is the usual source. Verify the version it gives you before relying on it.
1. A newer distro. Ubuntu 24.04 LTS or Debian 13 both clear the floor from the stock repositories, with libsrt included. This is the least ongoing work.
2. A static FFmpeg build. Does not touch your system packages and does not need a distro upgrade. BtbN's builds include libsrt:
curl -fsSL -o ffmpeg.tar.xz \
https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n7.1-latest-linux64-gpl-7.1.tar.xz
tar xf ffmpeg.tar.xz
sudo install -m755 ffmpeg-*/bin/ffmpeg ffmpeg-*/bin/ffprobe /usr/local/bin/
/usr/local/bin/ffmpeg -protocols | tr ' ' '\n' | grep -x srtVerified 2026-07-26: ffmpeg-n7.1-latest-linux64-gpl-7.1 reports version
n7.1.5 and lists srt. These are glibc, x86-64 builds — they will not run on
Alpine or musl, and linux64 means amd64, not arm64.
If you would rather not put them on PATH, point the config at them instead:
ffmpeg:
binary: /opt/ffmpeg/bin/ffmpeg
probe: /opt/ffmpeg/bin/ffprobeNote that John Van Sickle's static builds, the other well-known set, do not advertise libsrt in their published configuration. If you use them, run the protocol check before you rely on multitrack ingest.
3. Docker. See the section above. On an old host this is often the shortest path, since the FFmpeg problem stops being a host problem.
Prerequisites for building from source: Go 1.26.5+ (the floor in go.mod;
the official Go images set GOTOOLCHAIN=local, so an older toolchain fails
rather than silently upgrading itself) and Node 20.19+ or 22.12+ (Vite 8's
requirement). Neither is needed to run the result.
git clone https://github.com/rainmanjam/polyemesis
cd polyemesis
make build # builds the UI, embeds it, produces ./polyemesis
./polyemesis -data ./dataOpen http://localhost:8080 and set an admin password.
A hardened systemd unit ships in
deploy/polyemesis.service:
sudo cp polyemesis /usr/local/bin/
sudo useradd --system --home /var/lib/polyemesis --shell /usr/sbin/nologin polyemesis
sudo mkdir -p /var/lib/polyemesis /etc/polyemesis
sudo chown polyemesis:polyemesis /var/lib/polyemesis
sudo cp config.example.yaml /etc/polyemesis/config.yaml
sudo cp deploy/polyemesis.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now polyemesis
journalctl -u polyemesis -fThree details in that unit are load-bearing:
-
KillMode=mixedwith a 45 s stop timeout. polyemesis signals its FFmpeg children and gives them time to finish. On SIGTERM, FFmpeg flushes and finalises its output — that is the difference between a playable recording and a truncated one, and between a clean disconnect and a platform that thinks you dropped. Letting systemd kill the whole cgroup immediately cuts a recording off mid-write.It also does something you only notice when it is missing. polyemesis puts each FFmpeg child in its own process group, so that stopping one destination stops its helpers and a Ctrl-C reaches polyemesis rather than killing its children out from under it mid-write. The cost of that isolation is that a polyemesis which is killed rather than asked to stop — the OOM killer, a
kill -9, a cancelled job — signals nothing on the way out, and its encoders keep running, reparented toinit, still holding the relay ports they were reading.KillMode=mixedis what closes that: systemd SIGKILLs the whole cgroup when the unit stops for any reason.So if you run polyemesis outside systemd — in the foreground while developing, or in a container whose entrypoint is a shell rather than polyemesis itself — nothing reaps those encoders for you. Check with
pgrep -a ffmpegafter an abrupt exit. Under the shipped unit there is nothing to do. -
No
CAP_NET_BIND_SERVICEby default. The service runs unprivileged, so it cannot bind ports below 1024. Withtls.mode: autoresolving toselfsignedthat is harmless — you get a warning that:80could not be bound for the HTTP→HTTPS redirect and HTTPS serves normally. Withmode: acmeit is not harmless: issuance will never complete. Uncomment theAmbientCapabilitiesblock in the unit file. -
ProtectProc=invisible, and the host half you have to supply. A destination's RTMP target isrtmp://host/app/<streamKey>, and it reaches FFmpeg as a command-line argument. polyemesis masks that command line everywhere it renders it and cannot mask the kernel's copy: on a stock Linux host,/proc/<pid>/cmdlineand plainpsare readable by every local account.ProtectProc=invisiblehides other users' processes from this unit's view; the complement ishidepid=2on/procitself. On a single-operator VPS this costs nothing and matters little. On a box with other people's shell accounts on it, it is the difference between a stream key that is yours and one that is everybody's.
If nginx, Caddy or Traefik already terminates TLS, set trustProxyHeaders: true
and leave tls.mode: auto — it deliberately resolves to off, so polyemesis
does not bind :80 and does not compete with the proxy for ACME challenges.
There is a worked config in
deploy/nginx.conf.example.
SRT and RTMP ingest are not HTTP and cannot be proxied by nginx's HTTP server. Open those ports on the firewall so encoders reach polyemesis directly.
Developed on daily, so it works — but read the SRT paragraph, because the default Homebrew install cannot do multitrack ingest.
Prerequisites. Homebrew. Go 1.26.5+ and Node 20.19+/22.12+ if building from source. Apple Silicon and Intel both fine.
brew install ffmpeg gives you a current FFmpeg — 8.1.2 as of 2026-07-26, well
past the 6.0 floor. But the Homebrew bottle is built without libsrt. Its
formula lists dav1d, lame, libvmaf, libvpx, openssl@3, opus, sdl2-compat, svt-av1, x264, x265 as dependencies; srt is not among them, and the protocol
check above prints nothing on a stock Homebrew install. (Verified against a live
Homebrew install on 2026-07-26, not inferred from the formula alone.)
polyemesis will start and warn. Ingest will work over RTMP only, which means one stereo pair and nothing for the mix matrix to route.
Three ways out:
1. The homebrew-ffmpeg tap, with SRT. Builds from source, so budget time:
brew uninstall ffmpeg # the core formula, if installed
brew tap homebrew-ffmpeg/ffmpeg
brew install homebrew-ffmpeg/ffmpeg/ffmpeg --with-srt
ffmpeg -protocols | tr ' ' '\n' | grep -x srtThe tap's formula exposes option "with-srt" and passes --enable-libsrt.
2. Docker Desktop. The image bundles an FFmpeg with SRT, so the host's FFmpeg stops mattering. Note that SRT ingest arrives over UDP into a VM here — fine for development, not what you would ship.
3. Build FFmpeg yourself with --enable-libsrt.
git clone https://github.com/rainmanjam/polyemesis
cd polyemesis
make build
./polyemesis -data ./datalaunchd. For a per-user agent that starts at login, write
~/Library/LaunchAgents/dev.polyemesis.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key> <string>dev.polyemesis</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/polyemesis</string>
<string>-config</string>
<string>/Users/YOU/Library/Application Support/polyemesis/config.yaml</string>
<string>-data</string>
<string>/Users/YOU/Library/Application Support/polyemesis</string>
</array>
<key>RunAtLoad</key> <true/>
<key>KeepAlive</key> <true/>
<!-- Homebrew is not on a launchd job's default PATH, and polyemesis looks
for ffmpeg there. Without this it exits at startup saying ffmpeg is
missing, on a machine where ffmpeg plainly works in your shell. -->
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
<key>StandardOutPath</key> <string>/tmp/polyemesis.log</string>
<key>StandardErrorPath</key><string>/tmp/polyemesis.log</string>
</dict>
</plist>launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.polyemesis.plist
launchctl print gui/$(id -u)/dev.polyemesisA LaunchAgent stops when you log out. For a machine that should stream whether
or not anyone is logged in, put the same plist in
/Library/LaunchDaemons/, own it root:wheel with mode 644, and load it with
sudo launchctl bootstrap system … — and give the daemon a data directory it
can actually write, not one under a user's home.
macOS will ask for a firewall exception the first time polyemesis binds its ingest ports. Approve it, or nothing external reaches the encoder.
Read this first. Windows builds and runs, and is not broadcast-tested. CI
runs the full Go suite on windows-latest with FFmpeg installed on every push,
then starts the binary and checks it answers /health — and that job has caught
real Windows-only bugs, including a file:// URL corrupted by path separators
and TLS keys left world-readable because os.FileMode does nothing there.
What remains unverified is operation: nobody has run a real broadcast through it on Windows, and the service-stop recording truncation below is a known unresolved problem. The Service Control Manager wrapper, process-group teardown and installer scripts have never been exercised on a live host. If this needs to work today, use Linux or Docker.
Prerequisites. Windows 10 / Server 2019 or newer, x86-64. Go 1.26.5+ and Node 20.19+/22.12+ if you are building the binary yourself.
Unlike macOS, the mainstream Windows builds do include libsrt:
-
gyan.dev — libsrt is in the
essentials build, so you do not need the much larger full one. Also
available as
winget install Gyan.FFmpeg. -
BtbN — take a
win64-gplasset. libsrt is part of the build recipe.
Unzip somewhere stable, add the bin directory to PATH (or point
ffmpeg.binary and ffmpeg.probe at it in config.yaml), then verify in
PowerShell:
ffmpeg -version | Select-Object -First 1
(ffmpeg -protocols) -split '\s+' -contains 'srt' # must be TrueAvoid the Microsoft Store and Chocolatey's ffmpeg-shared unless you have run
that protocol check against them — an FFmpeg without SRT is the single most
likely reason multitrack ingest fails on a fresh Windows box.
make release cross-compiles windows/amd64 and windows/arm64 along with
Linux and macOS, and writes them to dist/ with the .exe suffix. It builds
the UI first, so go:embed picks up the real assets rather than the
placeholder:
make release
ls dist/polyemesis-*-windows-*.exeTo build just the one target:
make ui
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 \
go build -trimpath -ldflags "-s -w" -o polyemesis.exe ./cmd/polyemesisEither works from Linux or macOS just as well as from Windows — there is no cgo. Building the UI first is the step that matters: skip it and you get a binary that serves the placeholder page.
A console window you have to stay logged in to is not a deployment. The
installer scripts and the full account, firewall and troubleshooting notes live
in deploy/windows/:
# elevated PowerShell
.\install.ps1 -BinaryPath .\polyemesis.exeRead deploy/windows/README.md before running
it — particularly the service-account section, because an account that lacks
Modify on the data directory produces a service that starts cleanly and then
fails the first time it writes a recording.
Three Windows-specific things worth knowing up front:
-
A service stop truncates an in-progress recording. The graceful stop is a
CTRL_BREAK_EVENT, and Windows delivers those only through a console, which a service does not have. The supervisor therefore terminates FFmpeg outright and the container is never finalised. Stop the recording from the web UI and let it finalise before stopping the service. Running interactively from a console is not affected. -
The data directory belongs under
C:\ProgramData, notProgram Files.Program Filesis read-only to services by design, and this directory is written to constantly. -
Port 80 is often already taken by
http.sys(IIS, WinRM, or anything else using the HTTP Server API). Ports below 1024 are not privileged on Windows the way they are on Unix, but they are frequently claimed. If polyemesis terminates TLS it also tries to bind:80; that failing is a warning and HTTPS keeps serving, but ACME issuance will never complete.
Same on every platform.
curl -s http://localhost:8080/api/v1/healthThe health endpoint is unauthenticated on purpose, so it works before you have signed in and from a container healthcheck.
Then confirm in the UI what polyemesis actually detected. Settings → Ingest
carries three badges: the FFmpeg version, srt yes/no, and x264 yes/no. If
srt reads no, no amount of OBS configuration will fix it — go back to your
platform's FFmpeg section.
The startup log carries the same facts:
ffmpeg detected version=… path=… srt=true
tls mode=… hostname=…
| Port | Protocol | Needed when |
|---|---|---|
| 8080 | TCP | always — web UI and API. Configurable via addr. |
| 6000 | UDP | SRT ingest. The default; changeable in Settings → Ingest. |
| 1935 | TCP | RTMP ingest, only if you use the fallback. One port however many RTMP sources you run. |
| 80 | TCP | only for tls.mode: acme (HTTP-01 validation), plus the HTTP→HTTPS redirect whenever polyemesis terminates TLS |
| 443 | TCP | only if you set addr to :443 rather than serving TLS on 8080 |
The ingest listeners bind 0.0.0.0 regardless of addr, so restricting addr
to loopback for a reverse-proxy deployment does not restrict ingest. Set an SRT
passphrase in Settings → Ingest: without one your stream crosses the network
in the clear. RTMP has no equivalent — its stream key authenticates the
publisher but encrypts nothing, so the RTMP port is the one to keep off the
public internet if you have the choice.
The scripted install writes an uninstall.sh beside the install, in both modes:
sudo /opt/polyemesis/uninstall.sh
It removes what the installer added — the service, the unit file, the binary or
container, and the config directory — and keeps your data, because that
directory holds the database, your recordings, and secret.key. It prints the
one command that removes it, so destroying it is a decision you make rather than
one made for you.
secret.key is what decrypts your stored platform tokens. A backup of the data
directory without it is not a backup you can restore from, and a copy of it is
as sensitive as the tokens themselves. If you are decommissioning a host rather
than reinstalling, remove the data directory too — the uninstall script names
the path.
An install done by hand, or one from a version that did not write an
uninstall.sh for your mode, leaves the same things behind. Remove them in this
order:
sudo systemctl disable --now polyemesis
sudo rm -f /etc/systemd/system/polyemesis.service
sudo systemctl daemon-reload # or systemd still lists a service whose
# binary is gone, and a later `start` fails
sudo rm -f /usr/local/bin/polyemesis
sudo rm -rf /etc/polyemesisand then, only if you mean it:
sudo rm -rf /var/lib/polyemesis # database, recordings, secret.keyDocker installs additionally keep a named volume, which survives compose down
by design:
docker volume rm polyemesis-data # same warning appliesThe firewall rules the installer added are not removed with it. sudo ufw status
lists them; delete the ones you no longer want.
Configuration reference: CONFIGURATION.md, and
config.example.yaml for the annotated file itself.
TLS modes and what auto resolves to: TLS.md.
Architecture: ARCHITECTURE.md.
Testing without OBS: TESTING.md.
Getting it running
- Quickstart: from nothing to a live restream
- Install polyemesis — an SRT server on your own box
- OBS SRT setup: multitrack audio to one ingest
- TLS certificates for a self-hosted SRT server
The routing
- Audio routing: a different mix per destination
- Renditions: one shared video encode
- Encoding: what is copied and what is encoded
- Hardware encoding: NVENC, QSV, VA-API, AMF
Operating it
- Configuration: config.yaml and the web UI
- Streaming platforms: what can be automated
- Broadcasting from a file, on a schedule
- What a settings change restarts, and what it does not
- Upgrading polyemesis and its database
- Troubleshooting: SRT, RTMP and audio problems
Automating it
- Monitoring: Prometheus metrics and alerts
- Lifecycle webhooks: one signed POST per event
- MQTT telemetry and Home Assistant
- HTTP API reference — polyemesis /api/v1
Understanding it