gooddeploy
Handbuch · Kapitel 14

Datenbank in deiner App nutzen

Postgres für deine Next.js-App: anlegen, mit Prisma, Drizzle oder pg verbinden, lokal entwickeln, Migrationen ausrollen und Daten holen. Dazu, was über den Verbindungs-Pool nicht geht und welche Fehler du am häufigsten siehst.

Testphase: Während der Testphase sichert gooddeploy deine Datenbanken nicht automatisch, Daten können verloren gehen. Sichere wichtige Daten selbst mit gooddeploy db dump (siehe Daten holen und sichern). Automatische Sicherungen kommen später.

Datenbank anlegen

Die Datenbank entsteht zusammen mit dem Projekt. Beim ersten gooddeploy link oder gooddeploy deploy im Terminal fragt gooddeploy nach dem Anlegen des Projekts:

? Postgres anlegen? (J/n)
✓ Postgres für kunde-website angelegt

In Skripten ohne Terminal legst du beides ausdrücklich an:

gooddeploy link kunde-website --yes --postgres

Ob dein Projekt eine Datenbank hat, zeigt gooddeploy db info (siehe Logs, Checks und Datenbank). Zum Anlegen brauchst du das Recht deploy.

Kommt später: Für ein Projekt, das schon ohne Datenbank angelegt ist, gibt es noch keinen Befehl, um sie nachträglich anzulegen. Bis dahin legt goodDEV sie für dich an. Auf einer Plattform ohne Postgres sagt link „Postgres ist auf diesem Server noch nicht verfügbar“ und legt nur das Projekt an.

Eine Datenbank nutzen nur Apps mit eigenem Server-Code (Next.js). Statische Seiten können keine Datenbank ansprechen.

Was deine App bekommt

Bei jedem Start einer Version setzt gooddeploy zwei Variablen:

VariableWofür
DATABASE_URLalle normalen Abfragen deiner App, über einen Verbindungs-Pool
DATABASE_URL_UNPOOLEDMigrationen und alles, was eine durchgehende Sitzung braucht

Beide haben die Form postgres://<rolle>:<passwort>@<host>:<port>/<datenbank> und zeigen auf dieselbe Datenbank mit demselben Zugang; nur der Weg ist anders (siehe Grenzen des Verbindungs-Pools). Die Namen sind dieselben wie bei Vercel und Neon.

Wichtig für Next.js:

  • Die Variablen gibt es nur zur Laufzeit, nicht während next build. Seiten, die beim Build vorgerendert werden, können die Datenbank nicht abfragen (siehe Häufige Fehler).
  • Die Datenbank ist nur aus deiner App erreichbar, nicht aus dem Internet und nicht von deinem Rechner. An die Daten kommst du mit gooddeploy db dump.
  • Die Datenbank ist derzeit Postgres 17. Deine Rolle besitzt ihre Datenbank, ist aber kein Superuser.
  • Setzt du DATABASE_URL oder DATABASE_URL_UNPOOLED selbst (gooddeploy env add), gilt dein Wert, und gooddeploy warnt dich dabei.

Preview und Live

UmgebungDatenbank
Liveeigene Datenbank, z. B. kunde_website_prod
Previewseine gemeinsame Datenbank für alle Previews, z. B. kunde_website_preview

Previews sehen nie die Live-Daten und Live nie die Testdaten. Was das für dich heißt:

  • Alle Previews teilen sich Daten und Schema. Spielt die Preview eines Branches eine Migration ein, gilt sie auch für alle anderen Previews. Ein Branch, der die Spalte noch nicht kennt, läuft dann gegen ein neueres Schema.
  • promote baut nicht neu: Dieselbe Version startet für Live mit der Live-Datenbank. Laufen deine Migrationen beim Start (siehe Migrationen ausrollen), treffen sie in diesem Moment die Live-Datenbank, während die alte Version noch live ist.
  • rollback tauscht nur den Code, nie die Daten. Die ältere Version muss mit dem neueren Schema zurechtkommen.

Daraus folgt die wichtigste Regel für Migrationen: erst erweitern, dann aufräumen (expand/contract). Neue Spalten und Tabellen kommen mit einem Deploy dazu, alter Code läuft weiter; Spalten löschen oder umbenennen erst in einem späteren Deploy, wenn kein Code sie mehr nutzt.

