3. Auslieferung mit Containern
Auslieferung mit Containern
Abschnitt betitelt „Auslieferung mit Containern“Der Server aus dem letzten Kapitel läuft und ist abgesichert, aber er tut nichts. Auf Ihrem Notebook liegt ein Projekt, das startet, aber nur dort. Dazwischen fehlt die Kette, um die es in diesen zwei Wochen geht: Aus dem Code im Repository wird ein Image, aus dem Image ein laufender Container, davor ein Reverse Proxy mit gültigem Zertifikat, und darüber ein Name, den man jemandem sagen kann.
Am Ende des Kapitels ist Ihr Projekt öffentlich erreichbar, auch wenn es noch aus einer Startseite besteht. Wichtiger als der Inhalt ist an dieser Stelle der Weg: Ein git push löst eine neue Version aus, und wenn diese Version kaputt ist, kommen Sie mit zwei Klicks zur vorherigen zurück.
Diese Kette wird früh im Jahr gebaut und danach nicht mehr grundlegend verändert. Alles, was in den folgenden Kapiteln entsteht, entsteht in sie hinein.
Warum überhaupt Container
Abschnitt betitelt „Warum überhaupt Container“Das Problem, das Container lösen, kennen Sie aus SEW als Halbsatz beim Abgeben: „Bei mir läuft es.” Und das stimmt sogar. Auf Ihrem Rechner liegt Node in Version 22, eine bestimmte Systembibliothek ist da, die Zeitzone steht auf Europe/Vienna, irgendwann haben Sie ein Paket global installiert und vergessen. Auf dem Server ist nichts davon so. Die Software ist dieselbe, die Umgebung nicht, und ein Programm läuft immer in beidem.
Ein Container packt die Umgebung mit ein. Was dabei entsteht, hat drei Begriffe, die man auseinanderhalten muss, weil sonst die halbe Fehlersuche im Nebel stattfindet.
Ein Image ist der Bauplan: unveränderlich, versioniert, aus einem Dockerfile erzeugt. Es enthält Betriebssystemschicht, Laufzeitumgebung, Ihren gebauten Code.
Ein Container ist eine laufende Instanz dieses Images. Er ist ein Prozess mit eigener Sicht auf Dateisystem, Netzwerk und Prozessliste, keine virtuelle Maschine. Wenn er stirbt, ist alles weg, was in ihm geschrieben wurde.
Ein Volume ist der Ort, an dem Daten das überleben. Alles, was länger leben soll als der Container, gehört dorthin: Datenbankdateien, hochgeladene Bilder, das Datenverzeichnis des CMS.
Daraus folgt die Regel, die den Rest des Kapitels trägt: Ein Container ist jederzeit ersetzbar. Beim nächsten Deployment wird er weggeworfen und durch einen neuen aus einem neuen Image ersetzt. Wer sich darauf verlässt, dass eine Datei im Container liegen bleibt, verliert sie beim übernächsten Push. Eine SQLite-Datei neben der Anwendung, ein Upload-Ordner ohne Volume, ein von Hand im laufenden Container installiertes Paket: alles drei ist beim nächsten Deployment verschwunden.
Das eigene Dockerfile
Abschnitt betitelt „Das eigene Dockerfile“Ein Dockerfile beschreibt, wie aus Ihrem Quellcode ein Image wird. Es in einem Schritt zu tun, funktioniert und erzeugt ein Image von über einem Gigabyte, in dem der komplette Quellcode, alle Entwicklungsabhängigkeiten und der Compiler liegen. Deshalb baut man in mehreren Stufen und nimmt am Ende nur mit, was zur Laufzeit gebraucht wird.
# 1. DependenciesFROM node:22-alpine AS depsWORKDIR /appCOPY package.json package-lock.json ./RUN npm ci
# 2. BuildFROM node:22-alpine AS buildWORKDIR /appCOPY --from=deps /app/node_modules ./node_modulesCOPY . .RUN npm run build
# 3. RuntimeFROM node:22-alpine AS runtimeWORKDIR /appENV NODE_ENV=productionRUN addgroup -g 1001 nodejs && adduser -u 1001 -G nodejs -S nextjsCOPY --from=build /app/public ./publicCOPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/staticUSER nextjsEXPOSE 3000CMD ["node", "server.js"]Vier Entscheidungen in dieser Datei sind erklärungsbedürftig.
Die Reihenfolge der COPY-Anweisungen ist kein Zufall. Docker speichert jede Schicht zwischen und baut ab der ersten geänderten Zeile neu. Weil package.json seltener geändert wird als der Quellcode, wird sie zuerst kopiert; npm ci läuft dann nur, wenn sich die Abhängigkeiten tatsächlich geändert haben. Wer stattdessen COPY . . an den Anfang setzt, installiert bei jedem Tippfehler alle Pakete neu und wartet drei Minuten statt zwanzig Sekunden.
Die standalone-Ausgabe von Next.js muss in next.config.js mit output: 'standalone' eingeschaltet werden. Next.js legt dann einen Ordner an, der nur die tatsächlich benutzten Teile von node_modules enthält, samt eigenem server.js. Der Unterschied liegt bei einem üblichen Projekt zwischen etwa 1,2 GB und 180 MB Image-Größe. Das ist keine Kosmetik: Dieses Image wird bei jedem Deployment gebaut, übertragen und gestartet.
Der eigene Benutzer. Ohne USER läuft der Prozess im Container als root. Das ist bequem und dieselbe Nachlässigkeit wie auf dem Server selbst, nur eine Ebene tiefer. Wer aus der Anwendung ausbricht, steht dann als root im Container statt als Benutzer ohne Rechte.
Die .dockerignore gehört daneben, sonst kopiert COPY . . alles mit, was nicht ins Image gehört:
node_modules.next.git.env.env.*docsDockerfileDer Eintrag .env ist der wichtigste. Ohne ihn landen Ihre Zugangsdaten im Image, und ein Image wird weitergegeben, veröffentlicht und in einer Registry abgelegt.
Bauen und lokal ausprobieren, bevor irgendetwas auf den Server geht:
docker build -t project:test .docker run --rm -p 3000:3000 --env-file .env project:testdocker images | grep project # how big is the result?Compose: mehrere Dienste
Abschnitt betitelt „Compose: mehrere Dienste“Ihr Projekt besteht nicht aus einem Prozess. Es braucht mindestens eine Datenbank, später ein CMS. docker compose beschreibt diese Dienste zusammen mit ihren Netzwerken und Volumes in einer Datei.
services: app: build: . environment: DATABASE_URL: postgres://project:${DB_PASSWORD}@db:5432/project depends_on: db: condition: service_healthy networks: [internal] restart: unless-stopped
db: image: postgres:17-alpine environment: POSTGRES_USER: project POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_DB: project volumes: - dbdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U project"] interval: 10s timeout: 5s retries: 5 networks: [internal] restart: unless-stopped
volumes: dbdata:
networks: internal:An dieser Datei lohnt sich ein genauer Blick, weil sie mehrere Punkte aus dem letzten Kapitel einlöst.
Die Datenbank hat kein ports:. Sie ist damit aus dem Internet nicht erreichbar, und zwar wirklich, nicht nur laut ufw. Erreichbar ist sie ausschließlich für Container im Netz internal, und dort unter ihrem Dienstnamen db. Genau das war die Falle aus Kapitel 2: Ein veröffentlichter Port umgeht die Hostfirewall. Der sauberste Umgang damit ist, ihn gar nicht erst zu veröffentlichen.
Das Volume dbdata ist der Grund, warum die Daten das nächste Deployment überleben. Ohne diese drei Zeilen liegt die Datenbank im Container, und der Container wird ersetzt.
Der Healthcheck sorgt dafür, dass die Anwendung erst startet, wenn die Datenbank Anfragen annimmt. depends_on allein wartet nur darauf, dass der Container gestartet wurde, nicht darauf, dass PostgreSQL innen bereit ist. Der Unterschied zeigt sich als Verbindungsfehler in den ersten Sekunden nach jedem Deployment.
Das Passwort steht nicht in der Datei, sondern kommt als ${DB_PASSWORD} aus der Umgebung. docker-compose.yml gehört ins Repository, .env nicht.
Damit ist auch die allgemeine Regel beantwortet, welche Konfiguration wohin gehört. Ins Image kommt, was zum Programm gehört und für alle Umgebungen gleich ist. In die Umgebung kommt, was sich zwischen lokal, Vorschau und Produktion unterscheidet: Adressen, Zugangsdaten, Schalter. In die Dokumentation kommt, welche Variablen es gibt und was sie bedeuten, und zwar in .env.example mit allen Namen und ohne einen einzigen echten Wert.
Reverse Proxy und TLS
Abschnitt betitelt „Reverse Proxy und TLS“Auf Ihrem Server laufen bald mehrere Container, aber ins Internet zeigen nur zwei Türen: Port 80 und Port 443. Der Reverse Proxy steht hinter diesen beiden Türen und verteilt eingehende Anfragen anhand des angefragten Namens auf die passenden Container. Nebenbei erledigt er die zweite Aufgabe, für die man ihn haben will: Er terminiert TLS, kümmert sich also um die Zertifikate, sodass die Container selbst nur einfaches HTTP im internen Netz sprechen.
In diesem Kapitel übernimmt das Traefik. Der Unterschied zu nginx liegt in der Konfigurationsweise: Traefik liest die Docker-Schnittstelle mit und erkennt neue Container an ihren Labels, statt auf eine von Hand gepflegte Konfigurationsdatei zu warten.
labels: - "traefik.enable=true" - "traefik.http.routers.app.rule=Host(`projekt.example.at`)" - "traefik.http.routers.app.entrypoints=websecure" - "traefik.http.routers.app.tls.certresolver=letsencrypt" - "traefik.http.services.app.loadbalancer.server.port=3000"Diese fünf Zeilen sagen: Für Anfragen an projekt.example.at ist dieser Container zuständig, sie kommen über HTTPS herein, das Zertifikat besorgt der ACME-Resolver, und intern lauscht der Container auf Port 3000.
Das Zertifikat holt Traefik automatisch bei Let’s Encrypt. Der Ablauf dahinter ist eine Besitzprüfung: Let’s Encrypt stellt eine Aufgabe, Traefik hinterlegt die Antwort unter dem angefragten Namen, Let’s Encrypt ruft sie ab. Nur wer den Namen tatsächlich kontrolliert, kann das. Zwei Voraussetzungen folgen daraus, und beide sind der häufigste Grund für ein ausbleibendes Zertifikat: Der DNS-Eintrag muss auf Ihren Server zeigen, und Port 80 muss von außen erreichbar sein.
Dazu kommen zwei Einstellungen, die kein Extra sind. Anfragen auf Port 80 werden dauerhaft auf HTTPS umgeleitet, damit niemand versehentlich unverschlüsselt arbeitet. Und der Header Strict-Transport-Security (HSTS) weist den Browser an, diesen Namen künftig gar nicht erst unverschlüsselt anzufragen. Beides taucht in Kapitel 9 bei den Security-Headern erneut auf, dort dann mit Begründung aus Angreifersicht.
sequenceDiagram participant B as Browser participant D as DNS participant T as Traefik participant C as Container B->>D: Wo ist projekt.example.at? D->>B: 203.0.113.10 B->>T: HTTPS-Anfrage an 203.0.113.10, Host: projekt.example.at T->>T: TLS beenden, Regel nach Host auswerten T->>C: HTTP-Anfrage im internen Netz, Port 3000 C->>T: Antwort T->>B: Antwort über TLS
Dieses Diagramm sollten Sie erklären können, und zwar an jeder Station. Es ist die Standardfrage bei der praktischen Leistungsfeststellung zu diesem Kapitel, und es ist auch die Landkarte für die Fehlersuche: Jede Station kann ausfallen, und jede fällt anders auf.
DNS und Domain
Abschnitt betitelt „DNS und Domain“Ein Name wird auf eine Adresse abgebildet, mehr macht DNS nicht. Für dieses Jahr brauchen Sie drei Eintragsarten.
| Eintrag | Bedeutung | Beispiel |
|---|---|---|
A | Name zeigt auf eine IPv4-Adresse | projekt.example.at → 203.0.113.10 |
AAAA | Name zeigt auf eine IPv6-Adresse | projekt.example.at → 2a01:4f8::1 |
CNAME | Name ist ein Verweis auf einen anderen Namen | www.example.at → example.at |
Der Wert, der in der Praxis am meisten Verwirrung stiftet, ist die TTL: Sie sagt, wie lange andere Server die Antwort zwischenspeichern dürfen. Eine Änderung wirkt deshalb nicht sofort, sondern in einem Zeitraum, der von der alten TTL abhängt. Wer einen Umzug plant, setzt die TTL einen Tag vorher herunter, stellt dann um und setzt sie danach wieder hoch. Wer das vergisst, wartet vier Stunden und ändert in der Zwischenzeit nervös Dinge, die in Ordnung waren.
Für das Projekt legen Sie am besten gleich Unternamen an, auch wenn Sie zunächst nur einen brauchen:
projekt.example.at die Anwendungapi.projekt.example.at der Webservice, falls getrennt (Kapitel 5)cms.projekt.example.at der Inhaltsbereich (Kapitel 14)dokploy.example.at die Dokploy-OberflächeDiagnose läuft über drei Befehle, und zwar in dieser Reihenfolge:
dig +short projekt.example.at # what does DNS actually say?curl -vI https://projekt.example.at # does a response arrive? which certificate?sudo journalctl -u docker -f # what does the server say about it?Der erste Befehl trennt zuverlässig die beiden Fälle, die sich für den Benutzer gleich anfühlen: Die Seite ist nicht erreichbar, weil der Name nicht auflöst, oder sie ist nicht erreichbar, weil dahinter nichts läuft.
Dokploy als Deployment-Oberfläche
Abschnitt betitelt „Dokploy als Deployment-Oberfläche“Alles bisher Beschriebene ließe sich von Hand betreiben: Compose-Dateien auf den Server kopieren, Traefik selbst konfigurieren, bei jedem Deployment einloggen und docker compose up -d --build tippen. Das funktioniert und ist nach dem dritten Mal lästig, fehleranfällig und schlecht nachvollziehbar.
Dokploy ist eine Oberfläche, die diese Handgriffe zusammenfasst. Sie läuft auf Ihrem eigenen Server, nicht in einer fremden Cloud, und übernimmt den Traefik samt Zertifikatsverwaltung, den Build aus dem Git-Repository, die Verwaltung der Umgebungsvariablen, Datenbanken als eigene Dienste, den Zugriff auf Logs, geplante Sicherungen und die Deployment-Historie.
-
Installieren. Das Installationsskript richtet Docker, Traefik und die Oberfläche ein:
Terminal-Fenster curl -sSL https://dokploy.com/install.sh -o install.shless install.sh # read it firstsudo sh install.shDer Zwischenschritt ist Absicht. Ein Skript aus dem Netz direkt in eine Shell zu leiten, ist genau der Umgang, vor dem Kapitel 2 warnt, und für einen Server, den Sie gerade abgesichert haben, eine seltsame erste Handlung.
-
Oberfläche absichern. Nach der Installation ist die Oberfläche auf einem Port erreichbar und mit dem ersten angelegten Konto verbunden. Legen Sie das Administratorkonto sofort an, bevor es jemand anderer tut, hängen Sie die Oberfläche an den Unternamen
dokploy.mit eigenem Zertifikat, und schließen Sie den direkten Port danach in der Firewall. -
Anwendung anlegen. Repository verbinden, Branch wählen, Bauart festlegen (Ihr
Dockerfile), Domain eintragen, Zertifikat aktivieren. -
Umgebungsvariablen eintragen. Alle Werte aus
.env.example, jetzt mit echten Werten. Sie liegen damit auf dem Server und nicht im Repository. -
Datenbank anlegen. PostgreSQL als eigener Dienst, mit Volume und Sicherung. Die Verbindungszeichenfolge trägt Dokploy in die Umgebung der Anwendung ein.
-
Deployment auslösen und die Ausgabe des Builds mitlesen. Beim ersten Mal geht selten alles durch, und das ist der lehrreiche Teil.
Ist das eingerichtet, wird ein Webhook im Repository hinterlegt: Bei jedem Push auf den Produktionsbranch baut und startet Dokploy neu. Damit ist die Kette geschlossen, die dieses Kapitel verspricht, von git push bis zur öffentlich erreichbaren Version.
Rollback
Abschnitt betitelt „Rollback“Jedes Deployment hinterlässt einen Eintrag in der Historie samt Image. Zurück auf die vorige Version zu gehen heißt deshalb, ein vorhandenes Image wieder zu starten, und das dauert Sekunden statt eines Neubaus.
Testen Sie das, solange es unwichtig ist. Liefern Sie absichtlich eine kaputte Version aus, rollen Sie zurück, und schreiben Sie in docs/betrieb.md, wie lange es gedauert hat und was genau Sie geklickt haben. Ein Rollback, den man zum ersten Mal unter Druck durchführt, ist kein Rollback, sondern ein Experiment zum ungünstigsten Zeitpunkt.
Eine Einschränkung gehört dazu, und sie wird in der 5. Klasse ausführlicher behandelt: Ein Rollback holt den Code zurück, nicht die Datenbank. Wenn die kaputte Version bereits eine Migration ausgeführt hat, die Spalten entfernt, dann läuft die alte Version danach gegen ein Schema, das sie nicht kennt. Deshalb sind Migrationen, die nur hinzufügen, im Betrieb deutlich angenehmer als solche, die wegnehmen. In Kapitel 6 kommen wir darauf zurück.
Produktion und Vorschau trennen
Abschnitt betitelt „Produktion und Vorschau trennen“Sobald das Projekt läuft, brauchen Sie einen Ort zum Ausprobieren, der nicht der öffentliche ist. Üblich sind zwei Umgebungen auf demselben Server: die Produktion am Hauptnamen mit dem Branch main, und eine Vorschau unter test.projekt.example.at mit dem aktuellen Arbeitsbranch. Beide bekommen eigene Umgebungsvariablen und, das ist der wichtige Teil, eigene Datenbanken. Eine Vorschauumgebung, die auf der Produktionsdatenbank arbeitet, ist keine Vorschau, sondern eine gut getarnte Produktion.
Wenn das Deployment scheitert
Abschnitt betitelt „Wenn das Deployment scheitert“Fehlersuche in einer Kette funktioniert nur der Reihe nach. Die Reihenfolge ist immer dieselbe, und sie folgt dem Weg der Anfrage rückwärts.
-
Build-Log. Ist das Image überhaupt entstanden? Fehler hier sind Kompilierfehler, fehlende Abhängigkeiten oder eine Variable, die zur Bauzeit gebraucht wird und fehlt.
-
Container-Log. Ist der Container gestartet und geblieben? Ein Container, der sofort wieder beendet ist, hat fast immer eine sprechende letzte Zeile.
-
Proxy-Log. Kommt die Anfrage bei Traefik an, und findet er eine Regel dafür? Hier zeigen sich falsche Namen, fehlende Labels und Zertifikatsprobleme.
-
Healthcheck und Netzwerk. Erreicht der Proxy den Container auf dem angegebenen Port, und antwortet der Dienst dort?
Die Fehlerbilder wiederholen sich, deshalb lohnt sich eine Tabelle:
| Symptom | Wahrscheinliche Ursache | Wo nachsehen |
|---|---|---|
| Build bricht ab | Abhängigkeit oder Bauzeit-Variable fehlt | Build-Log, .env.example |
| Container startet und endet sofort | falscher CMD, fehlende Variable, Absturz beim Start | Container-Log, letzte Zeile |
| 502 vom Proxy | Container läuft nicht oder lauscht auf anderem Port | Container-Log, Label server.port |
| 404 vom Proxy | Host-Regel passt nicht zum angefragten Namen | Traefik-Log, Router-Regel |
| Kein Zertifikat | DNS zeigt woanders hin oder Port 80 ist zu | dig, Firewall, ACME-Log |
| Seite lädt, Daten fehlen | falsche DATABASE_URL oder Netz getrennt | Container-Log, Compose-Netzwerke |
| Daten nach Deployment weg | Volume fehlt | Compose-Datei |
Häufige Stolperfallen
Abschnitt betitelt „Häufige Stolperfallen“- Geheimnisse im Image oder im Repository. Ein Image wird weitergegeben und ausgepackt. Zugangsdaten gehören in die Umgebung, sonst nirgendwohin.
- Datenbank ohne Volume. Funktioniert tadellos bis zum nächsten Deployment, dann sind die Daten weg. Ohne Sicherung sind sie endgültig weg.
- Ein Geheimnis mit
NEXT_PUBLIC_-Präfix. Es steht damit im ausgelieferten JavaScript und ist öffentlich, unabhängig davon, wie gut der Server abgesichert ist. - Zertifikatsversuche in Schleife, bis Let’s Encrypt sperrt. Erst DNS prüfen, dann Staging, dann Produktion.
- Container als
root. Funktioniert sofort und macht aus jeder Lücke in der Anwendung mehr, als sie sein müsste. - Port der Datenbank veröffentlichen, damit man lokal mit einem Werkzeug draufschauen kann. Damit steht sie im Internet, an
ufwvorbei. Der richtige Weg ist ein SSH-Tunnel. COPY . .vor der Abhängigkeitsinstallation, wodurch der Cache bei jeder Änderung verfällt und jedes Deployment Minuten kostet.- Rollback nie ausprobiert. Der erste Versuch findet dann während eines Ausfalls statt.
- Vorschau auf der Produktionsdatenbank. Der Testdatensatz landet in echten Daten, und meistens fällt es erst später auf.
Lernergebnisse
Abschnitt betitelt „Lernergebnisse“- Erklären: Image, Container und Volume unterscheiden und begründen, warum ein Container jederzeit ersetzbar sein muss.
- Anwenden: ein mehrstufiges
Dockerfilefür das eigene Projekt schreiben, das ohnerootläuft und ein schlankes Laufzeit-Image erzeugt. - Erklären: den Unterschied zwischen Bauzeit- und Laufzeitvariablen erklären und die Folgen für Geheimnisse benennen.
- Anwenden: mehrere Dienste mit Compose beschreiben, inklusive internem Netz, Volume und Healthcheck.
- Erklären: die Aufgabe eines Reverse Proxy beschreiben und den Weg einer Anfrage von der Domain über TLS bis zum Container an jeder Station erklären.
- Anwenden: DNS-Einträge für das eigene Projekt anlegen und mit
digundcurlprüfen. - Anwenden: Dokploy installieren, absichern, das Projekt aus dem Repository ausliefern und eine Domain mit gültigem Zertifikat verbinden.
- Anwenden: ein fehlgeschlagenes Deployment anhand von Build-, Container- und Proxy-Log eingrenzen und ein Rollback durchführen.
- Beurteilen: entscheiden, welche Konfiguration ins Image, welche in die Umgebung und welche in die Dokumentation gehört.
- Beurteilen: die Grenzen eines Rollbacks im Zusammenspiel mit Datenbankmigrationen einschätzen.
Arbeit am Jahresprojekt
Abschnitt betitelt „Arbeit am Jahresprojekt“Am Ende dieses Kapitels ist das Projekt öffentlich erreichbar, auch wenn es inhaltlich noch aus einer Startseite besteht. Abzugeben sind:
Dockerfileund.dockerignoreim Repository, das Image baut lokal und auf dem Server.docker-compose.ymlmit Anwendung und Datenbank, ohne veröffentlichte Datenbankports, mit Volume.- Die Anwendung läuft unter der eigenen Domain mit gültigem Zertifikat, HTTP leitet auf HTTPS um.
- Ein Push auf den Produktionsbranch löst ein Deployment aus.
- Ein durchgeführtes und dokumentiertes Rollback in
docs/betrieb.md, mit Zeitangabe. .env.examplevollständig,.envnirgends im Repository und in keinem Image.
Passende Übungen
Abschnitt betitelt „Passende Übungen“- Aufgabe 09 - Vom lokalen Projekt zum Image
- Aufgabe 10 - Compose mit Anwendung und Datenbank
- Aufgabe 11 - Dokploy, Domain und Zertifikat
- Aufgabe 12 - Deployment kaputt: Fehlersuche und Rollback