Alle Artikel

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

2026 12 min LesezeitNGINXRTMPHLSPHPDebian

Ü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
  1. OBS verbindet sich mit der Application ingest. Der geheime Key steckt in der Server-URL, der öffentliche Streamname im Streamkey-Feld von OBS.
  2. NGINX ruft vor jedem Publish per on_publish ein PHP-Script auf, das nur auf localhost lauscht. Nur bei HTTP 2xx wird der Stream angenommen.
  3. Angenommene Streams gehen per internem push an die Application live, die daraus HLS erzeugt.
  4. NGINX liefert Player und HLS über HTTPS aus. Jede Anfrage unter /hls/ wird per auth_request gegen ein signiertes Cookie geprüft.
  5. 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=publish und app=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-cache auf Playlist und Segmenten
  • TLS: Let's Encrypt über certbot --nginx mit automatischer Verlängerung. Erneute Setup-Läufe binden das vorhandene Zertifikat wieder ein, statt ein neues anzufordern
  • Default-vHost: Ein eigener default_server leitet 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.

  1. Der Browser schickt POST /login mit dem Code.
  2. Das Script normalisiert den Code und prüft ihn mit password_verify() gegen einen gespeicherten Hash. Der Klartext liegt nirgends auf dem Server.
  3. Bei Erfolg setzt es das Cookie stream_auth=<ablaufzeit>.<hmac> mit HttpOnly, Secure und SameSite=Strict, gültig für 12 Stunden.
  4. Jede Anfrage auf /hls/ löst ein internes auth_request /_auth aus. Das Script prüft Format, Ablaufzeit und Signatur und antwortet mit 204 oder 401.
  5. GET /session sagt 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
  • /login hat 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_TITLE und werden serverseitig escaped
  • Vor dem Klick wird nichts vom Stream geladen (preload="none", im Browser nachgeprüft)
  • prefers-reduced-motion wird 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.1 in live: Die Auth lässt sich nicht durch direktes Publishen umgehen
  • deny play all auf 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 mit frame-ancestors 'self' und HSTS
  • RTMP-Limits: max_connections und drop_idle_publisher halten 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

  1. Distribution erkennen, bei nicht unterstützten Systemen abbrechen
  2. Ports 80, 443 und 1935 prüfen. Belegt sie ein anderer Prozess, bricht das Script mit Hinweis ab
  3. Pakete installieren, RTMP- und auth_request-Modul prüfen
  4. PHP 8.5 installieren und Mindestversion prüfen
  5. Verzeichnisse, NGINX-Konfiguration, PHP-Scripte und Player schreiben
  6. Stream-Key und Zugangscode beim ersten Lauf erzeugen
  7. Hls.js laden und prüfen
  8. Firewall einrichten, nginx -t ausführen, neu laden
  9. Zertifikat per Certbot holen, danach Default-vHost mit Umleitung aktivieren
  10. 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 include in der nginx.conf wird 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 ffmpeg mit 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-release ist 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

Zurück zu den Projekten