Welche Werkzeuge gehen

Jedes Werkzeug, das Postgres über eine Verbindungs-URL anspricht, z. B. Prisma, Drizzle, Kysely, pg (node-postgres) oder postgres (postgres.js). Für Abfragen nimmst du DATABASE_URL, für Migrationen DATABASE_URL_UNPOOLED.

Der Pool versteht Prepared Statements. Den Zusatz ?pgbouncer=true, den ältere Prisma-Anleitungen nennen, brauchst du nicht, und bei postgres.js musst du prepare nicht abschalten. Geprüft mit Prisma 6.19 und 7.10, Drizzle 0.45 mit postgres.js 3.4 und pg 8.23.

Prisma

Prisma 7 trennt die Verbindungen: Die CLI (Migrationen) liest die Adresse aus prisma.config.ts, deine App verbindet sich über einen Treiber-Adapter.

// prisma/schema.prisma
generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "postgresql"
}
// prisma.config.ts
import { loadEnvConfig } from "@next/env";
import { defineConfig } from "prisma/config";

// Lokal: Werte aus .env.development.local wie bei next dev.
// Auf gooddeploy setzt die Plattform die Variablen.
loadEnvConfig(process.cwd(), process.env.NODE_ENV !== "production");

export default defineConfig({
  schema: "prisma/schema.prisma",
  migrations: { path: "prisma/migrations" },
  datasource: { url: process.env.DATABASE_URL_UNPOOLED ?? "" },
});
// src/lib/prisma.ts
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../generated/prisma/client";

const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
export const prisma = new PrismaClient({ adapter });
"scripts": {
  "build": "prisma generate && next build"
}

Pakete: prisma (Entwicklung), @prisma/client, @prisma/adapter-pg und @next/env (kommt mit Next.js; mit pnpm ausdrücklich installieren).

Nimm in prisma.config.ts process.env.… ?? "" und nicht env("…") aus prisma/config: env() bricht ab, wenn die Variable fehlt, und beim Build fehlt sie immer. prisma generate braucht keine Datenbank.

Prisma 6

Bei Prisma 6 stehen beide Adressen in schema.prisma; Abfragen gehen über url, prisma migrate nimmt von selbst directUrl:

datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DATABASE_URL_UNPOOLED")
}

Drizzle

// drizzle.config.ts
import { loadEnvConfig } from "@next/env";
import { defineConfig } from "drizzle-kit";

loadEnvConfig(process.cwd(), process.env.NODE_ENV !== "production");

export default defineConfig({
  dialect: "postgresql",
  schema: "./src/db/schema.ts",
  out: "./drizzle",
  dbCredentials: { url: process.env.DATABASE_URL_UNPOOLED ?? "" },
});
// src/db/index.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";

const client = postgres(process.env.DATABASE_URL!, { max: 5 });
export const db = drizzle(client);

Migrationen erzeugst du mit drizzle-kit generate und spielst sie mit drizzle-kit migrate ein. drizzle-kit push ändert das Schema ohne Migrationsdatei; nimm es höchstens lokal.

pg und postgres.js direkt

// node-postgres
import { Pool } from "pg";
export const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 5 });

// postgres.js
import postgres from "postgres";
export const sql = postgres(process.env.DATABASE_URL!, { max: 5 });

Halte den Pool je Instanz klein (max: 5 reicht meist): Mehr gleichzeitige Verbindungen zur Datenbank gibt es ohnehin nicht (siehe Verbindungsgrenzen).

Migrationen ausrollen

Migrationen erstellst du lokal und committest die Dateien; ausgerollt werden sie auf gooddeploy:

Lokal erstellenAuf gooddeploy ausrollen
Prismanpx prisma migrate dev --name neue-spalteprisma migrate deploy
Drizzlenpx drizzle-kit generatedrizzle-kit migrate

Ein start-Skript wie "start": "prisma migrate deploy && next start" läuft auf gooddeploy nicht: gooddeploy startet deine Next.js-App mit node server.js, nicht mit npm start.

Kommt später: ein release-Befehl in gooddeploy.json, der deine Migrationen einmal je promote vor dem Umschalten ausführt; schlägt er fehl, bleibt Live unverändert.

