Most backend systems I work on are easy to draw as HTTP boxes.
A client sends a request. Nginx forwards it. PHP or another application process handles it. The response goes back through the same path.
A media server breaks that mental model very quickly.
When I first configured SRS - Simple Realtime Server - the difficult part was not getting the binary to start. The difficult part was understanding which protocol was entering the server, which protocol was leaving it, which port belonged to which part, and why putting Nginx in front of everything did not automatically make WebRTC work.
This is the setup I want to remember.
The architecture first
The useful mental model is not “SRS hosts a video”.
It is closer to this:
publish
OBS / FFmpeg -------------> SRS
RTMP |
|
+------------+-------------+
| |
v v
HLS / HTTP-FLV WebRTC
| |
v v
normal HTTP browser player
through Nginx low latencySRS is the protocol-aware media process in the middle.
It can accept a stream using one protocol and expose that stream through another. That is a very different responsibility from Nginx serving static files or reverse-proxying an API.
The ports stopped being random once I named them
A typical SRS deployment uses several ports, each for a different job.
1935/tcp RTMP publish/play
1985/tcp SRS HTTP API
8080/tcp HTTP server, commonly HLS/HTTP-FLV
8000/udp WebRTC media trafficThe exact public exposure depends on the deployment. I do not want the API port publicly available just because the streaming ports need to be reachable.
That means my firewall/security-group thinking becomes:
Internet
|
+--> 80/443 TCP -> Nginx
|
+--> 1935 TCP -> SRS, when direct RTMP publishing is required
|
+--> 8000 UDP -> SRS WebRTC media
Private / localhost only
|
+--> 1985 TCP -> SRS HTTP API
+--> 8080 TCP -> SRS HTTP output behind NginxThis separation matters. Opening 443 is not a substitute for opening the UDP port used by WebRTC.
A configuration I can reason about
I prefer keeping the first SRS configuration boring. Features can be added after the stream works end to end.
A simplified configuration looks like this:
listen 1935;
max_connections 1000;
daemon off;
srs_log_tank console;
http_api {
enabled on;
listen 1985;
}
http_server {
enabled on;
listen 8080;
dir ./objs/nginx/html;
}
rtc_server {
enabled on;
listen 8000;
candidate YOUR_PUBLIC_IP_OR_RESOLVABLE_ADDRESS;
}
vhost __defaultVhost__ {
hls {
enabled on;
}
rtc {
enabled on;
rtmp_to_rtc on;
rtc_to_rtmp on;
}
}The line I am most likely to forget is candidate.
WebRTC needs to tell the remote browser where the media connection should go. If SRS advertises 127.0.0.1, a Docker-only address, or the wrong private interface, everything can look almost correct while the browser still cannot receive media.
For a public server, the advertised candidate normally has to resolve to the address the client can actually reach.
Publishing is the easy side
For OBS, the RTMP server can be as simple as:
rtmp://stream.example.com/liveand the stream key:
camera-01Together they form:
rtmp://stream.example.com/live/camera-01The same test can be made with FFmpeg:
ffmpeg -re -i input.mp4 \
-c:v libx264 -c:a aac \
-f flv \
rtmp://stream.example.com/live/camera-01For troubleshooting, I like this test because it removes OBS configuration from the problem. If FFmpeg can publish and SRS logs the stream, ingestion works.
HLS is just HTTP after SRS creates it
Once HLS is enabled, SRS can generate a playlist and media segments under its HTTP server.
Conceptually the playback URL becomes:
http://127.0.0.1:8080/live/camera-01.m3u8I do not normally expose that internal port directly. Nginx terminates HTTPS and proxies the public path to SRS.
For example:
server {
listen 443 ssl http2;
server_name stream.example.com;
ssl_certificate /etc/letsencrypt/live/stream.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/stream.example.com/privkey.pem;
location /live/ {
proxy_pass http://127.0.0.1:8080/live/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}The public HLS URL is then:
https://stream.example.com/live/camera-01.m3u8This is the familiar part of the system. HLS playback is delivered over HTTP, so HTTPS termination and reverse proxying fit the same model as a normal web application.
WebRTC is where the web-server mental model breaks
The signaling/API side of WebRTC can go through HTTPS.
The actual media path is different.
That is why this configuration can be perfectly valid:
Browser --HTTPS--> Nginx --HTTP--> SRSwhile the video still fails.
The browser also needs a network path similar to:
Browser --UDP/8000--> SRSNginx did not magically proxy that UDP traffic merely because I created an HTTPS virtual host.
This distinction explained a lot of the “the page loads, but there is no video” class of problems.
Reverse proxying the SRS API
If the frontend needs an SRS HTTP endpoint, I can expose only the routes I need through Nginx instead of opening port 1985 publicly.
A basic example:
location /srs-api/ {
proxy_pass http://127.0.0.1:1985/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}Whether I expose this at all depends on the application. The SRS API is infrastructure, not something I want to publish accidentally because it happened to be useful during debugging.
Running SRS as a service
Starting SRS manually is useful while configuring it:
./objs/srs -c conf/srs.confFor an actual server I want the process to start after reboot and restart if it crashes.
If I am managing the binary myself, a small systemd unit is easier for me to reason about than leaving a terminal command running forever:
[Unit]
Description=SRS Media Server
After=network.target
[Service]
Type=simple
User=srs
Group=srs
WorkingDirectory=/usr/local/srs
ExecStart=/usr/local/srs/objs/srs -c /usr/local/srs/conf/srs.conf
Restart=on-failure
RestartSec=3
LimitNOFILE=100000
[Install]
WantedBy=multi-user.targetThen:
sudo systemctl daemon-reload
sudo systemctl enable --now srs
sudo systemctl status srsAnd, more importantly when something is wrong:
journalctl -u srs -fA process manager is not the fix for a bad streaming configuration, but it removes “is the process even alive?” from the list of questions.
The firewall is part of the application
This was another useful shift in thinking.
For a normal website I can often think in terms of 80 and 443. Streaming forces me to treat network policy as part of the feature.
On a host using UFW, a deployment might need rules along these lines:
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 1935/tcp
sudo ufw allow 8000/udpI would not automatically add public rules for the internal API and HTTP ports if Nginx is the only process that should reach them.
And if this server lives in AWS, Azure, GCP, or another provider, the cloud firewall/security group must agree with the operating-system firewall. Fixing only one side is not fixing the path.
The debugging order that saves time
I used to jump directly into Nginx or browser errors. A better order is to test each boundary independently.
1. Is SRS listening?
ss -lntup | grep -E '1935|1985|8080|8000'I want to see the expected TCP listeners and the WebRTC UDP listener.
2. Can I publish RTMP?
Publish with FFmpeg or OBS, then watch the SRS logs.
If SRS never sees the publisher, HLS and WebRTC are not the problem yet.
3. Does HLS work locally?
curl -I http://127.0.0.1:8080/live/camera-01.m3u8If local HLS fails, changing Nginx is premature.
4. Does HLS work through the public domain?
curl -I https://stream.example.com/live/camera-01.m3u8Now I am testing DNS, TLS, Nginx, and the upstream path.
5. Only then debug WebRTC
For WebRTC I check:
- HTTPS is valid
- the browser can call the required HTTP endpoint
candidateis the correct public/reachable address- UDP
8000is open on the host firewall - UDP
8000is open in the cloud firewall/security group - NAT is not causing SRS to advertise an unreachable address
- the browser's WebRTC diagnostics show candidate connectivity instead of only JavaScript errors
This order turns a vague streaming problem into a specific broken hop.
Docker changes addresses, not the architecture
SRS also runs cleanly in Docker. A basic container exposes the same responsibilities:
docker run --rm -it \
-p 1935:1935 \
-p 1985:1985 \
-p 8080:8080 \
-p 8000:8000/udp \
-e CANDIDATE=YOUR_PUBLIC_IP \
ossrs/srs:5The dangerous assumption is that publishing the Docker port solves WebRTC addressing automatically.
It does not.
The browser still needs an ICE candidate it can reach. Container networking adds another address space, which makes the candidate value more important, not less.
What I would keep private in a real repository
The article can show the topology without publishing operational secrets.
I would keep these outside the post and outside public Git history:
- server public IP if I do not need it published
- private/internal IP addresses
- stream authentication secrets
- callback secrets
- API credentials
- certificate private keys
- cloud security-group identifiers
- production stream keys
A public example should use values such as:
stream.example.com
YOUR_PUBLIC_IP
camera-01not production credentials copied from /etc.
The mental model I am keeping
SRS became much easier once I stopped seeing it as “a special web server”.
It is a media router with several network surfaces:
RTMP publisher
|
v
+-------------+
| SRS |
+-------------+
| | |
| | +---- HTTP API
| |
| +--------- WebRTC / UDP
|
+-------------- HLS / HTTP-FLV
|
v
Nginx
|
v
HTTPSNginx is still useful. TLS is still important. Firewalls are still firewalls.
But the media path does not become HTTP just because the application around it is a website.
That is the part I want future me to remember.