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?
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."
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.
Indem wir Infrastruktur als Code definieren, können wir Tools wie Git nutzen. Dies hat weitreichende Vorteile für die Zuverlässigkeit eines Systems:
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.
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.
Die .env-Datei fungiert als Schnittstelle zwischen Code und Umgebung. Sie trennt die Logik (den Bauplan) von den sensiblen Daten (den Geheimnissen).
Das wichtigste Prinzip lautet: Sensible Daten gehören niemals direkt in den Quellcode.
docker-compose.yml stehen, wären sie für jeden sichtbar, der Zugriff auf das Git-Repository hat.${DB_PASSWORD}) und definieren die echten Werte ausschließlich in der lokalen .env-Datei.Eine .env-Datei darf niemals in ein öffentliches Git-Repository hochgeladen (gepusht) werden.
.env in die Datei .gitignore ein. Git ignoriert diese Datei dann beim Hochladen..env.example. Diese enthält die Variablennamen, aber keine echten Passwörter.Im IaC-Prozess wird die .env-Datei erst auf dem Zielserver (der VM) erstellt oder ausgefüllt.
index.html) zu ersetzen..env-Datei aus.Da die .env Passwörter im Klartext enthält, müssen die Dateirechte auf dem Linux-Server restriktiv gesetzt werden:
chmod 600 .env
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):
.env (nicht im Git!):# min. 32 Zeichen, zufällig – z. B. erzeugt mit: openssl rand -base64 36
APP_SECRET=a8f7c6b5d4e3...z210
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
APP_SECRET beim Start aus seiner
Umgebung und verwendet sie zum Signieren der Sitzungs-Tokens.Was man daraus lernt:
.env „injiziert" – Logik (Bauplan) und Geheimnis bleiben getrennt.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.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.
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:
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.
Der häufigste Typ ist das relationale DBMS (RDBMS). Daten werden in Tabellen (engl. tables) organisiert:
benutzer).email).Tabelle "benutzer"
id (PK) | name | email
--------+-----------+--------------------
1 | Alice | alice@example.org
2 | Bob | bob@example.org
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;
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:
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.
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.
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:
200 OK, 404 Not Found,
500 Internal Server Error./usr/share/nginx/html).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:
┌─► Backend A
Internet ─► Reverse Proxy ─┼─► Backend B
└─► Backend C
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.
nginx.conf bzw. Dateien unter conf.d/).nginx) sehr klein und schnell einsatzbereit; eigene
Inhalte mountet man typischerweise schreibgeschützt (:ro) in das Document
Root.Web-Analytics bezeichnet das Messen und Auswerten des Nutzerverhaltens auf einer Website. Typische Kennzahlen (Metriken):
Ziel ist es zu verstehen, wie eine Website genutzt wird, um Inhalte und Angebot zu verbessern.
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>
script.js) vom
Analytics-Server.data-website-id ordnet der Server das Ereignis der richtigen
Website zu.Browser ─lädt Seite─► Webserver
Browser ─lädt script.js & meldet Aufruf─► Analytics-Server ─► Datenbank
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
Umami ist ein freies, quelloffenes Web-Analytics-Werkzeug, das genau diesen datenschutzfreundlichen Ansatz verfolgt.
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│
└─────────────────────────────┘ └──────────────────────────┘
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.
Diese sollten Sie nach dem Durcharbeiten ohne Hilfsmittel beantworten können:
website-id, Ereignis)?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.
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_PLACEHOLDERkann 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.
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.
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
-z) und über eine verschlüsselte
SSH-Verbindung (-e ssh),Damit ist rsync die ideale Ergänzung zum idempotenten user_deploy.sh: erst die
aktuellen Dateien hochspiegeln, dann auf der VM neu ausrollen.
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 – alsoIhrName@aivm.f4.htw-berlin.deund entsprechend/home/IhrName/iac. Das betrifft sowohl den Ziel-Teil dieses Befehls als auch alle weiteren Stellen, an denenlocalauftaucht (z. B. beim SSH-Login und im Deploy-Skript).
| 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 Quelledeploy/aber fehlt – sie erzwingt einen exakten Spiegel. Das ist mächtig, aber heikel: Die.envliegt auf der VM in~/iac(genau dort liest das Deploy-Skript sie), wird aber bewusst nicht ausdeploy/hochgeladen. Ohne Schutz würde--deletesie 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.envmit 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 mitchmod 600geschützt. Das--exclude '.env'bewirkt zweierlei:--deletekann sie nicht entfernen, und--chmod=F644kann ihre restriktiven600-Rechte nicht auf644„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.
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.
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.
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.
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.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).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:
sourceführt die.envals Shell-Code aus. Das ist hier nur unbedenklich, weil die Datei einfache, vertrauenswürdigeKEY=VALUE-Zeilen enthält.
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 -afentfernt alle Container des Users, nicht nur die mit Präfixiac_. Auf einer dedizierten Beleg-VM ist das unkritisch; liefen dort mehrere unabhängige Stacks, wäre ein gezielteres--filtersauberer.
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.
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 systemdgilt in neueren Podman-Versionen als veraltet; der heute empfohlene Weg sind Quadlet-Unit-Dateien. Das Prinzip – Container über systemd dauerhaft betreiben – bleibt dasselbe.
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).
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
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 lesen → Dateirechte 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 Forbiddenbei Nginx – fast immer ein Rechte-Problem. Der Container läuft und lauscht (sonst kämeConnection refused), aber Nginx darf die angeforderte Datei nicht lesen. Damit der Webserverindex.htmlausliefern 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ötigGut zu wissen: Genau diese Rechte setzt das
upload.shaus dem vorigen Abschnitt bereits automatisch beim Hochladen – über--chmod=D755,F644(Directories755, Files644). Wer also konsequent mit./upload.shsynchronisiert, bekommt diesen403-Fehler in der Regel gar nicht erst. Manuell von Hand kopierte oder bearbeitete Dateien können dagegen abweichende Rechte haben – dann hilft das obigechmod.
curl: (56) … Connection reset by peer– der Dienst antwortet nicht (sauber). Die TCP-Verbindung kommt zustande (der Port ist also offen, sonst kämeConnection 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 pszeigt links die CONTAINER ID (kryptisch, z. B.a1b2c3d4e5f6) und rechts den lesbaren NAMES-Eintrag. Beide funktionieren als Angabe fürpodman 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| liefertindex.htmlaus |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 psden Container, dessen Nameiac_analyticsenthält (-q= nur die ID ausgeben,-f name=…= filtern), und reicht die gefundene ID direkt anpodman logsweiter – 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 derDATABASE_URL– innerhalb des Containers ist nichtlocalhostdie DB, sondern der Service-Name aus dercompose.yaml(hierdb). | |password authentication failed| Passwort inDATABASE_URL≠POSTGRES_PASSWORD(sollten beide aus${DB_PASSWORD}in der einen.envkommen). | |ECONNREFUSED/the database system is starting up| Datenbank noch nicht bereit – einmal normal; als Dauerschleife → prüfen, ob der DB-Containeriac_dbselbst stabil läuft. | |database "umamiDB" does not exist|POSTGRES_DB(${DB_NAME}) / Benutzer passen nicht zurDATABASE_URL. |Also: erst
podman ps/podman logs, dann weitertesten. Ist es nur ein Timing-Problem (Container istUp, war nur noch nicht fertig), hilft schon ein paar Sekunden warten und erneutcurlen.
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.