Bis dahin geht es mit einer eigenen Startdatei. Das klappt nur ohne output: "standalone" in next.config: Dann übernimmt gooddeploy dein ganzes Projekt samt node_modules und startet eine vorhandene server.js (im Build-Log steht dazu ein Hinweis). Lege im Projekt-Root an:

// server.js – Start auf gooddeploy: erst Migrationen, dann Next.js
const { execFileSync } = require("node:child_process");

execFileSync(process.execPath, ["node_modules/prisma/build/index.js", "migrate", "deploy"], { stdio: "inherit" });
// mit Drizzle stattdessen: ["node_modules/drizzle-kit/bin.cjs", "migrate"]

process.env.NEXT_DEPLOYMENT_ID ||= process.env.GOODDEPLOY_DEPLOYMENT_ID || "";
process.argv.splice(2, 0, "start");
require("next/dist/bin/next");

Das bedeutet:

  • Die Migrationen laufen bei jedem Start einer Version: für jede Preview gegen die gemeinsame Preview-Datenbank, bei promote gegen die Live-Datenbank. Bereits eingespielte Migrationen überspringen beide Werkzeuge.
  • Schlägt eine Migration fehl, startet die Version nicht; bei promote bleibt die bisherige Version live. Die Ausgabe siehst du mit gooddeploy logs <id>.
  • Starten mehrere Instanzen gleichzeitig, wartet Prisma mit einer Sperre, bis die erste fertig ist, aber nur 10 Sekunden; dauert eine Migration länger, startet die wartende Instanz nicht. Die Sperre funktioniert nur über DATABASE_URL_UNPOOLED.
  • Ohne standalone wird das Image größer; gooddeploy warnt dazu im Build-Log.

Lokal entwickeln

Ein eigenes Postgres in Docker, gleiche Hauptversion wie auf gooddeploy:

docker run -d --name meine-app-db -p 5432:5432 \
  -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=meine_app postgres:17

Die Adresse trägst du in .env.development.local ein (lokal gibt es keinen Pool, beide zeigen auf dieselbe Datenbank):

DATABASE_URL="postgres://postgres:postgres@localhost:5432/meine_app"
DATABASE_URL_UNPOOLED="postgres://postgres:postgres@localhost:5432/meine_app"

next dev liest die Datei; Prisma und Drizzle lesen sie über loadEnvConfig in ihrer Konfiguration (siehe oben). Warum nicht .env.local? Die überschreibt gooddeploy env pull (nach Rückfrage) mit den Werten der Previews, und deine lokale Adresse wäre weg. .env.development.local hat bei next dev außerdem Vorrang. Beide Dateien gehören nicht ins Repo (.env*.local in .gitignore).

Danach wie gewohnt: Schema ändern, npx prisma migrate dev --name … (bzw. npx drizzle-kit generate und npx drizzle-kit migrate), Migrationsdateien committen, gooddeploy deploy.

Daten holen und sichern

gooddeploy db dump lädt eine Sicherung der Live-Datenbank herunter (Format von pg_dump -Fc, nur für dich lesbar, Recht read:sensitive). Die Datenbank der Previews kannst du nicht herunterladen.

gooddeploy db dump -o live.dump

In deine lokale Datenbank einspielen, vorhandene Tabellen werden ersetzt:

pg_restore --clean --if-exists --no-owner --no-acl \
  -d "postgres://postgres:postgres@localhost:5432/meine_app" live.dump

Ohne lokale Postgres-Werkzeuge geht es über den Container:

docker exec -i meine-app-db pg_restore --clean --if-exists --no-owner --no-acl \
  -U postgres -d meine_app < live.dump

--no-owner und --no-acl sind nötig, weil es die Rolle deiner Live-Datenbank lokal nicht gibt. Meldet pg_restore eine nicht unterstützte Version im Dateikopf, ist dein pg_restore älter als Postgres 17.

Als Sicherung in der Testphase: Lade vor größeren Änderungen und regelmäßig einen Dump herunter und bewahre ihn sicher auf. Er enthält alle Daten deiner Live-Seite. Zurückspielen auf gooddeploy kannst du ihn noch nicht selbst (kommt später); dabei hilft goodDEV.

Grenzen des Verbindungs-Pools

Über DATABASE_URL bekommt jede Transaktion eine Verbindung aus dem Pool, die nächste Transaktion vielleicht eine andere. Eine einzelne Abfrage ohne BEGIN ist auch eine Transaktion. Daraus folgt:

