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:
| Variable | Wofür |
|---|---|
DATABASE_URL | alle normalen Abfragen deiner App, über einen Verbindungs-Pool |
DATABASE_URL_UNPOOLED | Migrationen 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_URLoderDATABASE_URL_UNPOOLEDselbst (gooddeploy env add), gilt dein Wert, und gooddeploy warnt dich dabei.
Preview und Live
| Umgebung | Datenbank |
|---|---|
| Live | eigene Datenbank, z. B. kunde_website_prod |
| Previews | eine 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.
promotebaut 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.rollbacktauscht 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 erstellen | Auf gooddeploy ausrollen | |
|---|---|---|
| Prisma | npx prisma migrate dev --name neue-spalte | prisma migrate deploy |
| Drizzle | npx drizzle-kit generate | drizzle-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 ingooddeploy.json, der deine Migrationen einmal jepromotevor 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
promotegegen die Live-Datenbank. Bereits eingespielte Migrationen überspringen beide Werkzeuge. - Schlägt eine Migration fehl, startet die Version nicht; bei
promotebleibt die bisherige Version live. Die Ausgabe siehst du mitgooddeploy 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
standalonewird 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_URL | Stattdessen |
|---|---|---|
| Abfragen, Transaktionen, Prepared Statements | geht | – |
SET LOCAL innerhalb einer Transaktion | geht | – |
SET für die Sitzung (z. B. SET search_path) | geht nicht: wirkt auf fremde Abfragen und fehlt bei deinen nächsten | SET LOCAL in der Transaktion, oder Einstellung in die Abfrage |
LISTEN / NOTIFY | LISTEN 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ängen | pg_advisory_xact_lock in einer Transaktion, oder über DATABASE_URL_UNPOOLED |
Migrationen (prisma migrate, drizzle-kit migrate) | nicht verlässlich | DATABASE_URL_UNPOOLED |
| temporäre Tabellen über mehrere Transaktionen | geht nicht | in einer Transaktion halten |
Verbindungsgrenzen
Die Grenzen gelten je Datenbank, also für Live und für alle Previews zusammen jeweils einmal:
DATABASE_URL | DATABASE_URL_UNPOOLED | |
|---|---|---|
| gleichzeitig arbeitende Verbindungen zur Datenbank | 5 | 2 |
| offene Verbindungen deiner App insgesamt | 100 | 10 |
| Warten auf eine freie Verbindung | höchstens 30 Sekunden | höchstens 30 Sekunden |
| Verbindung ohne Abfrage | bleibt | nach 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
| Meldung | Ursache | Lö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 Pool | Migrationen 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 unexpectedly | 30 Sekunden lang war keine der 5 Verbindungen frei | Transaktionen kürzer, Pool je Instanz klein (max: 5); viele Previews teilen sich eine Datenbank |
canceling statement due to statement timeout | Abfrage länger als 30 Sekunden | Abfrage 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 timeout | Transaktion stand über 60 Sekunden ohne Abfrage offen | nicht innerhalb einer Transaktion auf andere Dienste warten |
Build bricht ab mit ECONNREFUSED oder fehlender DATABASE_URL | eine Seite fragt beim Vorrendern in next build die Datenbank ab; beim Build gibt es keine Datenbank | Seite dynamisch machen, z. B. await connection() aus next/server vor der Abfrage |
PrismaConfigEnvError: Cannot resolve environment variable beim Build | env("…") in prisma.config.ts | process.env.DATABASE_URL_UNPOOLED ?? "" (siehe Prisma) |
Migrationen laufen nicht, obwohl sie im start-Skript stehen | gooddeploy startet mit node server.js, nicht npm start | siehe Migrationen ausrollen |
| Eine Preview zeigt Fehler zu fehlenden oder unbekannten Spalten | eine andere Preview hat eine Migration in die gemeinsame Preview-Datenbank eingespielt | Migrationen rückwärtsverträglich schreiben (erst erweitern, dann aufräumen) |
SET scheint nicht zu wirken oder wirkt auf andere Anfragen | SET für die Sitzung über den Pool | SET LOCAL in der Transaktion |