Infrastructure as Code (IaC)

Was ist Infrastructure as Code?

Infrastructure as Code (IaC) ist der Ansatz, IT-Infrastruktur (Netzwerke, virtuelle Maschinen, Firewalls, Container-Landschaften) nicht mehr manuell über Benutzeroberflächen zu konfigurieren, sondern durch maschinenlesbare Definitionsdateien. Infrastruktur wird somit wie Software-Quelltext behandelt: Sie ist versionierbar, testbar und reproduzierbar.

Warum IaC?

  • Geschwindigkeit & Effizienz: Die Bereitstellung komplexer Umgebungen dauert Sekunden statt Stunden.
  • Fehlervermeidung: Manuelle Konfigurationsfehler ("Fat-Finger-Errors") werden minimiert, da der Prozess standardisiert ist.
  • Dokumentation: Der Code selbst ist die Dokumentation. Man sieht sofort, welche Ports offen sind und welche Datenbankversion läuft.
  • Skalierbarkeit: Eine Konfiguration kann identisch auf 1, 10 oder 100 Server ausgerollt werden.

Kernkonzept: Idempotenz

Ein zentrales Prinzip von IaC ist die Idempotenz. Ein Skript oder ein Tool (wie Ansible oder Terraform) gilt als idempotent, wenn es bei mehrfacher Ausführung immer zum selben Zielergebnis führt, ohne den Systemzustand negativ zu verändern.

Beispiel: Ein Skript sollte nicht einfach eine Zeile "Port 8080 offen" an eine Datei anhängen. Es sollte prüfen: "Ist der Port schon offen? Falls nein, öffne ihn. Falls ja, tu nichts."

Vermeidung von Redundanz (DRY-Prinzip)

Das Prinzip "Don't Repeat Yourself" (DRY) besagt, dass Informationen (wie Passwörter oder IP-Adressen) nur an einer einzigen Stelle definiert werden sollten.

In unserem Projekt nutzen wir dafür die .env-Datei.

Anstatt die IP-Adresse der VM manuell in die Firewall, die HTML-Datei und die App-Konfiguration zu schreiben, nutzen wir Variablen. Das vermeidet Inkonsistenzen: Ändert sich die IP an einer Stelle, wird sie durch das Skript überall korrekt übernommen.

IaC und die Integration in den DevOps-Lebenszyklus

Versionierung mit Git: Das "Gedächtnis" der Infrastruktur

Indem wir Infrastruktur als Code definieren, können wir Tools wie Git nutzen. Dies hat weitreichende Vorteile für die Zuverlässigkeit eines Systems:

  • Rückverfolgbarkeit (Auditing): Jede Änderung an der Firewall oder der Datenbank-Konfiguration ist mit einem Zeitstempel und einem Namen versehen. Man weiß immer, wer was wann geändert hat.
  • Rollback-Fähigkeit: Sollte ein Deployment fehlschlagen, kann man mit einem Befehl (git checkout) zu einer stabilen Version der Infrastruktur zurückkehren.
  • Collaboration: Mehrere Teammitglieder können gleichzeitig an verschiedenen Teilen der Infrastruktur arbeiten, Änderungen in "Branches" testen und sie erst nach einer Prüfung (Code Review) in das Live-System mergen.

Der Weg zu GitOps

Wenn IaC konsequent mit Versionierung kombiniert wird, spricht man oft von GitOps. Hierbei ist das Git-Repository die "Single Source of Truth". Sobald Code in das Repository gepusht wird, erkennt ein automatisches System die Änderung und gleicht den Ist-Zustand des Servers automatisch an den im Code definierten Soll-Zustand an.

Fazit: Infrastruktur ist Software

Die Grenze zwischen Softwareentwicklung und Systemadministration verschwimmt. Ein moderner IT-Experte schreibt keine Befehlslisten mehr, sondern entwickelt Infrastruktur-Systeme, die sich selbst verwalten, heilen und skalieren.

Der sichere Umgang mit Umgebungsvariablen (.env)

Die .env-Datei fungiert als Schnittstelle zwischen Code und Umgebung. Sie trennt die Logik (den Bauplan) von den sensiblen Daten (den Geheimnissen).

Trennung von Code und Konfiguration

Das wichtigste Prinzip lautet: Sensible Daten gehören niemals direkt in den Quellcode.

  • Warum? Würden Passwörter direkt in der docker-compose.yml stehen, wären sie für jeden sichtbar, der Zugriff auf das Git-Repository hat.
  • Lösung: Wir nutzen Platzhalter in den Dateien (z. B. ${DB_PASSWORD}) und definieren die echten Werte ausschließlich in der lokalen .env-Datei.

Sicherheit und Git (.gitignore)

Eine .env-Datei darf niemals in ein öffentliches Git-Repository hochgeladen (gepusht) werden.

  • Die Praxis: Man fügt den Dateinamen .env in die Datei .gitignore ein. Git ignoriert diese Datei dann beim Hochladen.
  • Die Dokumentation: Damit andere wissen, welche Variablen sie benötigen, erstellt man eine Vorlage namens .env.example. Diese enthält die Variablennamen, aber keine echten Passwörter.

Injektion beim Deployment

Im IaC-Prozess wird die .env-Datei erst auf dem Zielserver (der VM) erstellt oder ausgefüllt.

  • In unserem Projekt greift das Deployment-Skript auf diese Datei zu, um:
    1. Die Variablen in die Container zu "injizieren".
    2. Platzhalter in anderen Dateien (wie in der index.html) zu ersetzen.
  • Vorteil: Man kann dasselbe Repository für "Entwicklung", "Testing" und "Produktion" nutzen – man tauscht lediglich die .env-Datei aus.

Schutz auf dem Host-System

Da die .env Passwörter im Klartext enthält, müssen die Dateirechte auf dem Linux-Server restriktiv gesetzt werden: chmod 600 .env

Konkretes Beispiel: das APP_SECRET

Ein gutes Beispiel für ein solches Geheimnis ist im Beleg das APP_SECRET des Analytics-Werkzeugs Umami. Es zeigt den kompletten Weg eines Secrets – von der .env bis in den laufenden Container – an einer einzigen Variablen.

Wozu dient es? APP_SECRET ist der geheime Schlüssel, mit dem Umami die Login-Sitzungen signiert (technisch: die JSON Web Tokens, JWT, des Dashboards). Meldet man sich im Umami-Dashboard an, wird das Sitzungs-Token mit diesem Schlüssel signiert; der Server erkennt damit später, dass ein Token echt und unverändert ist. Wer den Schlüssel kennt, könnte gültige Sitzungen fälschen – deshalb muss er geheim und zufällig sein.

