GitHub Actions für Postgres-Deploys — mit Wegwerf-Datenbank als Qualitäts-Gate

Der Fehler war ein Tippfehler in einem ALTER TABLE — gefunden hat ihn der Staging-Deploy am Freitagnachmittag. Dabei ist genau dieser Fund automatisierbar: Wer den Postgres-Deploy über GitHub Actions gegen eine Wegwerf-Datenbank fährt, macht aus jedem Pull Request einen vollständigen Probelauf. Syntax- und Semantik-Fehler, Reihenfolge-Probleme und kaputte Idempotenz fallen dann vor dem Merge auf — nicht auf Staging.

Das Wichtigste vorab:

  • Was ein DB-CI-Gate fangen muss: die drei Fehlerklassen — Syntax- und Semantik-Fehler, Reihenfolge-Probleme, kaputte Idempotenz.
  • Die Wegwerf-Datenbank: Postgres als Service-Container, pro CI-Lauf frisch hochgezogen — ohne ein einziges Secret.
  • Das Gate: jeder Pull Request deployt das komplette Schema gegen die leere Datenbank und lässt die Objekt-Tests laufen.
  • Der Doppel-Deploy-Trick: ein zweites Deploy im selben Lauf ist der billigste Idempotenz-Test, den es gibt.
  • Vom Gate zur echten Umgebung: Environments, Secrets und Branch-Rules für das Deploy auf dev bis prod.

Voraussetzung: Ein GitHub-Repository mit Actions und Deploy-Skripte nach dem Verzeichnis-Runner-Modell aus SQL-Schema deployen ohne Migrations-Tool — das Gate ruft sie nur auf. Alle gezeigten Schritte und Fehlerbilder sind gegen Postgres 17 in einem Wegwerf-Docker-Container nachgespielt, einschließlich der Negativ-Tests. Die Pipeline stammt aus dem offenen DI²-Starter-Kit auf GitHub, wo sie den Beispiel-Schemabaum absichert.

Inhalt

Was ein DB-CI-Gate fangen muss

Der übliche „CI für Datenbanken“-Rat bleibt beim Linting stehen: ein Style-Checker über die SQL-Dateien, vielleicht noch ein Schema-Diff. Das ist nicht falsch — aber die Fehler, die einen Deploy tatsächlich abbrechen, findet kein Linter. Sie zeigen sich erst, wenn die Skripte gegen eine echte Datenbank laufen. Drei Klassen sind es, in aufsteigender Heimtücke:

Syntax- und Semantik-Fehler. Die triviale Hälfte — ein fehlendes Komma, ein vertipptes Schlüsselwort — fängt schon ein Linter, denn das ist reine Syntax. Die andere Hälfte kann nur eine Datenbank prüfen, weil sie Semantik ist: ob der vertippte Typ varchr(2) existiert, ob die vorausgesetzte Extension installiert ist, ob die aufgerufene Funktion diese Signatur hat. Für einen Parser ist das alles gültiges SQL — erst die Ausführung meldet den fehlenden Typ. Mit ON_ERROR_STOP bricht psql beim ersten Fehler ab, und der CI-Lauf wird rot.

Reihenfolge-Probleme. Das konventionsbasierte Deploy-Modell führt Objekt-Dateien in Nummern-Reihenfolge aus. Greift eine früh nummerierte Datei auf ein Objekt zu, das erst später entsteht — ein ALTER TABLE in der 000-Datei auf eine Tabelle aus der 005-Datei —, fällt das auf der Entwicklungs-Datenbank nicht auf: Dort existiert die Tabelle längst, seit irgendeinem früheren Deploy. Erst eine leere Datenbank deckt den Fehler auf:

psql:db/schemas/app/tables/000.add-country-code.sql:2: ERROR:  relation "app.customer" does not exist
exit code: 3

