In vielen Projekten lebt das Datenbank-Schema in der Datenbank statt im Repository — gewachsen aus Jahren von Hand-ALTERs, vollständig dokumentiert nirgends. Auffallen tut das erst, wenn eine zweite Umgebung entstehen soll oder ein Deploy zerbricht. Datenbank-CI/CD dreht das Verhältnis um: Das Repository beschreibt den Soll-Zustand, und jede Umgebung — von der Wegwerf-Datenbank im CI bis zur Produktion — entsteht aus denselben versionierten Skripten. Dieser Artikel zeigt den kompletten Lebenszyklus in sechs Stationen, bewusst ohne Migrations-Framework.
Das Wichtigste vorab:
- Der Lebenszyklus in sechs Stationen: Bootstrap, Objekt-Deploy, Tests, Evolution mit Daten, CI-Gate, Teardown.
- Jede Station löst ein konkretes Problem — und hat für die Tiefe einen eigenen Artikel in diesem Cluster.
- Die Werkzeuge: Bash,
psqlund GitHub Actions — bewusst ohne Flyway oder Liquibase. - Die ehrliche Grenze: wann ein Migrations-Tool doch die bessere Wahl ist — und welche Stationen es einem trotzdem nicht abnimmt.
- Alles ist lauffähig: Die Kommando-Folge stammt aus dem offenen Starter-Kit und ist end-to-end gegen Postgres 17 verifiziert.
Voraussetzung: Postgres, eine Bash und ein GitHub-Repository; Docker, wenn der Lebenszyklus lokal nachgespielt werden soll. Alle Bausteine stammen aus dem offenen DI²-Starter-Kit auf GitHub — dort liegen die Skripte, der Beispiel-Schemabaum und die CI-Pipeline im Zusammenhang.
Inhalt
- Der Lebenszyklus in sechs Stationen
- Station 1: Bootstrap — Datenbank, Rollen, Schema reproduzierbar anlegen
- Station 2: Objekt-Deploy — das Verzeichnis ist der Migrationsplan
- Station 3: Apply-Smoke und Objekt-Tests
- Station 4: Evolution mit Daten
- Station 5: CI als Gate — GitHub Actions
- Station 6: Teardown
- Wann doch ein Migrations-Tool?
- FAQ
- Verwandte Artikel
Der Lebenszyklus in sechs Stationen
„Datenbank-CI/CD einführen“ wird oft mit „Migrations-Tool installieren“ übersetzt. Das greift zu kurz — ein Tool beantwortet nur einen Ausschnitt der Frage. Der vollständige Lebenszyklus einer Datenbank-Umgebung hat sechs Stationen, und jede davon braucht eine Antwort, egal ob mit oder ohne Framework:
Als Kommando-Folge ist der ganze Zyklus unspektakulär — und genau das ist der Anspruch:
1: bash db/scripts/create.sh local # Station 1: Bootstrap — Datenbank, Rollen, Schema
2: bash db/scripts/deploy.sh all local # Station 2: Objekt-Deploy — alle Schema-Objekte
3: bash db/tests/run.sh # Station 3: Objekt-Tests gegen eine Wegwerf-DB
4: bash db/scripts/deploy.sh all local # zweiter Lauf: Idempotenz-Prüfung + Run-once-Skip
5: bash db/scripts/drop.sh local # Station 6: Teardown — Datenbank und Rollen restlos weg
Diese fünf Zeilen sind end-to-end gegen Postgres 17 in einem Wegwerf-Docker-Container verifiziert: Bootstrap, Voll-Deploy und alle Objekt-Tests laufen grün durch, der zweite Deploy quittiert die Run-once-Skripte mit skipped (already applied), und nach dem Teardown sind Datenbank und Rollen restlos verschwunden — pg_database enthält keinen Eintrag mehr für die angelegte Datenbank, pg_roles keine der angelegten Rollen. Station 4 (Evolution mit Daten) und Station 5 (CI-Gate) stecken nicht in eigenen Kommandos: Die Evolution lebt in den deployten Dateien selbst, das Gate fährt dieselbe Folge automatisch bei jedem Pull Request.
Die folgenden Abschnitte gehen die Stationen durch — jeweils mit dem Problem, das die Station löst, und dem Verweis auf den Artikel, der die Tiefe liefert.
Station 1: Bootstrap — Datenbank, Rollen, Schema reproduzierbar anlegen
Bevor irgendein Schema-Objekt deployt werden kann, muss die Umgebung existieren: die Datenbank selbst, die Extensions, das Anwendungs-Schema und die Rollen. Der Bootstrap legt all das mit einem Skript-Aufruf an — create.sh <env> — und zwar für jede Umgebung mit denselben Skripten. local, dev und prod unterscheiden sich nur in einer kleinen Konfigurations-Datei mit Verbindungs-Koordinaten, nicht im Ablauf.
Zwei Entscheidungen prägen die Station. Erstens die Rollen-Trennung: Es entstehen vier Rollen mit klaren Aufgaben — der Datenbank-Eigentümer, der Schema-Owner (unter dem später jedes Deploy läuft), eine Lese-Schreib-Gruppenrolle und der Service-Account, mit dem sich die Anwendung verbindet. Passwörter wandern dabei nie in Dateien: Das Skript braucht nur das bestehende Superuser-Passwort; für die drei neuen Rollen wählt man eigene Passwörter und legt sie am besten gleich im Passwort-Manager ab.
Zweitens die Drop-and-Recreate-Semantik: Der Bootstrap ist absichtlich nicht idempotent. Existieren Datenbank oder Rollen bereits, bricht ein Preflight ab und verweist auf den Teardown — ein halb-überschriebenes Setup wäre schlimmer als ein abgebrochenes. Reproduzierbarkeit heißt hier: Eine Umgebung entsteht vollständig aus den versionierten Skripten — nicht durch nachträgliches Anpassen von Hand.
Station 2: Objekt-Deploy — das Verzeichnis ist der Migrationsplan
Der Objekt-Deploy ist das Herzstück des Modells. Jedes Schema-Objekt — Tabelle, Funktion, Prozedur, Trigger, View — lebt als eigene Datei im Repository und beschreibt seinen Soll-Zustand, idempotent formuliert: CREATE TABLE IF NOT EXISTS und CREATE OR REPLACE, wo die Engine sie anbietet; wo nicht — bei Triggern oder Constraints —, übernimmt DROP … IF EXISTS plus Neuanlage denselben Dienst, um den Preis, dass das Objekt während des Deploys kurz entfällt und neu entsteht. Der Deploy-Runner lädt diese Dateien in einer festen Sektions-Reihenfolge direkt aus der Verzeichnisstruktur — Tabellen vor Funktionen, Funktionen vor Triggern — und innerhalb jeder Sektion nach Dateinamens-Nummer. Es gibt keinen separaten Deploy-Plan, den man pflegen (und vergessen) könnte: Das Verzeichnis ist der Migrationsplan.
Der wesentliche Vorteil gegenüber einer klassischen Migrations-Kette: Der DDL-Kern jedes Schema-Objekts steht an einer Stelle, lesbar als eine Datei — nicht verteilt über vierzig aufeinander aufbauende Änderungs-Skripte. Und jedes Deploy ist derselbe Aufruf, egal ob gegen eine leere oder eine aktuelle Datenbank: deploy.sh all <env>.
Warum dieses Modell ohne Migrations-Kette auskommt, wie die Verzeichnis-Konvention im Detail aussieht und wo ihre Grenzen liegen, begründet ausführlich der Artikel SQL-Schema deployen ohne Migrations-Tool — er ist die Grundlage, auf der alle weiteren Stationen aufbauen.
Station 3: Apply-Smoke und Objekt-Tests
Eine gemergte DDL-Änderung, die auf einer leeren Datenbank nicht durchläuft, ist per Definition kaputt — sie fällt nur nicht dort auf, wo sie geschrieben wurde, sondern auf der nächsten Umgebung, die von null aufgebaut wird. Gegen diese Fehlerklasse hilft eine Disziplin, nicht ein Werkzeug: Vor jedem Merge läuft das komplette Deploy einmal gegen eine leere Wegwerf-Datenbank durch — der Apply-Smoke.
Dazu kommen Objekt-Tests: pro Schema-Objekt eine SQL-Datei mit DO $$ … ASSERT-Blöcken, die Happy Path, erwartete Fehler-Guards und Constraints prüfen. Beides zusammen läuft lokal in einem selbst aufgeräumten Docker-Container (db/tests/run.sh) — und identisch noch einmal im CI-Gate der Station 5. Wer den Smoke lokal vergisst, wird also spätestens im Pull Request erinnert.
Station 4: Evolution mit Daten
Vor dem Go-live ist Schema-Evolution trivial: Teardown, Bootstrap, Deploy — die Datenbank ist wegwerfbar. Sobald Umgebungen Daten tragen, die niemand verlieren darf, wird die Station zum anspruchsvollsten Teil des Lebenszyklus. Das framework-freie Modell beantwortet sie mit zwei Bausteinen:
- Konvergente Objekt-Dateien: Eine bestehende Tabelle wird nie gedroppt und neu angelegt. Neue Spalten kommen als idempotentes
ALTER TABLE … ADD COLUMN IF NOT EXISTSin die Objekt-Datei — sie beschreibt weiter den Soll-Zustand, und jede Umgebung konvergiert beim nächsten Deploy dorthin. - Run-once-Transitions: Alles, was sich nicht idempotent formulieren lässt — Backfills, Umrechnungen, Daten-Rettungen —, wird ein zeitgestempeltes Änderungs-Skript, das genau einmal pro Datenbank läuft. Ein Tracker in der Datenbank merkt sich per Dateiname und Checksumme, was schon gelaufen ist; nachträglich editierte Skripte brechen das Deploy ab.
Auch die härteren Fälle — Umbenennungen, Typ-Änderungen, entfallende Spalten — folgen diesem Zwei-Baustein-Muster, brauchen aber fast immer den Transition-Anteil und einen Zwischenschritt, in dem Alt- und Neu-Form parallel existieren.
Die beiden Vertiefungen dazu: NOT-NULL-Spalte nachträglich hinzufügen zeigt den häufigsten Einzelfall — das Expand/Contract-Muster, mit dem Struktur-Änderung und Backfill auf einer befüllten Tabelle zusammenspielen. Und Schema-Änderungen tracken ohne Framework baut den Run-once-Tracker von Grund auf — inklusive der Erkenntnis, dass Flyway und Liquibase in ihrem Tracking-Kern dieselben vier Design-Entscheidungen treffen, bei allem, was die Tools darüber hinaus mitbringen.
Station 5: CI als Gate — GitHub Actions
Alle bisherigen Stationen laufen auf Zuruf — Station 5 macht sie verbindlich. Bei jedem Pull Request zieht GitHub Actions einen Postgres-Service-Container als Wegwerf-Datenbank hoch und fährt darauf den halben Lebenszyklus automatisch: Bootstrap, Voll-Deploy, Objekt-Tests — und dann ein zweites Deploy im selben Lauf, das die Idempotenz aller Objekt-Dateien und den Skip-Pfad der Run-once-Skripte prüft. Als Required Status Check verdrahtet, blockiert ein roter Lauf den Merge.
Das Gate fängt die Fehlerklassen, die sich maschinell prüfen lassen — Syntax und strukturelle Konsistenz, Lade-Reihenfolge, Idempotenz — und braucht dafür kein einziges Secret. Wie der Workflow im Detail aussieht, was der Doppel-Deploy-Trick genau prüft und wie es vom Gate zum Deploy auf echte Umgebungen weitergeht: GitHub Actions für Postgres-Deploys.
Station 6: Teardown
Der Teardown ist die unterschätzte Station des Lebenszyklus. drop.sh <env> beendet aktive Verbindungen, droppt die Datenbank und räumt alle vier Rollen ab — jeweils mit IF EXISTS, sodass das Skript auch nach einem Teilabbruch gefahrlos erneut laufen kann. Im verifizierten Durchlauf blieb danach nichts zurück: kein Eintrag der Datenbank mehr in pg_database, keine der vier Rollen mehr in pg_roles.
Warum eine eigene Station für das Wegwerfen? Weil Teardown der Lackmustest für die Stationen 1 und 2 ist: Eine Umgebung darf nur dann bedenkenlos verschwinden, wenn Bootstrap und Objekt-Deploy sie vollständig wiederherstellen können. Wer beim Gedanken an drop.sh nervös wird, weil in der Datenbank Wissen lebt, das nirgends im Repository steht, hat den Lebenszyklus noch nicht geschlossen. Vor dem Go-live ist Drop-und-Neuaufbau der normale Reset-Pfad; danach gilt die harte Regel: nie auf einer Umgebung mit schützenswerten Daten ohne verifiziertes Backup.
Wann doch ein Migrations-Tool?
Der framework-freie Lebenszyklus trägt gut, solange drei Dinge zusammenkommen: eine Datenbank-Engine, ein überschaubares Team und ein ohnehin Shell-basiertes Deploy. Dann liefern Bash und psql den Kern — Soll-Zustands-Dateien, Run-once-Tracking, CI-Gate — ohne neue Abhängigkeit im Stack. Stillschweigende Voraussetzung dabei: Das Schema lässt sich vollständig deklarativ im Repository beschreiben. Wo extern verwaltete Objekte, Replikations-Setups oder umfangreiche Partitionierung mitspielen, gilt der Anspruch „vollständiger Lebenszyklus“ nur noch eingeschränkt.
Ein Migrations-Tool wie Flyway oder Liquibase wird legitim, sobald dessen Zusatzleistungen wirklich gebraucht werden: Unterstützung mehrerer Datenbank-Engines, Rollback- bzw. Undo-Workflows, eine Lock-Tabelle gegen parallel laufende Deploys, Dry-Run- und Report-Tooling für Audit-Anforderungen, programmatische Migrationen. Dazu kommt, was sich nicht an einzelnen Stationen festmacht: ein etabliertes Ökosystem mit Dokumentation, IDE-Integration und Support — und ein Standard, auf den sich mehrere Teams verständigen können, ohne einen Eigenbau einarbeiten zu müssen. Ab Multi-Team- oder Multi-Datenbank-Betrieb sind das keine Komfort-Features mehr, sondern Notwendigkeiten — dann ist das Framework die richtige Wahl, ohne dass am Modell hier etwas falsch wäre. Außerhalb dieses Artikels bleiben daneben die Betriebs-Themen jenseits des Deploy-Modells: Zero-Downtime-Strategien wie Blue/Green oder Rolling Deployments und die Sperr-Fragen sehr großer Tabellen — Letztere zeigt der Expand/Contract-Artikel am konkreten Fall.
Die ehrliche Einordnung geht aber auch in die andere Richtung: Ein Migrations-Tool ersetzt im Kern zwei der sechs Stationen — den Objekt-Deploy und die Evolutions-Mechanik. Bootstrap, Objekt-Tests, das CI-Gate und der Teardown bleiben eigene Arbeit, mit Framework wie ohne. Wer den Lebenszyklus einmal komplett selbst gebaut hat, trifft die Werkzeug-Entscheidung deshalb von innen heraus: Er weiß, welche Stationen das Tool übernimmt, welche es offenlässt — und liest Fehlermeldungen wie „Migration checksum mismatch“ als das, was sie sind: dieselben Design-Entscheidungen, nur hinter einer anderen Oberfläche.
FAQ
Nein, nicht zwingend. Der Kern — versionierte Soll-Zustands-Dateien, Run-once-Tracking für Daten-Änderungen und ein CI-Gate gegen eine Wegwerf-Datenbank — passt in Bash und psql. Ein Framework lohnt sich, wenn seine Zusatzleistungen gebraucht werden: Multi-Engine-Support, Rollback-Workflows, Lock gegen parallele Deploys oder Audit-Reporting. Zwei der sechs Lebenszyklus-Stationen nimmt es ab, vier bleiben eigene Arbeit.
Mit einem Skript-Runner, der die Objekt-Dateien in fester Sektions- und Nummern-Reihenfolge aus dem Repository anwendet, und einem CI-Workflow, der genau diesen Runner aufruft — bei jedem Pull Request gegen eine Wegwerf-Datenbank, beim Release gegen die echte Umgebung. Da jede Datei idempotent ihren Soll-Zustand beschreibt, ist der Deploy immer derselbe Aufruf, egal ob die Datenbank leer oder aktuell ist.
Eine Migrations-Kette beschreibt den Weg: nummerierte Änderungs-Skripte, die aufeinander aufbauen und der Reihe nach laufen. Ein Objekt-Deploy beschreibt das Ziel: pro Schema-Objekt eine Datei mit dem Soll-Zustand, beliebig oft anwendbar. Der Objekt-Ansatz hält den aktuellen Zustand an einer Stelle lesbar; für Daten-abhängige Schritte braucht er zusätzlich Run-once-Skripte — genau die Stelle, an der beide Modelle sich wieder treffen.
Mit einem vollständigen Probelauf gegen eine leere Wegwerf-Datenbank: komplettes Deploy, danach Objekt-Tests als DO $$ … ASSERT-Blöcke, dann ein zweites Deploy als Idempotenz-Prüfung. Lokal übernimmt das ein Docker-Container, im Pull Request derselbe Ablauf als GitHub-Actions-Gate. Was dieser Prüfstand nicht sieht — Effekte auf Bestandsdaten, Sperren auf großen Tabellen —, bleibt Aufgabe der Evolutions-Disziplin aus Station 4.
Bewährt haben sich vier, mit klarer Arbeitsteilung: ein Datenbank-Eigentümer, ein Schema-Owner, unter dem alle Deploys laufen (deployte Objekte gehören damit automatisch ihm), eine Lese-Schreib-Gruppenrolle für den Datenzugriff und ein Service-Account für die Anwendung, der Mitglied dieser Gruppenrolle ist. Der Superuser postgres bleibt dem Bootstrap und dem Teardown vorbehalten und taucht in keiner Anwendungs-Verbindung auf.
Verwandte Artikel
- SQL-Schema deployen ohne Migrations-Tool — Verzeichnis-Konvention statt Flyway oder Liquibase — Station 2 in der Tiefe: das Apply-Modell, auf dem der ganze Lebenszyklus aufbaut.
- NOT-NULL-Spalte nachträglich hinzufügen — das Expand/Contract-Muster für befüllte Tabellen — Station 4 am häufigsten Einzelfall durchgespielt.
- Schema-Änderungen tracken ohne Framework — Run-once-Skripte, Checksummen und Immutabilität — Station 4: der Tracker im Selbstbau, mit Flyway-Vergleich.
- GitHub Actions für Postgres-Deploys — mit Wegwerf-Datenbank als Qualitäts-Gate — Station 5 in der Tiefe: Service-Container, Doppel-Deploy, Environments.
- Migration verifizieren — Datenqualität und Zeilen-Abgleich nach dem Umzug — wenn der Anlass des Neuaufbaus eine Datenbank-Migration war: der Abnahme-Check danach.
- Das offene DI²-Starter-Kit auf GitHub — alle sechs Stationen lauffähig im Zusammenhang.