Der Weg durch das System (genau die in diesem Kapitel beschriebene Trennung):

  1. Definiert – nur an einer Stelle, in der .env (nicht im Git!):
    # min. 32 Zeichen, zufällig – z. B. erzeugt mit:  openssl rand -base64 36
    APP_SECRET=a8f7c6b5d4e3...z210
    
  2. Injiziert – die compose.yaml reicht den Wert als Umgebungsvariable nur in den umami-Container (nicht in db oder website):
    umami:
      environment:
        APP_SECRET: ${APP_SECRET}   # Platzhalter, ersetzt aus der .env
    
  3. Genutzt – Umami liest die Variable APP_SECRET beim Start aus seiner Umgebung und verwendet sie zum Signieren der Sitzungs-Tokens.

Was man daraus lernt:

  • Der echte Wert steht nie im Code/Repository, sondern wird erst beim Deployment aus der .env „injiziert" – Logik (Bauplan) und Geheimnis bleiben getrennt.
  • Ändert man APP_SECRET nachträglich, werden alle bestehenden Logins ungültig (die alten Tokens lassen sich nicht mehr verifizieren) – jeder muss sich neu anmelden.
  • APP_SECRET ist nicht dasselbe wie DB_PASSWORD: Das DB-Passwort authentifiziert Umami gegenüber der Datenbank (über die DATABASE_URL), während APP_SECRET die Sitzungen der Dashboard-Nutzer absichert. Zwei verschiedene Geheimnisse für zwei verschiedene Zwecke.

Zusammenfassung für die Praxis

IaC verwandelt "Wissen in den Köpfen der Admins" in "Code im Repository". Im Projekt soll dies durch die Kombination aus Bash-Skripten (Automatisierung), Environment-Variablen (Abstraktion) und Docker-Compose (Orchestrierung) umgesetzt.

Typische Bausteine einer (containerisierten) Web-Anwendung

Im ersten Teil ging es um das Prinzip Infrastructure as Code: Wie beschreibt man Infrastruktur als versionierbaren, reproduzierbaren Code? Dieser zweite Teil schaut auf den Inhalt, der typischerweise so ausgerollt wird.

Eine reale Web-Anwendung besteht selten aus einem einzigen Programm. Sie setzt sich meist aus mehreren spezialisierten Diensten zusammen, die jeweils eine klar abgegrenzte Aufgabe übernehmen und zusammenarbeiten. Klassisch unterscheidet man drei Schichten (three tier):

Schicht Aufgabe Beispiel-Technologie
Präsentation Auslieferung von Inhalten an den Browser (HTTP) Webserver (z. B. Nginx)
Anwendungslogik Verarbeitung der eigentlichen Geschäftslogik Applikations-Server / Backend
Daten Dauerhafte, strukturierte Speicherung Datenbank (z. B. PostgreSQL)

Dazu kommen oft Querschnitts-Dienste, die keine eigene Schicht bilden, aber gebraucht werden – etwa Web-Analytics zur Messung der Nutzung.

Wichtig: Das Drei-Schichten-Modell ist ein Referenzmodell, kein Zwang. Nicht jede Anwendung besitzt alle drei Schichten als eigenen Dienst – Schichten können fehlen oder in einem Dienst zusammenfallen.

Ein einfacher Stack aus Webserver + Web-Analytics + Datenbank hat zum Beispiel keine eigene Schicht für Anwendungslogik:

  • Der Webserver liefert hier nur statische Inhalte aus (fertige HTML-Dateien) – er führt keine eigene Geschäftslogik aus.
  • Der Analytics-Dienst ist eine in sich geschlossene Anwendung, die ihre Logik selbst mitbringt; er ist kein generischer Applikations-Server für die ausgelieferte Seite.
  • Die Präsentations- und die Datenschicht sind also vorhanden, eine eigenständige Anwendungslogik-Schicht dagegen nicht.

Eine eigene Anwendungslogik-Schicht käme erst dann hinzu, wenn die Seite dynamische, selbst programmierte Inhalte hätte (z. B. ein Backend, das Benutzereingaben verarbeitet und mit der Datenbank arbeitet). Behalten Sie das im Hinterkopf, wenn Sie die folgenden Bausteine in einem konkreten Aufbau wiedererkennen: nicht jede Schicht des Modells muss tatsächlich besetzt sein.

Im Sinne von IaC wird jeder dieser Dienste als eigener Container verpackt und gemeinsam über eine Compose-Datei orchestriert (siehe die Notebooks Container am Beispiel Podman und Docker/Podman Compose).

Die folgenden Abschnitte erklären drei dieser Bausteine allgemein – jeweils am Beispiel einer konkreten, weit verbreiteten Technologie:

  • Datenbankmanagementsysteme am Beispiel PostgreSQL
  • Webserver am Beispiel Nginx
  • Web-Analytics am Beispiel Umami

Datenbankmanagementsysteme (DBMS) am Beispiel PostgreSQL

Was ist eine Datenbank und ein DBMS?

Sobald eine Anwendung Daten dauerhaft und strukturiert speichern muss – Benutzerkonten, Bestellungen, Messwerte – stößt man mit einfachen Dateien schnell an Grenzen. Man müsste sich selbst um Suchen, gleichzeitige Zugriffe, Konsistenz und Ausfallsicherheit kümmern.

Diese Aufgabe übernimmt ein Datenbankmanagementsystem (DBMS): eine spezialisierte Software, die Daten verwaltet und Anwendungen einen kontrollierten, effizienten Zugriff darauf bietet. Die Datenbank ist die Sammlung der Daten selbst; das DBMS ist das Programm, das sie verwaltet.

Das relationale Modell

Der häufigste Typ ist das relationale DBMS (RDBMS). Daten werden in Tabellen (engl. tables) organisiert:

  • Eine Tabelle beschreibt einen Typ von Objekt (z. B. benutzer).
  • Jede Zeile (row, Datensatz) ist eine konkrete Ausprägung (ein Benutzer).
  • Jede Spalte (column) ist ein Attribut mit festem Datentyp (z. B. email).
  • Ein Primärschlüssel (primary key) identifiziert jede Zeile eindeutig.
  • Ein Fremdschlüssel (foreign key) verknüpft Tabellen miteinander – daher relational (Beziehungen zwischen Tabellen).
Tabelle "benutzer"
 id (PK) | name      | email
 --------+-----------+--------------------
 1       | Alice     | alice@example.org
 2       | Bob       | bob@example.org