Genau deshalb reicht „läuft bei mir auf dev“ als Beweis nicht — der Fehler wartet auf die nächste Umgebung, die von null aufgebaut wird. Im Test verschwand er, sobald die Datei hinter die Tabellen-Datei nummeriert wurde: gleiche Skripte, richtige Reihenfolge, grüner Lauf.

Kaputte Idempotenz. Das Deploy-Modell verlangt, dass jede Objekt-Datei beliebig oft laufen kann. Ein nacktes CREATE TABLE ohne IF NOT EXISTS erfüllt das nicht — und bleibt trotzdem unsichtbar, solange jede Datei nur einmal läuft. Der erste Deploy ist grün, erst der zweite scheitert. Diese Klasse braucht also nicht nur eine echte Datenbank, sondern einen zweiten Durchlauf — dazu unten mehr.

Alle drei Klassen haben denselben Nenner: Sie brauchen einen vollständigen Probelauf gegen eine Datenbank, die vorher nichts wusste. Genau das lässt sich in GitHub Actions für ein paar Cent Rechenzeit pro Pull Request verdrahten.

Die Wegwerf-Datenbank: Postgres als Service-Container

GitHub Actions bringt das nötige Stück Infrastruktur nativ mit: Service-Container. Der services:-Block startet neben dem Job einen Docker-Container, der für die Dauer des Laufs lebt und danach mitsamt Datenbank verschwindet — die Wegwerf-Datenbank muss also niemand aufräumen:

  1: name: CI
  2:
  3: on:
  4:   pull_request:
  5:     branches: [main]
  6:
  7: permissions:
  8:   contents: read
  9:
 10: jobs:
 11:   db-dry-run-deploy:
 12:     runs-on: ubuntu-latest
 13:
 14:     services:
 15:       postgres:
 16:         image: postgres:17
 17:         env:
 18:           POSTGRES_PASSWORD: pw
 19:         ports:
 20:           - 5432:5432
 21:         options: >-
 22:           --health-cmd "pg_isready -U postgres"
 23:           --health-interval 10s
 24:           --health-timeout 5s
 25:           --health-retries 5
 26:
 27:     env:
 28:       # Superuser-Passwort = POSTGRES_PASSWORD des Service-Containers (kein Secret)
 29:       DB_ADMIN_PASSWORD_POSTGRES: pw
 30:       PGPASSWORD: pw

Drei Stellen entscheiden, ob das zuverlässig läuft:

  • Der Health-Check (Zeilen 21 bis 25). Der Postgres-Container meldet sich nicht sofort einsatzbereit — er initialisiert beim ersten Start das Datenverzeichnis. Ohne Health-Check startet der erste Job-Step, während die Datenbank noch hochfährt, und der erste psql-Connect scheitert sporadisch. Mit pg_isready als --health-cmd wartet GitHub Actions, bis der Container wirklich Verbindungen annimmt, und startet die Steps erst dann.
  • Das Port-Mapping (Zeilen 19 und 20). Die Steps laufen direkt auf dem Runner und erreichen den Service-Container über localhost:5432 — dieselben Verbindungsdaten wie bei einer lokalen Datenbank.
  • Der Versions-Pin (Zeile 16). postgres:17 statt postgres:latest — das Image auf die Major-Version pinnen, die produktiv läuft. Sonst testet das Gate irgendwann stillschweigend gegen eine neuere Engine als die, auf die deployt wird. Soll das Gate mehrere Major-Versionen parallel prüfen — etwa in der Vorbereitung eines Engine-Upgrades —, macht eine strategy.matrix über die Image-Version aus demselben Job mehrere Läufe.

Auffällig ist, was fehlt: Secrets. Das Passwort pw steht im Klartext im Workflow (Zeilen 18, 29, 30) — und das ist hier richtig so. Die Datenbank existiert nur wenige Minuten, nur im Runner, ohne echte Daten. Der Verzicht auf Secrets hat einen handfesten Nebengewinn: GitHub gibt Secrets an Pull Requests aus Forks nicht heraus — ein Gate ohne Secrets läuft deshalb auch für Fork-PRs, was für offene Repositories den Unterschied macht. Dasselbe Muster funktioniert übrigens werkzeug-neutral: GitLab CI kennt services: fast wortgleich, Azure DevOps startet den Container per Container-Job — die Wegwerf-Datenbank ist keine GitHub-Spezialität.

