Infrastruktur
Livestream-Infrastruktur für das Abschlusskonzert 2026
Neuaufbau der Streaming-Plattform für den Grundschultag: ein Setup-Script, Zugangscode ohne Benutzerkonten und drei Wiedergabe-Standorte
Überblick
Für den Grundschultag 2025 habe ich eine selbst gehostete Live-Streaming-Infrastruktur aufgebaut. Am 7. November 2026 stand das Abschlusskonzert wieder an, also habe ich die Plattform ehrenamtlich neu aufgesetzt.
Die Grundidee blieb gleich: OBS Studio sendet per RTMP, NGINX erzeugt daraus HLS, der Browser spielt es mit Hls.js ab. Neu sind das Setup als einzelnes Script, der Zugangsschutz für Zuschauer, ein eigener Player und drei Wiedergabe-Standorte statt einem.
- Server: kleiner Cloud-Server mit 2 vCPU, 4 GB RAM und 40 GB SSD, Debian 13 (trixie)
- Stack: OBS, NGINX mit RTMP-Modul, HLS, NGINX mit HTTPS, Browser-Player mit Hls.js
- Authentifizierung: Stream-Key für das Einspeisen, Zugangscode für Zuschauer
- Latenz: etwa 4 bis 8 Sekunden (klassisches HLS)
Projekt-Kontext
Das Abschlusskonzert der Laacher-See-Schule (Realschule plus und FOS Mendig) bildet wie schon 2025 den Abschluss des Grundschultages. Der Platz am Konzertort reicht nicht für alle Gäste, deshalb wurde das Konzert komplett in die Schule übertragen. 2025 ging die Übertragung in die Mensa, 2026 an drei Standorte: Foyer, Mensa und Konferenzraum.
Aufbau vor Ort
Mehrere Kameras liefen zusammen mit Ton und Musik in eine einzige OBS-Instanz. Von dort ging ein Stream per RTMP zum Server, dieser Ingest war also der Upstream vom Konzert. Die Wiedergabe-Laptops an den drei Standorten holten das Signal unabhängig voneinander per HLS vom Server ab.
Kameras + Ton + Musik
│
▼
OBS Studio ──RTMP──► Streaming-Server ──HLS──┬─► Foyer
├─► Mensa
└─► Konferenzraum
- Signal: 1080p mit 50 fps, H.264 und AAC, rund 8 Mbit/s aus OBS
- Netzwerk: Die Laptops waren per LAN angebunden
- Bandbreite: Die drei Wiedergaben brauchen zusammen rund 24 Mbit/s, die Schule hat einen Gigabit-Download
Anforderungen
- Nur berechtigte Personen dürfen einspeisen
- Nur berechtigte Personen dürfen zuschauen, aber ohne Benutzerkonten: ein gemeinsamer Zugangscode
- Wiedergabe in jedem gängigen Browser, ohne App
- Die Wiedergabe soll vorab in den Vollbildmodus gehen und automatisch umschalten, sobald der Stream startet
- Vor dem Klick auf Start wird nichts vom Stream geladen, danach startet er direkt mit Ton
- Der Player braucht im Normalbetrieb kein CDN
- Das Setup ist reproduzierbar, mehrfach ausführbar und braucht keine Handarbeit auf dem Server
Architektur
OBS am Konzertort │ RTMP :1935 ▼ ┌─ Cloud-Server (Debian 13) ─────────────────────────────┐ │ │ │ nginx-rtmp: application ingest │ │ │ on_publish ──► rtmp-auth.php (nur 127.0.0.1) │ │ │ interner push │ │ ▼ │ │ nginx-rtmp: application live │ │ │ publish nur von 127.0.0.1 │ │ ▼ │ │ HLS: Playlist + Segmente (2 s) │ │ ▲ │ │ │ auth_request ──► viewer-auth.php │ │ NGINX HTTPS :443: Player + /hls/ │ │ │ └────────────────────────────────────────────────────────┘ ▲ │ POST /login (Zugangscode) │ GET /hls/* mit signiertem Cookie Browser mit Hls.js
- OBS verbindet sich mit der Application
ingest. Der geheime Key steckt in der Server-URL, der öffentliche Streamname im Streamkey-Feld von OBS. - NGINX ruft vor jedem Publish per
on_publishein PHP-Script auf, das nur auf localhost lauscht. Nur bei HTTP 2xx wird der Stream angenommen. - Angenommene Streams gehen per internem
pushan die Applicationlive, die daraus HLS erzeugt. - NGINX liefert Player und HLS über HTTPS aus. Jede Anfrage unter
/hls/wird perauth_requestgegen ein signiertes Cookie geprüft. - Das Cookie bekommt der Browser nur nach Eingabe des richtigen Zugangscodes.
Ingest: OBS, RTMP und Stream-Key
Das RTMP-Modul kommt als libnginx-mod-rtmp aus den Distributionspaketen. Die Konfiguration ist bewusst kurz:
rtmp {
server {
listen 1935;
chunk_size 4096;
notify_method post;
application ingest {
live on;
on_publish http://127.0.0.1:8080/rtmp-auth;
deny play all;
drop_idle_publisher 10s;
push rtmp://127.0.0.1:1935/live;
}
application live {
live on;
# Nur der interne Push darf publishen, sonst wäre die Auth umgehbar
allow publish 127.0.0.1;
deny publish all;
deny play all;
hls on;
hls_path /var/www/hls;
hls_fragment 2s;
hls_playlist_length 6s;
hls_cleanup on;
}
}
}
Streamname und Key getrennt
Der Streamname ist öffentlich, er wird zum Dateinamen der Playlist, zum Beispiel /hls/mensa.m3u8. Der Key steht
nur in der Server-URL. So taucht das Geheimnis nie in einer öffentlich sichtbaren URL auf.
- Server:
rtmp://stream.example.de/ingest?key=<STREAM_KEY> - Streamkey:
mensa - Keyframe-Intervall: 2 Sekunden, passend zu
hls_fragment 2s. Sonst werden Segmente länger und die Latenz steigt
Prüfung im PHP-Script
- Es akzeptiert nur
POST,call=publishundapp=ingest - Der Streamname wird Teil eines Dateinamens und läuft deshalb durch eine Regex, das verhindert Path Traversal
- Der Key-Vergleich läuft über
hash_equals(), damit es keine Timing-Lecks gibt - Jede Entscheidung wird mit Grund geloggt
- Die Key-Datei wird bei jedem Publish neu gelesen, ein Key-Wechsel braucht also keinen Neustart
Auslieferung: HLS, NGINX und TLS
- HLS: 2-Sekunden-Segmente und 6 Sekunden Playlist,
Cache-Control: no-cacheauf Playlist und Segmenten - TLS: Let's Encrypt über
certbot --nginxmit automatischer Verlängerung. Erneute Setup-Läufe binden das vorhandene Zertifikat wieder ein, statt ein neues anzufordern - Default-vHost: Ein eigener
default_serverleitet Aufrufe über die IP oder fremde Hostnamen per 301 auf die Domain um. Ausgenommen ist nur/.well-known/acme-challenge/ - HSTS: bewusst ohne
includeSubDomains, damit andere Subdomains nicht betroffen sind - Kein CORS-Header: Der erste Entwurf setzte
Access-Control-Allow-Origin: *. Das ist entfernt, damit fremde Seiten den Stream nicht einbinden können
Zugangsschutz ohne Benutzerkonten
HTTP Basic Auth schied aus, weil es immer einen Browser-Dialog mit Benutzername und Passwort zeigt. Gewünscht war ein einzelner Code. Eine IP-Beschränkung ging nicht, weil das öffentliche IP-Netz der Schule nicht bekannt war.
Die Lösung: ein Code-Feld auf der Startseite, Prüfung auf dem Server, ein signiertes Cookie und auth_request in NGINX.
- Der Browser schickt
POST /loginmit dem Code. - Das Script normalisiert den Code und prüft ihn mit
password_verify()gegen einen gespeicherten Hash. Der Klartext liegt nirgends auf dem Server. - Bei Erfolg setzt es das Cookie
stream_auth=<ablaufzeit>.<hmac>mitHttpOnly,SecureundSameSite=Strict, gültig für 12 Stunden. - Jede Anfrage auf
/hls/löst ein internesauth_request /_authaus. Das Script prüft Format, Ablaufzeit und Signatur und antwortet mit 204 oder 401. GET /sessionsagt dem Player beim Neuladen, ob er schon freigeschaltet ist.
location = /_auth {
internal;
fastcgi_pass_request_body off;
include fastcgi_params;
fastcgi_param CONTENT_LENGTH "";
fastcgi_param SCRIPT_FILENAME /var/www/script/viewer-auth.php;
fastcgi_param AUTH_ACTION check;
fastcgi_pass unix:/run/php/php8.5-fpm.sock;
}
# Mit Schrägstrich! Ohne passt der Präfix auch auf /hls.min.js
location /hls/ {
auth_request /_auth;
root /var/www;
add_header Cache-Control no-cache always;
}
Das Cookie ist zustandslos: Die Signatur ist ein HMAC-SHA256 über Version und Ablaufzeit. Damit braucht es weder Session-Speicher noch Datenbank.
function sign(int $exp, string $secret): string
{
return hash_hmac('sha256', 'v1|' . $exp, $secret);
}
function token_valid(string $secret): bool
{
$value = $_COOKIE['stream_auth'] ?? '';
if (!is_string($value) || !preg_match('/^(\d{1,12})\.([0-9a-f]{64})$/', $value, $m)) {
return false;
}
$exp = (int) $m[1];
return $exp >= time() && hash_equals(sign($exp, $secret), $m[2]);
}
- Auch die direkte URL einer Playlist oder eines Segments liefert ohne gültiges Cookie 401
- Wird der Code geändert, erzeugt das Script ein neues Secret, damit sind alle bisherigen Freischaltungen sofort ungültig
/loginhat ein Rate-Limit pro IP. Es ist bewusst großzügig, weil im Schulnetz alle Zuschauer hinter einer IP sitzen- Fehlversuche werden geloggt
Der Player
Der Player ist eine einzige statische HTML-Seite, ohne Framework und ohne Build-Schritt.
Hls.js
Die Library wird beim Setup in fester Version heruntergeladen und lokal abgelegt. Drei Download-Quellen werden nacheinander versucht, und jede Datei muss zu einer im Script hinterlegten SHA-256-Prüfsumme passen, sonst bricht das Setup ab. Fehlt die lokale Datei, lädt der Player vom CDN nach, abgesichert per Subresource Integrity. Safari nutzt natives HLS.
Startseite
- Konzert-Look: dunkler violetter Hintergrund mit wanderndem Bühnenlicht, goldener Titel in Serifenschrift
- Nur Systemschriften, keine Webfonts
- Texte kommen aus Variablen wie
EVENT_TITLEund werden serverseitig escaped - Vor dem Klick wird nichts vom Stream geladen (
preload="none", im Browser nachgeprüft) prefers-reduced-motionwird respektiert
Autoplay mit Ton
Browser erlauben Ton nur nach einer Nutzerinteraktion. Deshalb wird video.play() noch synchron im Klick-Handler
aufgerufen, auch wenn die Daten erst danach kommen. Das schaltet vor allem auf iPhone und iPad die Wiedergabe mit Ton frei.
Blockiert ein Browser trotzdem, startet der Player stumm und zeigt „Ton einschalten“.
Warteseite
- Läuft der Stream nach dem Login noch nicht, erscheint die Begrüßung mit animiertem Equalizer
- Der Player prüft alle 2,5 Sekunden per
HEAD-Request die Playlist und startet dann selbst - Ein 401 oder 403 dabei bedeutet eine abgelaufene Freischaltung und führt zurück zur Startseite
- Hängt ein laufender Stream länger als 8 Sekunden, gilt er als beendet und der Player kehrt zur Warteseite zurück
- Der Vollbild-Knopf ist schon hier verfügbar. So kann die Wiedergabe vorab eingerichtet werden, beim Start bleibt der Vollbildmodus erhalten
Rahmen und Steuerung
- Fester 16:9-Rahmen per
aspect-ratio, Overlays skalieren über Container Query Units - Steuerleiste mit Start/Pause, Ton, Lautstärke, LIVE-Anzeige und Vollbild
- Pause stoppt das Nachladen per
hls.stopLoad(), beim Weiterspielen springt der Player zurück auf live - Tastatur: Leertaste oder K für Pause, M für Ton, F für Vollbild
- Leiste und Mauszeiger blenden sich nach 3 Sekunden ohne Bewegung aus
Härtung
- Auth-Endpunkt nur auf 127.0.0.1: Die
on_publish-Prüfung ist von außen nicht ansprechbar allow publish 127.0.0.1inlive: Die Auth lässt sich nicht durch direktes Publishen umgehendeny play allauf beiden Applications: Niemand schaut per RTMP am Login vorbei zu- Geheimnisse unter
/etc: Sie liegen außerhalb des Webroots und sind über den Webserver nicht erreichbar server_tokens off,expose_php = Off: Header verraten keine Versionen- Security-Header:
X-Content-Type-Options,Referrer-Policy,X-Frame-Options, CSP mitframe-ancestors 'self'und HSTS - RTMP-Limits:
max_connectionsunddrop_idle_publisherhalten hängende oder massenhafte Verbindungen vom Dienst fern - PHP 8.5 mit Mindestversion: Das Setup bricht ab, wenn der aktuelle Sicherheitsrelease noch nicht verfügbar ist
Ein Detail: add_header wird in NGINX nicht aus übergeordneten Blöcken geerbt, sobald ein Block ein eigenes
add_header hat. Deshalb liegen die Security-Header in einem Snippet, das jeder betroffene
location-Block zusätzlich einbindet.
Bewusst nicht umgesetzt: RTMPS
Der Stream-Key geht per RTMP im Klartext vom Konzertort zum Server. Das Bedrohungsmodell waren Dritte im Internet, nicht jemand, der im selben Netz mitschneidet. Für eine einzelne Veranstaltung ist das ein vertretbares Restrisiko: Der Key wurde danach neu generiert und der Server nach dem Event abgeschaltet.
Für eine spätere Umsetzung ist ein Detail wichtig: Ein TLS-Proxy vor RTMP würde sich von 127.0.0.1 verbinden und damit die
Ausnahme in live treffen. Er müsste also von einer anderen lokalen Adresse kommen.
Ein Script für alles
setup.sh richtet den kompletten Server ein und kann gefahrlos mehrfach ausgeführt werden.
# nur HTTP, zum Beispiel intern
sudo bash setup.sh
# Produktion
sudo DOMAIN=stream.example.de EMAIL=<EMAIL> bash setup.sh
# Code oder Texte ändern
sudo DOMAIN=stream.example.de EMAIL=<EMAIL> VIEWER_CODE=<ZUGANGSCODE> \
EVENT_LABEL="Grundschultag" bash setup.sh
Ablauf
- Distribution erkennen, bei nicht unterstützten Systemen abbrechen
- Ports 80, 443 und 1935 prüfen. Belegt sie ein anderer Prozess, bricht das Script mit Hinweis ab
- Pakete installieren, RTMP- und
auth_request-Modul prüfen - PHP 8.5 installieren und Mindestversion prüfen
- Verzeichnisse, NGINX-Konfiguration, PHP-Scripte und Player schreiben
- Stream-Key und Zugangscode beim ersten Lauf erzeugen
- Hls.js laden und prüfen
- Firewall einrichten,
nginx -tausführen, neu laden - Zertifikat per Certbot holen, danach Default-vHost mit Umleitung aktivieren
- Zusammenfassung mit OBS-Daten, Code und Player-URL ausgeben
Idempotenz
- Konfigurationsdateien werden bei jedem Lauf neu geschrieben, Änderungen gehören also ins Script und nicht auf den Server
- Stream-Keys, Zugangscode und Zertifikat bleiben erhalten
- Zugangscode und Secret werden nur neu geschrieben, wenn sich der Code tatsächlich ändert
- Der
includein dernginx.confwird nicht doppelt angelegt
Entscheidungen und verworfene Alternativen
- Klassisches HLS mit 4 bis 8 Sekunden Latenz: nginx-rtmp kann kein LL-HLS. Für eine Übertragung in andere Räume ohne Interaktion reicht das
- MediaMTX mit LL-HLS, verworfen: mehr Umbau ohne echten Nutzen für diesen Anlass
- WebRTC per WHIP/WHEP, verworfen: braucht UDP-Ports und wäre im Schulnetz mit restriktiver Firewall am fehleranfälligsten
- Zugangscode mit HMAC-Cookie: kein Session-Store und keine Datenbank, Widerruf läuft über einen Secret-Wechsel
- Hls.js lokal statt vom CDN: unabhängig von CDNs und mit geprüfter Integrität
- Bash-Script statt Container: nachvollziehbar, kein zusätzlicher Container-Stack, läuft direkt mit den Distributionspaketen
Stolpersteine
Debian statt Ubuntu
Problem: Das Script war zuerst für Ubuntu geschrieben. Auf Debian 13 gibt es
software-properties-common nicht, und PPAs passen dort ohnehin nicht.
Lösung: Das Script erkennt die Distribution und richtet unter Debian das Sury-Repository mit eigenem Keyring und deb822-Quelle ein.
Sicherheitsrelease am selben Tag
Problem: Das PHP-Repository hinkt neuen Releases manchmal hinterher.
Lösung: Statt auf eine Paketrevision zu pinnen, prüft das Script die installierte Version und bricht mit klarer Meldung ab.
location /hls passt auf /hls.min.js
Problem: Ein NGINX-Präfix ohne Schrägstrich hätte die Hls.js-Datei hinter den Login gesperrt. Das fiel erst im Test auf.
Lösung: location /hls/ mit Schrägstrich.
Hls.js-Download still fehlgeschlagen
Problem: In einer frühen Version hat das Script nur gewarnt, die Seite lief dann ohne Player-Bibliothek.
Lösung: Mehrere Quellen, Prüfsumme und Abbruch, wenn nichts klappt.
Overlays in den schwarzen Balken
Problem: Solange das Video nur per object-fit ins Fenster eingepasst wurde, lagen Badge und Buttons
auf Bildschirmen außerhalb von 16:9 neben dem Bild. Im festen Rahmen war dagegen die Startseite auf dem Handy hochkant zu klein.
Lösung: Fester 16:9-Rahmen für das Video und die Startseite als eigene Vollbildebene außerhalb des Rahmens.
Kleinere Fallen
- Die Hintergrundanimation erzeugte Scrollbalken, weil das Element über den Rand ragte. Jetzt wird nur die Hintergrundposition animiert
- Docker auf demselben Server kann Ports belegen, deshalb die Portprüfung vor der Installation
- Ein AAAA-Record, der nicht auf den Server zeigt, lässt die Let's-Encrypt-Validierung scheitern, obwohl IPv4 stimmt
- Ein NGINX-Reload während des Streams trennt OBS. Am Veranstaltungstag also kein Setup-Lauf
Kapazität und Kosten
- CPU: sehr geringe Last, weil nicht transkodiert wird. NGINX verpackt nur um
- Traffic: rund 3,6 GB pro Stunde und Wiedergabe bei 8 Mbit/s
- Upload am Konzertort: dauerhaft die volle Stream-Bitrate, am besten mit Reserve
Der Engpass ist nicht der Server, sondern der Upload am Veranstaltungsort. Ein kleiner Cloud-Server für wenige Euro im Monat reicht vollständig.
Tests
Getestet wurde in einer Testumgebung mit echtem NGINX, RTMP-Modul und PHP-FPM.
- Stream-Key: Ein falscher Key wird abgewiesen, ein richtiger erzeugt HLS-Segmente. Als Teststream diente
ffmpegmit Testbild und Sinuston - Zugangsschutz: Playlist und Segmente liefern ohne Cookie 401, mit Cookie 200. Gefälschte und abgelaufene Cookies werden abgelehnt, das Rate-Limit greift
- Setup-Läufe: Code und Secret bleiben erhalten, ein neuer Code ersetzt das Secret
- Browser-Tests mit Playwright: keine Requests an
/hls/vor dem Login, Fehlermeldungen, Neuladen mit gültigem Cookie, Rückkehr nach Ablauf, Vollbild auf der Warteseite und Darstellung in mehreren Bildschirmgrößen
Eine Einschränkung blieb: Die eigentliche Videowiedergabe ließ sich im Testbrowser nicht prüfen, weil dort der H.264-Decoder fehlt. Deshalb gab es am Vortag einen Probedurchlauf, und der war erfolgreich.
Ergebnis
Am 7. November 2026 lief die Übertragung ohne Probleme. Das Konzert wurde wie 2025 komplett übertragen, diesmal an alle drei Standorte. Viele Eltern waren vor Ort, die Veranstaltung war reichlich besucht. Zuschauerzahlen habe ich nicht erhoben.
Nach dem Event wurde der Stream-Key neu generiert und der Server abgeschaltet.
Wichtigste Learnings
- Ein Script als einzige Quelle für das Setup macht den Neuaufbau planbar
- Annahmen prüfen: Ein Blick in
/etc/os-releaseist schneller als der Umbau danach - Interne RTMP-Applications brauchen eigene Zugriffsregeln, sonst lässt sich die Authentifizierung umgehen
- Viele Fehler zeigen sich erst im echten Durchlauf, nicht beim Lesen der Konfiguration
Technologie-Stack
NGINX
RTMP-Modul, HLS und auth_request
RTMP
Stream-Ingest von OBS Studio
HLS
Browser-Wiedergabe
PHP 8.5
Authentifizierung für Publisher und Zuschauer
Hls.js
Player, lokal mit Prüfsumme abgelegt
Debian 13
Server-Betriebssystem