SQL – die Abfragesprache

Mit relationalen Datenbanken kommuniziert man über SQL (Structured Query Language). Die wichtigsten Operationen (oft CRUD genannt):

SQL-Befehl Aufgabe (CRUD)
INSERT Daten anlegen (Create)
SELECT Daten lesen/abfragen (Read)
UPDATE Daten ändern (Update)
DELETE Daten löschen (Delete)
SELECT name, email FROM benutzer WHERE id = 1;

Client-Server-Architektur

Ein DBMS läuft typischerweise als Server-Prozess, der auf einem Netzwerk-Port lauscht und auf Verbindungen von Client-Anwendungen wartet. Die Anwendung verbindet sich über eine Verbindungszeichenkette (connection string), die Host, Port, Benutzer, Passwort und Datenbankname enthält:

postgresql://benutzer:passwort@host:5432/datenbankname

Weil mehrere Clients gleichzeitig zugreifen, sorgt das DBMS über Transaktionen für Korrektheit. Üblich ist die Garantie der ACID-Eigenschaften:

  • Atomicity – eine Transaktion wird ganz oder gar nicht ausgeführt.
  • Consistency – die Datenbank bleibt in einem gültigen Zustand.
  • Isolation – gleichzeitige Transaktionen stören sich nicht.
  • Durability – einmal bestätigte Daten gehen nicht verloren.

Warum ein DBMS statt einfacher Dateien?

  • Mehrbenutzerbetrieb: kontrollierte, gleichzeitige Zugriffe ohne Datensalat.
  • Integrität: Datentypen, Schlüssel und Bedingungen verhindern Unsinn.
  • Effiziente Abfragen: auch große Datenmengen werden über Indizes schnell durchsucht.
  • Datensicherheit: Rechteverwaltung, Transaktionen, Backups.

PostgreSQL konkret

PostgreSQL ("Postgres") ist ein freies, quelloffenes, objekt-relationales DBMS. Es gilt als besonders standardkonform, robust und funktionsreich und ist eines der meistgenutzten DBMS für Web-Anwendungen.

  • Standard-Port: 5432.
  • Verwaltet Rollen/Benutzer, Datenbanken und Tabellen.
  • Beim ersten Start lassen sich Benutzer, Passwort und initiale Datenbank über Konfiguration bzw. Umgebungsvariablen festlegen (POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB).

Bezug zu Containern & Persistenz: Die Daten eines DBMS müssen einen Neustart oder Austausch des Containers überleben. Deshalb legt man das Datenverzeichnis der Datenbank immer in ein Volume (siehe Container-Notebook) – sonst wäre die Datenbank nach jedem Container-Neustart leer.

Webserver am Beispiel Nginx

Was ist ein Webserver?

Ein Webserver ist ein Programm, das das HTTP-Protokoll (bzw. das verschlüsselte HTTPS) "spricht". Er wartet auf Anfragen (requests) von Clients – in der Regel Webbrowsern – und beantwortet sie mit den angeforderten Ressourcen (HTML-Seiten, Bilder, CSS, JavaScript, …) in einer Antwort (response).

Browser  ──── HTTP-Request:  GET /index.html ───►  Webserver
Browser  ◄─── HTTP-Response: 200 OK + HTML  ─────  Webserver

Wichtige Grundbegriffe:

  • Ports: HTTP nutzt standardmäßig Port 80, HTTPS Port 443.
  • Statuscodes: signalisieren das Ergebnis – z. B. 200 OK, 404 Not Found, 500 Internal Server Error.
  • Document Root: das Verzeichnis im Dateisystem, aus dem der Server Dateien ausliefert (bei Nginx z. B. /usr/share/nginx/html).

Statische und dynamische Inhalte

  • Statische Inhalte: fertige Dateien, die unverändert ausgeliefert werden (HTML, Bilder, CSS). Der Webserver liest die Datei und schickt sie zurück.
  • Dynamische Inhalte: werden pro Anfrage erzeugt (z. B. eine personalisierte Seite). Hier reicht der Webserver die Anfrage an eine dahinterliegende Anwendung weiter.

Reverse Proxy und Lastverteilung

Webserver können mehr als nur Dateien ausliefern. Eine wichtige Rolle ist die des Reverse Proxy: Der Webserver nimmt Anfragen aus dem Internet entgegen und leitet sie an interne Dienste weiter (und deren Antworten zurück). Das ist nützlich für:

  • Zentraler Eingangspunkt: ein Dienst nach außen, viele dahinter.
  • TLS-Terminierung: HTTPS-Verschlüsselung an einer Stelle bündeln.
  • Lastverteilung (load balancing): Anfragen auf mehrere gleiche Backends verteilen.
                 ┌─► Backend A
Internet ─► Reverse Proxy ─┼─► Backend B
                 └─► Backend C

Nginx konkret

Nginx (gesprochen "Engine-X") ist einer der meistgenutzten Webserver weltweit. Er ist bekannt für hohe Performance und geringen Ressourcenverbrauch und wird sowohl zum Ausliefern statischer Seiten als auch sehr häufig als Reverse Proxy und Load Balancer eingesetzt.

  • Konfiguriert über Textdateien (nginx.conf bzw. Dateien unter conf.d/).
  • Liefert statische Dateien standardmäßig aus dem Document Root aus.
  • Als Container-Image (nginx) sehr klein und schnell einsatzbereit; eigene Inhalte mountet man typischerweise schreibgeschützt (:ro) in das Document Root.

Web-Analytics am Beispiel Umami

Was ist Web-Analytics?

Web-Analytics bezeichnet das Messen und Auswerten des Nutzerverhaltens auf einer Website. Typische Kennzahlen (Metriken):

  • Seitenaufrufe (page views) und Besucher (visitors),
  • Verweildauer und Absprungrate (bounce rate),
  • Herkunft der Besucher (referrer: Suchmaschine, Link, Kampagne …),
  • verwendete Geräte, Browser, Länder.

Ziel ist es zu verstehen, wie eine Website genutzt wird, um Inhalte und Angebot zu verbessern.

Wie funktioniert das technisch?

Das gängige Verfahren ist Client-seitiges Tracking über ein kleines JavaScript-Snippet, das in jede Seite eingebunden wird:

<script async
        src="https://analytics.example.org/script.js"
        data-website-id="EINDEUTIGE-WEBSITE-ID">