Das Gate: Voll-Deploy und Tests bei jedem Pull Request

Die Steps des Jobs fahren nun genau die Sequenz, die auch eine neue echte Umgebung durchlaufen würde — Bootstrap, Voll-Deploy, Tests. Der Trick ist, dass die Wegwerf-Datenbank dafür wie eine ganz normale Umgebung behandelt wird: local ist im Deploy-Modell aus SQL-Schema deployen ohne Migrations-Tool einfach eine Umgebungs-Konfiguration wie dev oder prod, nur mit committeten Wegwerf-Zugangsdaten:

  1:     steps:
  2:       - name: Checkout
  3:         uses: actions/checkout@v4
  4:
  5:       - name: psql-Client sicherstellen
  6:         run: |
  7:           command -v psql >/dev/null 2>&1 || { sudo apt-get update && sudo apt-get install -y postgresql-client; }
  8:           psql --version
  9:
 10:       - name: Bootstrap (Datenbank, Schema, Rollen)
 11:         run: bash db/scripts/create.sh local
 12:
 13:       - name: Voll-Deploy  alle Schema-Objekte
 14:         run: bash db/scripts/deploy.sh all local
 15:
 16:       - name: Objekt-Tests
 17:         run: |
 18:           for f in db/tests/*.sql; do
 19:             echo ">>> test $f"
 20:             psql -h localhost -p 5432 -U postgres -d app_local \
 21:               -v ON_ERROR_STOP=1 \
 22:               -f "$f"
 23:           done
 24:
 25:       - name: Zweites Deploy  Idempotenz-Beweis
 26:         run: bash db/scripts/deploy.sh all local

Die Sequenz im Einzelnen:

  • Der Bootstrap (Zeilen 10 und 11) legt Datenbank, Schema und Rollen an — mit demselben Skript, das auch eine echte Umgebung aufsetzt. Damit testet das Gate nebenbei den Bootstrap selbst: Ein Fehler in der Rollen-Anlage wird genauso rot wie ein Fehler in einer Tabellen-Datei.
  • Der Voll-Deploy (Zeilen 13 und 14) wendet alle Objekt-Dateien in Sektions- und Nummern-Reihenfolge an. Hier fallen Syntaxfehler und Reihenfolge-Probleme — die leere Datenbank kennt keine Altbestände, hinter denen sich eine falsche Nummerierung verstecken könnte.
  • Die Objekt-Tests (Zeilen 16 bis 23) laufen als gewöhnliche psql-Aufrufe mit ON_ERROR_STOP (Zeile 21): pro Objekt eine SQL-Datei mit DO $$ … ASSERT-Blöcken — Happy Path, erwartete Fehler-Guards, Constraints. Schlägt eine Assertion fehl, bricht die Schleife ab und der Lauf wird rot. Wer mehr will als Objekt-Assertions — Test-Suites, TAP-Ausgabe, hunderte Checks —, greift zum Test-Framework pgTAP; für das Gate hier reicht psql.
  • Das zweite Deploy (Zeilen 25 und 26) ist die Pointe des Gates und bekommt den nächsten Abschnitt.

Der psql-Client-Step (Zeilen 5 bis 8) ist eine Absicherung: Die ubuntu-latest-Runner bringen den Client meist mit, der Guard macht den Workflow unabhängig von diesem Image-Detail.

Damit das Gate wirklich ein Gate ist, gehört der Job in den Branch-Protection-Rules des Ziel-Branches als Required Status Check eingetragen — ein roter db-dry-run-deploy blockiert dann den Merge, egal wie eilig es Freitagnachmittag ist.

Der Doppel-Deploy-Trick

Das Deploy-Modell verspricht Idempotenz: Jede Objekt-Datei beschreibt den Soll-Zustand und kann beliebig oft laufen. Nur — ein Versprechen ist kein Beweis. Der Beweis kostet im CI-Lauf eine einzige zusätzliche Zeile: dasselbe deploy.sh all local ein zweites Mal, gegen die jetzt gefüllte Datenbank. Läuft es fehlerfrei durch, ist die Wiederholbarkeit für jeden Objekt-Typ belegt — nicht behauptet.

Genau genommen beweist der zweite Lauf die fehlerfreie Wiederholbarkeit: Jede Datei läuft ein zweites Mal durch, ohne abzubrechen. Dass sie dabei auch dasselbe Ergebnis hinterlässt — und nicht etwa still einen Wert weiterzählt —, bleibt Konvention der Objekt-Dateien; der Doppel-Deploy macht die Verstöße sichtbar, die einen Fehler auslösen, nicht die, die still Daten verändern.

Was der zweite Durchlauf fängt, zeigt der Negativ-Test: eine Tabellen-Datei mit nacktem CREATE TABLE statt CREATE TABLE IF NOT EXISTS. Der erste Deploy läuft grün durch — die Tabelle ist ja neu. Der zweite bricht ab:

psql:db/schemas/app/tables/005.customer.sql:9: ERROR:  relation "customer" already exists
exit code: 3

Ohne den Doppel-Deploy wäre dieser Fehler gemerged worden — und beim nächsten regulären Deploy auf einer Bestandsumgebung explodiert, also genau dort, wo er am meisten stört. Mit ihm ist er eine rote Zeile im Pull Request.

Der zweite Durchlauf beweist noch etwas Zweites, Subtileres: den Skip-Pfad der Run-once-Skripte. Daten-Änderungs-Skripte — Backfills, Umrechnungen — dürfen nur genau einmal pro Datenbank laufen; der Deploy-Runner merkt sie sich dafür in einer Tracking-Tabelle. Beim zweiten Deploy im selben CI-Lauf sind sie bereits registriert, und das Log muss sie überspringen statt erneut auszuführen:

postdeploy: app/postdeploy/202607061000.backfill-customer-country-code.sql skipped (already applied)

Damit testet jeder Pull Request automatisch beide Pfade des Trackers — den Anwenden-Pfad beim ersten, den Überspringen-Pfad beim zweiten Deploy. Ein Fehler in dieser Logik, etwa ein Backfill, der doppelt läuft, würde hier sichtbar, nicht auf einer Umgebung mit echten Daten.

Der Preis für all das: ein zweiter Durchlauf gegen eine Datenbank, die ohnehin schon dasteht — bei kleinen und mittleren Schemata Sekunden, bei sehr großen Objekt-Beständen entsprechend mehr. Gemessen an dem, was er fängt, ist es der billigste Idempotenz-Test, den es gibt.

Vom Gate zur echten Umgebung

Das Gate prüft, ausgeliefert wird woanders: auf dev, test, prod. Für diesen zweiten Schritt wechselt das Muster — statt Wegwerf-Datenbank im Runner ein manuell ausgelöster Workflow, der per SSH auf dem Ziel-Host deployt. Und statt committeter Wegwerf-Passwörter jetzt echte Secrets, verwaltet über GitHub Environments:

  1: name: DB - deploy
  2:
  3: on:
  4:   workflow_dispatch:
  5:     inputs:
  6:       environment:
  7:         description: 'Ziel-Umgebung'
  8:         required: true
  9:         default: 'dev'
 10:         type: choice
 11:         options: [dev, test, prod]
 12:
 13: jobs:
 14:   deploy:
 15:     name: Deploy nach ${{ github.event.inputs.environment }}
 16:     runs-on: ubuntu-latest
 17:     environment: ${{ github.event.inputs.environment }}
 18:
 19:     steps:
 20:       - name: deploy.sh via SSH
 21:         uses: appleboy/ssh-action@v1.2.0
 22:         env:
 23:           DB_FW_PASSWORD: ${{ secrets.DB_FW_PASSWORD }}
 24:         with:
 25:           host: ${{ secrets.DEPLOY_SSH_HOST }}
 26:           username: ${{ secrets.DEPLOY_SSH_USER }}
 27:           key: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}
 28:           envs: DB_FW_PASSWORD
 29:           script: |
 30:             set -e
 31:             cd "${{ vars.DEPLOY_PATH }}"
 32:             git fetch origin "${{ github.ref_name }}"
 33:             git reset --hard "origin/${{ github.ref_name }}"
 34:             bash db/scripts/deploy.sh all "${{ github.event.inputs.environment }}"

Die Umgebungs-Logik steckt in Zeile 17: environment: bindet den Job an das GitHub Environment, das im Dispatch-Formular gewählt wurde (Zeilen 6 bis 11). Pro Stage wird einmalig ein Environment angelegt — und darin leben die Secrets (SSH-Zugang und das Passwort des Schema-Owners, unter dem deploy.sh sich verbindet, Zeilen 23 und 25 bis 27) und die Variablen (der Repository-Checkout-Pfad auf dem Host, Zeile 31). Drei Konfigurations-Details ersparen später Fehlersuche:

  • Secrets ins Environment, nicht auf Repository-Ebene. Sobald der Job ein environment: pinnt, zieht er Secrets aus genau diesem Environment — auf Repository-Ebene angelegte Secrets kommen dort leer an. Das ist der häufigste Stolperer beim Einrichten.
  • Deployment-Branch-Rules regeln pro Environment, von welchem Branch aus deployt werden darf — etwa dev und test von dev, prod nur von main. Der Workflow selbst braucht dafür keine eigene Branch-Logik; GitHub verweigert den falschen Branch nativ.
  • Required Reviewers auf dem prod-Environment schalten eine menschliche Freigabe vor den Job: Der Dispatch wartet, bis eine berechtigte Person bestätigt. Das Approval-Gate für die Produktion ist damit Konfiguration, kein Code.

Mehr braucht der zweite Schritt bewusst nicht — die eigentliche Deploy-Intelligenz (Reihenfolge, Idempotenz, Run-once-Tracking) steckt in den Skripten, die das PR-Gate bereits gegen die Wegwerf-Datenbank bewiesen hat. Der Dispatch-Workflow ruft sie nur auf einem anderen Host auf. Der SSH-Weg ist dabei bewusst die kleine Lösung — ein Host, ein Checkout, ein Skript-Aufruf. Wer seine Infrastruktur ohnehin über self-hosted Runner, Kubernetes oder ein Deployment-Werkzeug orchestriert, ersetzt genau diesen einen Step und behält Gate, Environments und Approval unverändert.

Was das Gate nicht fängt

Ein ehrliches Gate kennt seine Grenze, und sie hat einen Namen: leere Datenbank. Das Gate beweist, dass sich das Schema fehlerfrei und wiederholbar von null aufbauen lässt. Es beweist nicht, dass der Deploy auf einer befüllten Bestandsumgebung gut ausgeht:

  • Daten-abhängige Effekte bleiben unsichtbar. Ein Backfill läuft im Gate gegen null Zeilen — UPDATE 0, fertig. Ob dieselbe Anweisung auf zwanzig Millionen Bestandszeilen die richtigen Werte setzt, ob ein nachträgliches SET NOT NULL auf Alt-Daten mit NULL-Werten trifft — das entscheidet sich erst auf der echten Umgebung. Die gängige Ergänzung für diese Klasse ist ein Deploy-Test gegen einen anonymisierten Produktions-Dump — als nächtlicher Job, nicht im PR-Gate, dafür ist er zu langsam.
  • Sperren und Laufzeiten zeigen sich nicht. ALTER TABLE auf einer leeren Tabelle ist in Millisekunden fertig; dieselbe Anweisung auf einer großen Bestandstabelle nimmt Sperren, deren Dauer und Stau-Wirkung das Gate nicht abbildet.
  • Drift auf echten Umgebungen ist außerhalb des Blickfelds. Hat jemand auf prod manuell ein ALTER TABLE abgesetzt, weiß das Gate davon nichts — es testet den Repository-Stand, nicht den Ist-Zustand der Umgebungen. Das ist die Domäne des Change-Trackings.
  • Konfigurations-Fehler der echten Umgebung — fehlende Secrets, falsche Pfade, SSH-Rechte — tauchen erst beim Dispatch-Deploy auf. Das Gate läuft absichtlich ohne diese Infrastruktur.

Die Grenze schmälert den Wert nicht, sie ordnet ihn ein: Das Gate eliminiert die Fehlerklassen, die sich strukturell beweisen lassen — Syntax und Semantik, Reihenfolge, Idempotenz. Für die daten-abhängigen Klassen gelten die eigenen Disziplinen: das Expand/Contract-Muster für Struktur-Änderungen an befüllten Tabellen und das Run-once-Tracking für Daten-Änderungen.

FAQ

Wie startet man PostgreSQL in GitHub Actions?

Als Service-Container: Im Job einen services:-Block mit image: postgres:17POSTGRES_PASSWORD und Port-Mapping 5432:5432 deklarieren, dazu einen Health-Check mit pg_isready. GitHub Actions startet den Container neben dem Job und wartet dank Health-Check, bis die Datenbank Verbindungen annimmt; die Steps erreichen sie dann unter localhost:5432.

Wie testet man Datenbank-Änderungen in CI ohne Migrations-Framework?

Mit einem vollständigen Probelauf: Bei jedem Pull Request das komplette Schema gegen eine leere Wegwerf-Datenbank deployen, danach Objekt-Tests als psql-Aufrufe mit ON_ERROR_STOP laufen lassen. Das fängt Syntax- und Semantik-Fehler sowie Reihenfolge-Probleme, die ein Linter nicht sieht — eine leere Datenbank kennt keine Altbestände, hinter denen sich Fehler verstecken.

Was bringt ein zweites Deploy im selben CI-Lauf?

Es beweist die Idempotenz der Deploy-Skripte: Jede Objekt-Datei muss auch gegen die bereits gefüllte Datenbank fehlerfrei laufen. Ein nicht wiederholbares Statement — etwa ein CREATE TABLE ohne IF NOT EXISTS — passiert den ersten Deploy und fällt erst im zweiten auf. Nebenbei testet der zweite Lauf den Überspringen-Pfad des Run-once-Trackings.

Braucht das CI-Gate Zugriff auf Datenbank-Secrets?

Nein — bewusst nicht. Die Wegwerf-Datenbank lebt nur im Runner, enthält keine echten Daten und nutzt committete Wegwerf-Zugangsdaten. Dadurch läuft das Gate auch für Pull Requests aus Forks, denen GitHub keine Secrets aushändigt. Echte Secrets kommen erst beim Deploy auf reale Umgebungen ins Spiel — dort in GitHub Environments verwaltet, nicht auf Repository-Ebene.

Funktioniert das Muster auch in GitLab CI oder Azure DevOps?

Ja. Das Muster — Wegwerf-Datenbank hochziehen, Voll-Deploy, Objekt-Tests, zweites Deploy — ist werkzeug-neutral. GitLab CI deklariert die Datenbank fast wortgleich im services:-Abschnitt der Pipeline, Azure DevOps startet sie als Container-Ressource oder per docker run im Agent-Job. Nur die Environment-/Approval-Mechanik des zweiten Schritts heißt in jedem Werkzeug anders.

Verwandte Artikel