Zurück zum Blog

Vier stille Fehler beim Ausliefern einer PWA

Vier stille Fehler beim Ausliefern einer PWA

Datum 15. September 2026
Kategorie Web & Betrieb
Lesezeit 10 Minuten
Worum es geht: Ein selbstgebautes Arcade-Spiel im Stil der frühen Achtziger sollte unter einer eigenen Adresse laufen. Das Spiel selbst war fertig. Am Ausliefern hing trotzdem eine Woche Arbeit, weil vier Fehler nacheinander auftraten, die alle dasselbe gemeinsam hatten: Der Statuscode war 200, die Seite sah richtig aus, und trotzdem funktionierte etwas nicht. Das Spiel läuft unter vivien.matzka.cloud.

Die Ausgangslage

Das Spiel ist eine Progressive Web App: eine einzelne HTML-Datei mit Spiellogik, dazu Sprites und Musik, zusammen rund 44 MB. Es lässt sich als App installieren und läuft offline. Genau diese beiden Eigenschaften haben später die meisten Probleme verursacht, denn beide hängen am Service Worker.

Der Spielcode liegt in einem eigenen Repository, die Auslieferung in einem anderen. Das Spiel wird also nicht dort entwickelt, wo es ausgeliefert wird. Diese Trennung ist praktisch, weil sich beides unabhängig ändern lässt, und sie hat bei jedem einzelnen Problem geholfen: Man kann die Auslieferung reparieren, ohne den Spielcode anzufassen.

Warum ein eigener Container

Unter einer anderen Adresse laufen bereits acht kleine Browserspiele in einem nginx-Container. Der naheliegende Weg wäre gewesen, das neue Spiel dort mit hineinzulegen und die neue Domain auf denselben Container zu leiten. Die Vorgabe war aber, dass das Spiel ausschließlich unter der neuen Adresse erreichbar sein soll.

In einem gemeinsamen Container hätte diese Trennung an einer nginx-Regel gehangen. Ein nginx mit einem Wurzelverzeichnis liefert jede Datei unter jedem Hostnamen aus, der auf ihn zeigt. Man kann das mit einem eigenen server_name-Block einschränken, aber dann ist die Trennung eine Konfigurationszeile, die jemand versehentlich löschen kann.

Ein zweiter Container trennt dagegen bauartbedingt. Das kostet ein Image von etwa 45 MB und spart die Frage, ob die Regel noch stimmt. Zwei Nebeneffekte kamen dazu: Der Service Worker des Spiels bekommt einen sauberen eigenen Geltungsbereich, und die 44 MB Spieldaten blähen nicht das Image der anderen Spiele auf, das sonst bei jeder kleinen Änderung dort neu gebaut würde.

Die erste Fassung der nginx-Konfiguration setzte die Sicherheits-Header im server-Block und die Cache-Steuerung in mehreren location-Blöcken. Das sieht sauber aus und ist trotzdem falsch.

nginx vererbt add_header aus einem übergeordneten Block nur so lange, wie der passende location-Block kein eigenes add_header enthält. Sobald dort eines steht, verwirft nginx alle geerbten Header. Das ist dokumentiert, überrascht aber zuverlässig.

Dazu kommt eine Eigenheit, die den Fehler erst richtig gut versteckt: Die Direktive index index.html schreibt einen Aufruf des Wurzelpfads intern auf /index.html um. Damit greift die Location für HTML-Dateien, und die hatte ein eigenes add_header für die Cache-Steuerung. Ergebnis: Auf jeder HTML-Seite fehlten Content-Security-Policy, X-Frame-Options, X-Content-Type-Options und Referrer-Policy vollständig.

Gemessen, nicht vermutet: Beim Test im Container kamen null Sicherheits-Header zurück, bei Statuscode 200 und korrekt ausgelieferter Seite. Wer nach einem Deploy nur den Statuscode prüft, sieht davon nichts.

Die Lösung war nicht, das Header-Paket in jede Location zu kopieren. Stattdessen berechnet eine map den Wert für Cache-Control aus der angefragten URL, und alle Header stehen genau einmal im server-Block. Damit braucht keine Location mehr ein eigenes add_header, und die Falle kann nicht wiederkommen.

map $uri $cache_wert {
    default            "no-cache, no-store, must-revalidate";
    ~^/play/[^/]+/assets/  "public, max-age=2592000, immutable";
}

server {
    add_header Content-Security-Policy "..." always;
    add_header X-Frame-Options "DENY" always;
    add_header Cache-Control $cache_wert always;
    # in location-Bloecken steht bewusst kein add_header
}

Die Weiterleitung ins Leere

Später zog das Spiel von einem Pfad auf einen anderen um. Damit alte Links weiter funktionieren, kam eine Weiterleitung dazu. Im Container getestet sah sie richtig aus. Von außen geprüft lautete das Ziel dann so:

location: http://spiel.beispiel.de:8080/neuer/pfad/

Gleich drei Probleme in einer Zeile. Das Ziel ist unverschlüsselt, es nennt den internen Port des Anwendungscontainers, und dieser Port ist von außen gar nicht erreichbar. Die Weiterleitung lief also ins Leere, und nebenbei stand ein Stück interner Aufbau im Antwortkopf.

Der Grund ist eine Voreinstellung: nginx baut Weiterleitungsziele absolut aus Hostnamen und dem Port, auf dem es selbst lauscht. Hinter einem Reverse Proxy ist das immer falsch, weil der Proxy auf 443 hört und nginx auf etwas anderem. Zwei Zeilen stellen es ab, und das Ziel ist danach ein reiner Pfad, den der Browser gegen die aufgerufene Adresse auflöst.

absolute_redirect off;
port_in_redirect off;

Betroffen war nicht nur die neue Weiterleitung, sondern auch die automatische Verzeichnis-Weiterleitung, die nginx anlegt, wenn jemand den abschließenden Schrägstrich vergisst. Die gab es schon vorher, und sie hatte denselben Fehler. Innerhalb des Containers fiel nichts davon auf, weil dort localhost und der interne Port beide stimmen. Sichtbar wurde es erst beim Aufruf von außen.

Ein Pfad, der Platz für das zweite Spiel lässt

Zuerst lag das Spiel direkt unter /play/. Das funktioniert, solange es ein Spiel gibt. Beim zweiten müsste das erste umziehen, und ein Umzug bedeutet tote Links und, wie sich zeigen sollte, eine installierte App, die am alten Ort hängt.

Das Schema lautet jetzt /play/<kurzname>/, mit /play/ als reinem Sammelpfad. Ein zweites Spiel braucht damit einen Kopierbefehl im Dockerfile, eine Kachel auf der Startseite und eine Zeile im Überwachungsskript. Die Cache-Regeln sind schemabasiert geschrieben und müssen nicht angefasst werden.

Wichtiger als die Ordnung ist ein technischer Punkt: Jedes Spiel bekommt so seinen eigenen Geltungsbereich für den Service Worker. Lägen zwei Spiele gemeinsam unter /play/, teilten sie sich einen Worker und einen Cache und würden sich gegenseitig die Daten überschreiben.

Die alte Adresse leitet auf die neue weiter, bewusst mit Status 302 und nicht 301. Ein 301 bleibt dauerhaft im Browser gespeichert und stünde im Weg, falls unter /play/ irgendwann eine Übersicht aller Spiele stehen soll.

Reichweitenmessung mit Einwilligung

Für die Reichweitenmessung läuft Umami auf demselben Server. Auf der Hauptdomain wird das Messskript nur geladen, wenn der Besucher zugestimmt hat, und die Datenschutzerklärung nennt als Rechtsgrundlage die Einwilligung. Ein Skript, das auf einer weiteren Domain bedingungslos lädt, würde dieser Erklärung widersprechen.

Die neue Seite liest deshalb dasselbe Einwilligungs-Cookie, das auf der übergeordneten Domain gesetzt wird und auf allen Unterdomains lesbar ist. Ohne Zustimmung lädt nichts. Einen zweiten Cookie-Banner gibt es nicht: Wer direkt auf der Spieleseite einsteigt und nie auf der Hauptdomain zugestimmt hat, wird einfach nicht gezählt. Das kostet ein paar Zahlen und erspart einen weiteren Banner.

Die Datenschutzerklärung zählt die messenden Domains einzeln auf. Sie musste also mitgeändert werden, sonst wäre sie mit der dritten Domain schlicht unvollständig gewesen.

Bei der Content-Security-Policy gab es eine Abwägung. Das Spiel lädt nichts von außen, und genau das ist eine Eigenschaft, die man nicht leichtfertig aufgibt. Statt die Ausnahme für den Analytics-Host pauschal für die ganze Domain zu erlauben, hat die Konfiguration zwei Stufen: eine strenge als Normalfall und eine gelockerte für die Pfade, die tatsächlich messen. Wer später einen Pfad ergänzt, trifft damit eine bewusste Entscheidung.

Zwei Fallen mit Service Workern

Nachdem das Spiel die Messaufrufe eingebaut hatte, war serverseitig alles grün. Das Messskript kam mit Status 200 und korrektem Inhaltstyp, die Policy erlaubte den Host, die Überwachung meldete nichts. Im Browser war window.umami trotzdem nicht vorhanden. Keine Fehlermeldung, kein roter Eintrag in der Konsole.

Ein Abruf des Skripts aus der geöffneten Seite heraus brachte die Erklärung: Statt der erwarteten 4,7 KB JavaScript kamen 137 KB HTML zurück, beginnend mit <!DOCTYPE html>. Der Browser bekam die Startseite des Spiels als Skript geliefert und versuchte, HTML auszuführen.