</script>
  • Der Browser lädt beim Seitenaufruf das Tracker-Skript (script.js) vom Analytics-Server.
  • Das Skript meldet ein Ereignis (z. B. "Seite X wurde aufgerufen") zurück.
  • Über die data-website-id ordnet der Server das Ereignis der richtigen Website zu.
  • Der Analytics-Server speichert die Ereignisse (in einer Datenbank) und bereitet sie in einem Dashboard auf.
Browser ─lädt Seite─► Webserver
Browser ─lädt script.js & meldet Aufruf─► Analytics-Server ─► Datenbank

Datenschutz: ein zentrales Thema

Das bekannteste Web-Analytics-Werkzeug ist Google Analytics. Solche Dienste stehen aber datenschutzrechtlich in der Kritik, weil sie personenbezogene Daten sammeln, oft Cookies setzen und Daten an Dritte (in andere Länder) übertragen. In der EU unterliegt das der DSGVO (Datenschutz-Grundverordnung).

Deshalb gibt es datenschutzfreundliche Alternativen, die

  • keine Cookies setzen,
  • keine personenbezogenen Daten speichern,
  • die Daten selbst gehostet auf dem eigenen Server belassen.

Umami konkret

Umami ist ein freies, quelloffenes Web-Analytics-Werkzeug, das genau diesen datenschutzfreundlichen Ansatz verfolgt.

  • Self-Hosted: Man betreibt Umami auf dem eigenen Server – die Daten verlassen die eigene Infrastruktur nicht. (Das passt gut zum IaC-Gedanken: der Dienst wird als Container mit ausgerollt.)
  • Datensparsam: ohne Cookies, ohne Sammeln personenbezogener Daten, DSGVO-freundlich.
  • Leichtgewichtig: schlankes Tracker-Skript, übersichtliches Dashboard.
  • Datenbank-gestützt: Umami benötigt eine Datenbank (z. B. PostgreSQL), um die erfassten Ereignisse zu speichern – hier greifen also zwei der hier vorgestellten Bausteine ineinander.

Zusammenspiel der Bausteine

Die drei vorgestellten Technologien sind typische Beispiele dafür, wie eine moderne Anwendung aus mehreren kooperierenden Diensten besteht. Ein mögliches Zusammenspiel:

                    ┌─────────────────────────────┐
   Browser ───────► │  Webserver (Nginx)          │  liefert die Web-Seite
       │            └─────────────────────────────┘
       │  lädt zusätzlich das Tracker-Skript der Seite
       ▼
   ┌─────────────────────────────┐        ┌──────────────────────────┐
   │  Web-Analytics (Umami)      │ ─────► │  Datenbank (PostgreSQL)  │
   │  zählt Aufrufe              │        │  speichert die Ereignisse│
   └─────────────────────────────┘        └──────────────────────────┘
  • Der Webserver liefert die eigentliche Seite an den Browser.
  • Die Seite enthält das Analytics-Tracker-Skript; der Browser meldet den Aufruf an den Analytics-Dienst.
  • Der Analytics-Dienst legt seine Daten dauerhaft in der Datenbank ab.

Genau dieses Bündel aus mehreren Diensten – jeder in seinem Container, verbunden über ein gemeinsames Netzwerk, konfiguriert über Umgebungsvariablen, dauerhaft gemacht über Volumes – wird im Sinne von Infrastructure as Code in einer Compose-Datei beschrieben und mit einem Befehl reproduzierbar ausgerollt. Die Details dazu in Docker/Podman Compose.

Verständnisfragen

Diese sollten Sie nach dem Durcharbeiten ohne Hilfsmittel beantworten können:

  1. Was ist der Unterschied zwischen einer Datenbank und einem DBMS?
  2. Erklären Sie die Begriffe Tabelle, Zeile, Spalte, Primärschlüssel und Fremdschlüssel im relationalen Modell.
  3. Wofür steht SQL, und welche vier Grundoperationen (CRUD) gibt es?
  4. Warum nutzt man ein DBMS statt die Daten einfach in Dateien zu speichern?
  5. Was macht ein Webserver, und welche Rolle spielt das HTTP-Protokoll dabei?
  6. Was ist der Unterschied zwischen statischen und dynamischen Inhalten?
  7. Was versteht man unter einem Reverse Proxy?
  8. Wie funktioniert Client-seitiges Web-Analytics technisch (Stichworte: Tracker-Skript, website-id, Ereignis)?
  9. Warum ist Datenschutz bei Web-Analytics ein wichtiges Thema, und was machen datenschutzfreundliche Werkzeuge anders?
  10. Erklären Sie am Beispiel der drei Bausteine, wie eine Anwendung aus mehreren kooperierenden Diensten bestehen kann.

Praxisbeispiel aus dem Beleg

Die bisherigen Abschnitte haben die Prinzipien (Idempotenz, DRY, .env) und die Bausteine (Webserver, DBMS, Web-Analytics) getrennt behandelt. Zum Abschluss sehen wir, wie beides im Beleg konkret zusammenkommt – an zwei zentralen Artefakten: der ausgelieferten Webseite und dem Deployment-Skript.

Die Test-Seite mit Tracker-Snippet (html/index.html)

Der Nginx-Webserver liefert eine statische HTML-Seite aus. Sie bindet das Umami-Tracker-Skript ein – also genau das in der Web-Analytics-Sektion beschriebene Client-seitige Tracking:

<!DOCTYPE html>
<html lang="de">
<head>
    <meta charset="UTF-8">
    <title>IaC Testseite</title>
    <script async defer
            src="http://IP_DER_VM:UMAMI_PORT_PLACEHOLDER/script.js"
            data-website-id="UMAMI_ID_PLACEHOLDER">
    </script>
</head>
<body>
    <h1>Willkommen auf der Statistik-Testseite</h1>
    <p>Wenn du diese Seite siehst, lauscht der Nginx auf Port WEB_PORT_PLACEHOLDER.</p>
    <p>Deine IP wurde (hoffentlich) gerade an Umami auf Port UMAMI_PORT_PLACEHOLDER gesendet!</p>
</body>
</html>

Was hier passiert:

  • <script async defer> lädt das Tracker-Skript, ohne den Seitenaufbau zu blockieren.
  • src="http://IP_DER_VM:UMAMI_PORT_PLACEHOLDER/script.js" – das Skript kommt vom Umami-Dienst, nicht vom Nginx. Die Seite selbst wird über den Web-Port (z. B. 8080) ausgeliefert, das Tracker-Skript dagegen über den Umami-Port (z. B. 3000) geladen. Zwei Dienste, zwei Ports.
  • data-website-id="UMAMI_ID_PLACEHOLDER" – ordnet die gemeldeten Ereignisse der richtigen Website im Umami-Dashboard zu.