WasÜber DATABASE_URLStattdessen
Abfragen, Transaktionen, Prepared Statementsgeht–
SET LOCAL innerhalb einer Transaktiongeht–
SET für die Sitzung (z. B. SET search_path)geht nicht: wirkt auf fremde Abfragen und fehlt bei deinen nächstenSET LOCAL in der Transaktion, oder Einstellung in die Abfrage
LISTEN / NOTIFYLISTEN empfängt nichtsüber DATABASE_URL_UNPOOLED; dort trennt gooddeploy Verbindungen nach 10 Minuten ohne Abfrage, du musst also neu verbinden
Advisory Locks (pg_advisory_lock)Sperre bleibt an einer fremden Verbindung hängenpg_advisory_xact_lock in einer Transaktion, oder über DATABASE_URL_UNPOOLED
Migrationen (prisma migrate, drizzle-kit migrate)nicht verlässlichDATABASE_URL_UNPOOLED
temporäre Tabellen über mehrere Transaktionengeht nichtin einer Transaktion halten

Verbindungsgrenzen

Die Grenzen gelten je Datenbank, also für Live und für alle Previews zusammen jeweils einmal:

DATABASE_URLDATABASE_URL_UNPOOLED
gleichzeitig arbeitende Verbindungen zur Datenbank52
offene Verbindungen deiner App insgesamt10010
Warten auf eine freie Verbindunghöchstens 30 Sekundenhöchstens 30 Sekunden
Verbindung ohne Abfragebleibtnach 10 Minuten getrennt

Für jede Verbindung gilt außerdem: Eine einzelne Abfrage darf höchstens 30 Sekunden laufen, eine offene Transaktion höchstens 60 Sekunden ohne Abfrage bleiben. Danach bricht Postgres ab.

Fünf gleichzeitig arbeitende Verbindungen reichen für viele Anfragen, solange Transaktionen kurz sind: Eine Verbindung ist nur für die Dauer einer Transaktion belegt. Halte keine Transaktion offen, während du auf etwas anderes wartest (externe API, Datei-Upload).

Häufige Fehler

MeldungUrsacheLösung
Timed out trying to acquire a postgres advisory lock (Prisma P1002)prisma migrate lief einmal über DATABASE_URL; seine Sperre hängt an einer Verbindung im PoolMigrationen immer über DATABASE_URL_UNPOOLED (directUrl bzw. prisma.config.ts). Die hängende Sperre löst sich, wenn der Pool die Verbindung schließt, spätestens nach einer Stunde; eilt es, hilft goodDEV.
query_wait_timeout, danach Connection terminated unexpectedly30 Sekunden lang war keine der 5 Verbindungen freiTransaktionen kürzer, Pool je Instanz klein (max: 5); viele Previews teilen sich eine Datenbank
canceling statement due to statement timeoutAbfrage länger als 30 SekundenAbfrage oder Index verbessern. Braucht eine Migration länger, schreib SET statement_timeout = '5min'; an den Anfang ihrer SQL-Datei (wirkt über DATABASE_URL_UNPOOLED für diese Sitzung).
terminating connection due to idle-in-transaction timeoutTransaktion stand über 60 Sekunden ohne Abfrage offennicht innerhalb einer Transaktion auf andere Dienste warten
Build bricht ab mit ECONNREFUSED oder fehlender DATABASE_URLeine Seite fragt beim Vorrendern in next build die Datenbank ab; beim Build gibt es keine DatenbankSeite dynamisch machen, z. B. await connection() aus next/server vor der Abfrage
PrismaConfigEnvError: Cannot resolve environment variable beim Buildenv("…") in prisma.config.tsprocess.env.DATABASE_URL_UNPOOLED ?? "" (siehe Prisma)
Migrationen laufen nicht, obwohl sie im start-Skript stehengooddeploy startet mit node server.js, nicht npm startsiehe Migrationen ausrollen
Eine Preview zeigt Fehler zu fehlenden oder unbekannten Spalteneine andere Preview hat eine Migration in die gemeinsame Preview-Datenbank eingespieltMigrationen rückwärtsverträglich schreiben (erst erweitern, dann aufräumen)
SET scheint nicht zu wirken oder wirkt auf andere AnfragenSET für die Sitzung über den PoolSET LOCAL in der Transaktion