Erste Falle: der Worker aus dem alten Pfad

Der Service Worker des Spiels hat, wie viele, eine Rückfallebene für den Offline-Betrieb. Schlägt ein Abruf fehl, liefert er die zwischengespeicherte Startseite aus. Für eine Seitennavigation ist das richtig. Für eine Bilddatei, eine Audiodatei oder ein Skript ist es nie richtig, weil der Aufrufer dann Inhalt eines völlig falschen Typs bekommt und der eigentliche Fehler unsichtbar wird.

Warum schlug der Abruf überhaupt fehl? Der kontrollierende Worker stammte noch vom alten Pfad. Vor dem Umzug lag das Spiel unter /play/ und registrierte seinen Worker dort, mit Geltungsbereich /play/. Dieser Bereich umfasst den neuen Pfad gleich mit. Bei jedem Besucher, der die Seite vor dem Umzug einmal geöffnet hatte, fing also weiterhin der alte Worker sämtliche Abrufe ab und beantwortete sie aus einem veralteten Cache.

Ein 404 reicht nicht. Die alte Worker-Datei gab es auf dem Server längst nicht mehr. Ein bereits installierter Service Worker verschwindet dadurch aber nicht zuverlässig. Der übliche Weg ist, an der alten Adresse ein gültiges Skript abzulegen, dessen einzige Aufgabe es ist, sich selbst abzumelden und die offenen Seiten neu zu laden.
self.addEventListener('install', () => self.skipWaiting());

self.addEventListener('activate', (event) => {
  event.waitUntil((async () => {
    await self.registration.unregister();
    const fenster = await self.clients.matchAll({ type: 'window' });
    for (const c of fenster) c.navigate(c.url);
  })());
});

// Solange dieser Worker noch lebt: nichts abfangen.
self.addEventListener('fetch', () => {});

Der leere fetch-Listener ist kein Versehen. Ohne respondWith holt der Browser normal aus dem Netz, und genau dieses Verhalten hatte vorher gefehlt.

Zweite Falle: die eingefrorene Policy

Nach dem Aufräumen war der alte Worker weg, und der richtige Worker kontrollierte die Seite. Das Skript kam trotzdem weiterhin als HTML zurück.

Ein Service Worker merkt sich die Content-Security-Policy, mit der sein eigenes Skript ausgeliefert wurde, zum Zeitpunkt der Installation. Sie gilt dann für alles, was er selbst aus dem Netz holt. Die Policy war zwar um den Analytics-Host erweitert worden, aber der Worker war vorher installiert worden und arbeitete weiter mit der alten, strengen Fassung. Sein Abruf wurde blockiert, und die Rückfallebene lieferte wieder die Startseite.

Entscheidend ist dabei: Der Browser ersetzt einen Worker nur, wenn sich die Bytes seines Skripts ändern. Eine reine Änderung an den Antwort-Headern löst keine Neuinstallation aus. Die Datei war seit Wochen unverändert, also blieb alles, wie es war.

Die Lösung liegt beim Bauen des Images: An das Worker-Skript wird eine Zeile mit der ausgelieferten Version angehängt. Bei jedem Deploy mit neuem Spielstand ändern sich damit die Bytes, der Worker wird neu installiert und übernimmt die aktuellen Header. Der Spielcode selbst bleibt unangetastet, die Zeile entsteht erst beim Bauen.

RUN printf '\n// deployed: %s\n' "${REVISION}" >> /pfad/zum/sw.js

Das ist auch unabhängig von der Policy sinnvoll. Ein neuer Stand der Anwendung sollte keinen Service Worker von vorgestern behalten, der weiterhin alte Dateien aus seinem Cache beantwortet.

Das gemeinsame Muster

Alle vier Fehler waren mit curl unsichtbar. Der Statuscode stimmte jedes Mal, die Antwortgröße war plausibel, die Überwachung meldete nichts. Drei davon wären erst Wochen später aufgefallen, und zwar Besuchern, nicht mir.

Zwei Prüfungen haben jeweils den Unterschied gemacht. Die erste ist der Blick von außen statt aus dem Container: Der fehlende Header und der falsche Weiterleitungs-Port zeigten sich beide erst, als der Aufruf denselben Weg nahm wie der eines Besuchers. Die zweite ist der Blick in den Browser statt auf die Antwort: Die Service-Worker-Probleme waren auf keiner Protokollebene sichtbar, weil auf dem Server nichts falsch lief.

Bei einer Progressive Web App kommt erschwerend hinzu, dass ein Teil der Anwendung im Browser des Besuchers weiterlebt und dort eigene Regeln mitbringt. Wer den Pfad einer PWA ändert, sollte den alten Geltungsbereich aufräumen, sonst schleppt jeder bisherige Besucher den alten Worker mit. Und wer Header ändert, von denen ein Worker abhängt, muss dafür sorgen, dass sich auch dessen Skript ändert.