Die Platzhalter sind der Knackpunkt (DRY): IP_DER_VM, WEB_PORT_PLACEHOLDER und UMAMI_PORT_PLACEHOLDER werden nicht von Hand eingetragen, sondern beim Deployment automatisch aus der .env bzw. der IP-Autodetektion ersetzt. So steht jeder Wert nur an einer Stelle und kann nicht zwischen Dateien auseinanderlaufen.

Einzige Ausnahme – ein bewusst manueller Schritt: UMAMI_ID_PLACEHOLDER kann das Skript nicht automatisch füllen, weil die Website-ID erst entsteht, nachdem man sich in Umami eingeloggt und dort eine Website angelegt hat. Diese ID trägt man danach einmal von Hand ein.

Wie genau diese Ersetzung geschieht, zeigt das folgende Deployment-Skript.

Den lokalen Ordner auf die VM übertragen (upload.sh)

Bearbeitet wird der Code lokal auf dem Entwicklungsrechner (im Editor, mit Git versioniert). Ausgerollt wird er aber auf der VM. Dazwischen fehlt ein Schritt: Die Dateien müssen vom Laptop auf den Server kommen. Genau das ist der mittlere Schritt des IaC-Kreislaufs lokal bearbeiten → hochladen → deployen.

Was ist rsync?

rsync (von remote sync, „entferntes Abgleichen") ist ein Standard-Werkzeug unter Linux/Unix, um Dateien und Verzeichnisse zwischen zwei Orten abzugleichen – lokal zwischen zwei Ordnern oder, wie hier, zwischen dem eigenen Rechner und einem entfernten Server. Es ist gewissermaßen ein „intelligenteres cp": Statt stumpf alles zu kopieren, vergleicht rsync Quelle und Ziel und überträgt nur das, was sich tatsächlich geändert hat (sogar nur die geänderten Teile großer Dateien). Das macht es

  • schnell: beim zweiten Lauf gehen nur die geänderten Dateien über die Leitung,
  • sparsam: auf Wunsch komprimiert (-z) und über eine verschlüsselte SSH-Verbindung (-e ssh),
  • zuverlässig: es kann das Ziel zu einem exakten Spiegel der Quelle machen.

Damit ist rsync die ideale Ergänzung zum idempotenten user_deploy.sh: erst die aktuellen Dateien hochspiegeln, dann auf der VM neu ausrollen.

Das Skript upload.sh

Statt jede Datei einzeln zu kopieren, gleicht ein einziger Befehl den lokalen Ordner mit dem Zielverzeichnis auf der VM ab. Wir legen ihn als kleines Skript upload.sh ab, damit der Aufruf reproduzierbar ist und nicht jedes Mal neu getippt werden muss:

#!/bin/bash
rsync -avz -e ssh --exclude '*~' --exclude '.git' --exclude '.env' \
      --delete --chmod=D755,F644 \
      deploy/ local@aivm.f4.htw-berlin.de:/home/local/iac

Wichtig – eigenen Benutzernamen einsetzen: Der Benutzer local (und der Pfad /home/local/iac) ist hier nur ein Beispiel. Verwenden Sie überall Ihren selbst gewählten Benutzernamen auf der VM – also IhrName@aivm.f4.htw-berlin.de und entsprechend /home/IhrName/iac. Das betrifft sowohl den Ziel-Teil dieses Befehls als auch alle weiteren Stellen, an denen local auftaucht (z. B. beim SSH-Login und im Deploy-Skript).

Der Einzeiler Stück für Stück

Teil Bedeutung
rsync Das Synchronisations-Werkzeug selbst.
-a Archive-Modus: kopiert rekursiv und erhält Rechte, Zeitstempel, Symlinks usw. – die Standard-Wahl, um ein Verzeichnis 1:1 abzubilden.
-v Verbose: zeigt an, welche Dateien übertragen werden.
-z Compress: komprimiert die Daten während der Übertragung (spart Bandbreite).
-e ssh Legt SSH als Transportkanal fest – die Übertragung läuft also verschlüsselt über dieselbe Verbindung, mit der man sich auch einloggt.
--exclude '*~' Lässt Editor-Sicherungsdateien (die auf ~ enden, z. B. index.html~) aus.
--exclude '.git' Überträgt das Git-Verzeichnis nicht – die Versionsgeschichte gehört nicht auf den Produktionsserver.
--exclude '.env' Schließt die .env mit den Geheimnissen vom Abgleich aus. Wichtig in Kombination mit --delete (siehe Kasten unten): So bleibt die .env, die nur auf der VM liegt, unangetastet.
--delete Löscht auf dem Ziel Dateien, die es in der Quelle nicht mehr gibt. So wird das Ziel zu einem exakten Spiegel der Quelle (sonst sammelt sich dort „Müll" früherer Uploads an).
--chmod=D755,F644 Setzt einheitliche Rechte auf dem Ziel: Directories 755 (Eigentümer darf schreiben, alle dürfen lesen/betreten), Files 644 (Eigentümer schreibt, alle lesen) – unabhängig davon, wie die Rechte lokal aussehen.
deploy/ Die Quelle: der lokale Ordner. Der abschließende Schrägstrich ist wichtig – deploy/ überträgt den Inhalt des Ordners; ohne ihn (deploy) landete der Ordner selbst im Ziel (also …/iac/deploy/…).
local@aivm.f4.htw-berlin.de:/home/local/iac Das Ziel im Format benutzer@host:pfad – Benutzer local auf der VM aivm.f4.htw-berlin.de, Zielverzeichnis /home/local/iac. (local durch den eigenen Benutzernamen ersetzen, siehe Hinweis oben.)

Vorsicht mit --delete: Die Option löscht auf dem Server alles, was im Zielordner liegt, in der Quelle deploy/ aber fehlt – sie erzwingt einen exakten Spiegel. Das ist mächtig, aber heikel: Die .env liegt auf der VM in ~/iac (genau dort liest das Deploy-Skript sie), wird aber bewusst nicht aus deploy/ hochgeladen. Ohne Schutz würde --delete sie deshalb mitlöschen. Genau dafür sorgt das --exclude '.env' oben – solche „nur auf dem Server"-Dateien muss man gezielt vom Abgleich ausnehmen.

Hinweis zur .env: Die .env mit den Geheimnissen gehört nicht ins Git (sie steht in .gitignore, siehe Abschnitt zur .env) und wird auch nicht mit hochgeladen. Sie wird einmalig direkt auf der VM angelegt und dort mit chmod 600 geschützt. Das --exclude '.env' bewirkt zweierlei: --delete kann sie nicht entfernen, und --chmod=F644 kann ihre restriktiven 600-Rechte nicht auf 644 „aufweichen".

Ablauf in der Praxis: lokal bearbeiten → ./upload.sh ausführen (Dateien landen in /home/local/iac auf der VM) → per SSH auf der VM einloggen und dort ./user_deploy.sh starten. Damit ist der Kreis aus dem nächsten Abschnitt geschlossen.

Das Deployment-Skript user_deploy.sh

Das eigentliche Ausrollen übernimmt ein Bash-Skript, das auf der VM als unprivilegierter Benutzer local läuft. Es bündelt alle wiederkehrenden Schritte zu einem Aufruf (./user_deploy.sh) und ist idempotent – man kann es beliebig oft ausführen und landet immer im selben definierten Zustand.

#!/bin/bash
# ==============================================================================
# IaC DEPLOYMENT SCRIPT (Umami, Postgres, Nginx)
# ==============================================================================

# 1. ARBEITSVERZEICHNIS SETZEN
cd "$(dirname "$0")"

# 2. PRÜFUNG DER UMGEBUNG
if [ ! -f .env ]; then
    echo "Fehler: .env Datei fehlt!"
    exit 1
fi

# 3. IP-ADRESSE AUTOMATISCH FINDEN
DETECTED_IP=$(ip addr show | grep -oP '141\.45\.\d+\.\d+' | head -n 1)
[ -z "$DETECTED_IP" ] && DETECTED_IP=$(ip addr show | grep -oP '10\.\d+\.\d+\.\d+' | head -n 1)
DETECTED_IP=${DETECTED_IP:-127.0.0.1}

echo "Gefundene IP: $DETECTED_IP"

# 4. KONFIGURATION AKTUALISIEREN
sed -i "s|^VM_IP_OR_DOMAIN=.*|VM_IP_OR_DOMAIN=$DETECTED_IP|" .env

# Ports aus der .env laden (Single Source of Truth); Defaults als Fallback.
source .env
WEB_PORT=${WEB_PORT:-8080}
UMAMI_PORT=${UMAMI_PORT:-3000}

if [ -f "./html/index.html" ]; then
    sed -i "s/IP_DER_VM/$DETECTED_IP/g" ./html/index.html
    sed -i "s/WEB_PORT_PLACEHOLDER/$WEB_PORT/g" ./html/index.html
    sed -i "s/UMAMI_PORT_PLACEHOLDER/$UMAMI_PORT/g" ./html/index.html
fi

# 5. ROBUSTES CLEANUP
echo "Bereinige altes Setup..."
podman-compose down || true
podman rm -af || true
podman network prune -f || true

# 6. CONTAINER STARTEN
echo "Starte Container-Stack..."
podman-compose up -d

# 7. SYSTEMD INTEGRATION
echo "Generiere Systemd-Services..."
SYSTEMD_DIR="$HOME/.config/systemd/user"
mkdir -p "$SYSTEMD_DIR"
rm -f "$SYSTEMD_DIR"/container-iac_*.service

for container in $(podman ps --filter "name=iac_" --format "{{.Names}}"); do
    podman generate systemd --name "$container" --files --restart-policy=always > /dev/null
    mv "container-$container.service" "$SYSTEMD_DIR/"
done

systemctl --user daemon-reload

# WICHTIG: Die Services nicht nur 'enable' (für Reboot), sondern auch 'start' (für jetzt sofort)
for service in "$SYSTEMD_DIR"/container-iac_*.service; do
    s_name=$(basename "$service")
    systemctl --user enable "$s_name" > /dev/null 2>&1
    systemctl --user start "$s_name" > /dev/null 2>&1
done

# 8. ABSCHLIESSENDER STATUS-CHECK
echo -e "\n🔍 Prüfe Dienst-Status (Systemd)..."
sleep 2
systemctl --user list-units "container-iac_*"

echo -e "\n Setup erfolgreich abgeschlossen!"
echo "Webseite:  http://$DETECTED_IP:$WEB_PORT"
echo "Analytics: http://$DETECTED_IP:$UMAMI_PORT"

echo "✅ Container laufen!"
echo "👉 1. Öffne http://$DETECTED_IP:$UMAMI_PORT"
echo "👉 2. Logge dich ein (admin / umami)"
echo "👉 3. Erstelle eine Website und kopiere die ID."
echo "👉 4. Ersetze 'UMAMI_ID_PLACEHOLDER' in der index.html manuell."

user_deploy.sh Schritt für Schritt

Das Skript ist in nummerierte Abschnitte gegliedert. Jeder davon illustriert ein IaC-Prinzip aus dem ersten Teil des Notebooks.

1. Arbeitsverzeichnis setzen

cd "$(dirname "$0")"

$0 ist der Aufrufpfad des Skripts, dirname schneidet den Dateinamen ab. Das Skript wechselt also in sein eigenes Verzeichnis. Dadurch funktionieren alle folgenden relativen Pfade (.env, ./html/index.html) unabhängig davon, aus welchem Verzeichnis man das Skript aufruft.

2. Prüfung der Umgebung

if [ ! -f .env ]; then
    echo "Fehler: .env Datei fehlt!"
    exit 1
fi

Fail-fast: Ohne .env fehlen alle Geheimnisse (DB-Passwort, APP_SECRET) – ein Start wäre sinnlos. Das Skript bricht deshalb sofort mit einem von 0 verschiedenen Exit-Code ab, statt halbfertig weiterzulaufen.

3. IP-Adresse automatisch finden

DETECTED_IP=$(ip addr show | grep -oP '141\.45\.\d+\.\d+' | head -n 1)
[ -z "$DETECTED_IP" ] && DETECTED_IP=$(ip addr show | grep -oP '10\.\d+\.\d+\.\d+' | head -n 1)
DETECTED_IP=${DETECTED_IP:-127.0.0.1}
  • ip addr show listet die Netzwerk-Adressen; grep -oP (Perl-Regex) zieht nur die passende IP heraus, head -n 1 nimmt die erste.
  • Kaskade von Fallbacks: zuerst eine HTW-Adresse (141.45.x.x), sonst eine private (10.x.x.x), und falls gar nichts passt, 127.0.0.1 (${VAR:-default} setzt den Default, wenn die Variable leer ist).
  • So muss niemand die IP von Hand eintragen – das Skript ermittelt den Ist-Zustand selbst und macht den Deploy auf jeder VM lauffähig.

4. Konfiguration aktualisieren (DRY in Aktion)

sed -i "s|^VM_IP_OR_DOMAIN=.*|VM_IP_OR_DOMAIN=$DETECTED_IP|" .env

source .env
WEB_PORT=${WEB_PORT:-8080}
UMAMI_PORT=${UMAMI_PORT:-3000}

if [ -f "./html/index.html" ]; then
    sed -i "s/IP_DER_VM/$DETECTED_IP/g" ./html/index.html
    sed -i "s/WEB_PORT_PLACEHOLDER/$WEB_PORT/g" ./html/index.html
    sed -i "s/UMAMI_PORT_PLACEHOLDER/$UMAMI_PORT/g" ./html/index.html
fi

Hier wird das DRY-Prinzip umgesetzt: Die ermittelte IP und die Ports werden aus einer Quelle (.env bzw. der Autodetektion) in alle Dateien geschrieben, die sie brauchen. sed -i ersetzt direkt in der Datei (in place); source .env lädt die Variablen der .env in die Shell, damit WEB_PORT/UMAMI_PORT als Werte für die Platzhalter zur Verfügung stehen.

Hinweis: source führt die .env als Shell-Code aus. Das ist hier nur unbedenklich, weil die Datei einfache, vertrauenswürdige KEY=VALUE-Zeilen enthält.

5. Robustes Cleanup (Idempotenz)

podman-compose down || true
podman rm -af || true
podman network prune -f || true

Vor dem Neustart wird der alte Zustand entfernt. Das || true verhindert, dass das Skript abbricht, wenn (z. B. beim ersten Lauf) noch gar nichts zu löschen ist. Genau das macht das Skript idempotent: Egal ob erster Lauf oder zehnter – am Ende steht immer derselbe, sauber neu gestartete Stack.

Zum Mitdenken: podman rm -af entfernt alle Container des Users, nicht nur die mit Präfix iac_. Auf einer dedizierten Beleg-VM ist das unkritisch; liefen dort mehrere unabhängige Stacks, wäre ein gezielteres --filter sauberer.

6. Container starten

podman-compose up -d

Liest die compose.yaml, lädt fehlende Images, legt Netzwerk und Volume an und startet alle drei Dienste detached (-d, im Hintergrund). Die .env wird von podman-compose automatisch eingelesen und füllt die ${...}-Platzhalter der Compose-Datei.

7. systemd-Integration (Reboot-Festigkeit)

for container in $(podman ps --filter "name=iac_" --format "{{.Names}}"); do
    podman generate systemd --name "$container" --files --restart-policy=always > /dev/null
    mv "container-$container.service" "$SYSTEMD_DIR/"
done
systemctl --user daemon-reload
# ... enable + start je Service

podman generate systemd erzeugt für jeden laufenden iac_-Container eine systemd-User-Service-Datei. Diese werden nach ~/.config/systemd/user/ gelegt, dann enabled (Autostart beim Booten) und started (sofort aktiv). Zusammen mit dem zuvor (als root) gesetzten Linger (loginctl enable-linger) überleben die Container damit Logout und Reboot.

Aktualitätshinweis: podman generate systemd gilt in neueren Podman-Versionen als veraltet; der heute empfohlene Weg sind Quadlet-Unit-Dateien. Das Prinzip – Container über systemd dauerhaft betreiben – bleibt dasselbe.

8. Status-Check und manueller Restschritt

systemctl --user list-units "container-iac_*"
echo "Webseite:  http://$DETECTED_IP:$WEB_PORT"
echo "Analytics: http://$DETECTED_IP:$UMAMI_PORT"
# ...
echo "👉 4. Ersetze 'UMAMI_ID_PLACEHOLDER' in der index.html manuell."

Zum Abschluss zeigt das Skript den Dienst-Status und die erreichbaren URLs. Der letzte Schritt bleibt bewusst manuell: Die data-website-id (UMAMI_ID_PLACEHOLDER) existiert erst, nachdem man sich in Umami eingeloggt und dort eine Website angelegt hat. Danach gilt erneut der IaC-Kreislauf: lokal bearbeiten → hochladen (rsync) → deployen.


Was dieses Skript zu IaC macht: Es ist versioniert (liegt in Git), reproduzierbar (läuft auf jeder VM gleich), idempotent (beliebig oft ausführbar) und selbst- dokumentierend (der Code zeigt, welche Ports offen sind und wie der Stack zusammenhängt).

Vor dem Öffnen der Firewall: lokal auf dem Server testen

Bevor man die Ports nach außen freigibt (nftables-Regel, siehe Firewalls mit nftables), sollte man direkt auf der VM prüfen, ob der Stack überhaupt korrekt läuft. Das ist guter Stil: Man testet eine Sache nach der anderen und schließt Fehlerquellen schrittweise aus.

Warum zuerst lokal? Ruft man die Seite gleich vom eigenen Laptop auf und es klappt nicht, gibt es viele mögliche Ursachen: Container kaputt, Firewall zu, VPN nicht aktiv, falsche IP … Ein Test auf dem Server selbst umgeht Firewall und Netz komplett (er geht über localhost/127.0.0.1) und beantwortet genau eine Frage: Laufen die Dienste und antworten sie auf ihren Ports?

   ┌─ Test 1: auf der VM (localhost) ──────────────┐
   │  curl ──► 127.0.0.1:8080 / :3000              │   prüft nur die Container
   └───────────────────────────────────────────────┘   (Firewall/VPN egal)

   ┌─ Test 2: vom eigenen Rechner (VM-IP) ─────────┐
   │  curl ──► 141.45.x.x:8080  (durch Firewall)   │   prüft zusätzlich
   └───────────────────────────────────────────────┘   Firewall + Netz/VPN

So testet man auf der VM

Auf der VM eingeloggt (als local), die Ports aus der .env benutzen:

# Werte aus der .env holen (oder Defaults 8080 / 3000)
source ~/iac/.env

# 1) Lauschen die Ports überhaupt?  -> beide sollten als LISTEN auftauchen
ss -tlnp | grep -E "${WEB_PORT:-8080}|${UMAMI_PORT:-3000}"

# 2) Antwortet der Nginx?  -I = nur HTTP-Header anfragen (HEAD-Request)
curl -I http://127.0.0.1:${WEB_PORT:-8080}        # erwartet: HTTP/1.1 200 OK

# 3) Antwortet Umami?  -> 200 oder eine Weiterleitung (3xx) auf die Login-Seite
curl -I http://127.0.0.1:${UMAMI_PORT:-3000}

# 4) Liefert Umami wirklich das Tracker-Skript aus?
curl -s http://127.0.0.1:${UMAMI_PORT:-3000}/script.js | head -c 100

curl nicht installiert? wget tut es genauso:

wget -S -O /dev/null http://127.0.0.1:${WEB_PORT:-8080}    # -S zeigt die Server-Header

Wie liest man die Ergebnisse?

Beobachtung Bedeutung
HTTP/1.1 200 OK (Nginx) Webserver läuft und liefert die Seite aus ✔
200 oder 30x/Location: (Umami) Umami läuft (leitet ggf. auf /login um) ✔
HTTP/1.1 403 Forbidden (Nginx) Container läuft, aber Nginx darf die Datei nicht lesenDateirechte prüfen (siehe Kasten unten)
curl: (56) … Connection reset by peer Port ist offen, aber der Dienst dahinter wirft die Verbindung weg → Dienst noch nicht bereit oder Container startet in einer Schleife neu (siehe Kasten unten)
Connection refused Container läuft nicht / lauscht nicht auf dem Port → podman ps, podman logs <name> prüfen
ss-Zeile fehlt Port-Mapping greift nicht → compose.yaml und .env-Ports prüfen

403 Forbidden bei Nginx – fast immer ein Rechte-Problem. Der Container läuft und lauscht (sonst käme Connection refused), aber Nginx darf die angeforderte Datei nicht lesen. Damit der Webserver index.html ausliefern kann, müssen sowohl die Verzeichnisse auf dem Weg dorthin als auch die Datei selbst die richtigen Rechte haben:

| Pfad | benötigte Rechte | warum | |---|---|---| | iac/ | 755 (Verzeichnis) | Verzeichnisse müssen ausführbar/betretbar (x) sein, damit man „hindurch" auf den Inhalt zugreifen kann. | | iac/html/ | 755 (Verzeichnis) | dito – Nginx muss das Verzeichnis betreten dürfen. | | iac/html/index.html | 644 (Datei) | die Datei muss lesbar (r) für andere sein; ausführbar muss sie nicht sein. |

Prüfen und ggf. korrigieren lässt sich das auf der VM mit:

ls -ld ~/iac ~/iac/html            # Verzeichnisse: erwartet drwxr-xr-x (755)
ls -l  ~/iac/html/index.html       # Datei:         erwartet -rw-r--r-- (644)

chmod 755 ~/iac ~/iac/html         # nur falls nötig
chmod 644 ~/iac/html/index.html    # nur falls nötig

Gut zu wissen: Genau diese Rechte setzt das upload.sh aus dem vorigen Abschnitt bereits automatisch beim Hochladen – über --chmod=D755,F644 (Directories 755, Files 644). Wer also konsequent mit ./upload.sh synchronisiert, bekommt diesen 403-Fehler in der Regel gar nicht erst. Manuell von Hand kopierte oder bearbeitete Dateien können dagegen abweichende Rechte haben – dann hilft das obige chmod.

curl: (56) … Connection reset by peer – der Dienst antwortet nicht (sauber). Die TCP-Verbindung kommt zustande (der Port ist also offen, sonst käme Connection refused), aber der Dienst dahinter setzt sie sofort zurück. Bei Umami bedeutet das meist: Der Container ist noch nicht fertig hochgefahren – oder er startet immer wieder neu (Restart-Schleife). Die genaue Ursache steht immer im Container-Log – das ist der erste Griff:

# 1) Status ansehen: Wie heißt der Container und läuft er stabil?
podman ps -a                       # Spalte STATUS beachten: "Up …" oder ständig "Restarting"?

podman ps zeigt links die CONTAINER ID (kryptisch, z. B. a1b2c3d4e5f6) und rechts den lesbaren NAMES-Eintrag. Beide funktionieren als Angabe für podman logs – man muss also nicht die kryptische ID abtippen, der Name genügt. Im Beispiel-Stack (siehe Docker/Podman Compose) tragen die drei Container feste Namen:

| Dienst | container_name | Aufgabe | |---|---|---| | Datenbank (PostgreSQL) | iac_db | speichert die Umami-Daten | | Web-Analytics (Umami) | iac_analytics | der Dienst, der hier (56) wirft, wenn er nicht hochkommt | | Webserver (Nginx) | iac_web_site | liefert index.html aus |

Den Umami-Container schaut man sich also so an:

# 2) Log lesen – mit dem Namen ODER der ID aus 'podman ps':
podman logs --tail 50 iac_analytics           # fester Name aus der compose.yaml
podman logs --tail 50 a1b2c3d4e5f6            # alternativ die ID aus 'podman ps'

# Bequemer, ohne die kryptische ID abtippen: über einen Namensteil finden
podman logs --tail 50 "$(podman ps -aqf name=iac_analytics)"

Die letzte Zeile sucht mit podman ps den Container, dessen Name iac_analytics enthält (-q = nur die ID ausgeben, -f name=… = filtern), und reicht die gefundene ID direkt an podman logs weiter – so muss man die kryptische ID nie selbst abtippen.

Was die Logs typischerweise verraten (bei Umami fast immer die DB-Verbindung):

| Logmeldung (Auszug) | Ursache | |---|---| | getaddrinfo ENOTFOUND db | Falscher Host in der DATABASE_URL – innerhalb des Containers ist nicht localhost die DB, sondern der Service-Name aus der compose.yaml (hier db). | | password authentication failed | Passwort in DATABASE_URLPOSTGRES_PASSWORD (sollten beide aus ${DB_PASSWORD} in der einen .env kommen). | | ECONNREFUSED / the database system is starting up | Datenbank noch nicht bereit – einmal normal; als Dauerschleife → prüfen, ob der DB-Container iac_db selbst stabil läuft. | | database "umamiDB" does not exist | POSTGRES_DB (${DB_NAME}) / Benutzer passen nicht zur DATABASE_URL. |

Also: erst podman ps / podman logs, dann weitertesten. Ist es nur ein Timing-Problem (Container ist Up, war nur noch nicht fertig), hilft schon ein paar Sekunden warten und erneut curlen.

Erst wenn die lokalen Tests grün sind, lohnt es sich, die Firewall zu öffnen. Danach testet man vom eigenen Rechner (ggf. über VPN) gegen die VM-IP statt 127.0.0.1 – schlägt es jetzt fehl, obwohl es lokal lief, liegt das Problem nachweislich an Firewall oder Netz/VPN, nicht am Stack. So hat man die Fehlersuche von Anfang an eingegrenzt.