← Blog

Wie man ein Node.js-Backend mit Postgres deployt

Railyard

30. Juni 2026 · 8 Min. Lesezeit

Folge dem auf deinem eigenen Server. Kostenlos starten, keine Karte nötig

“Ein Node-Backend mit Postgres deployen” klingt nach einer Aufgabe, ist aber eigentlich vier: Die API schreiben, ihr eine echte Datenbank statt eines In-Memory-Arrays geben, die Form dieser Datenbank sicher ändern, während die App wächst, und sicherstellen, dass die Verbindungen zwischen beiden nicht zusammenbrechen, sobald zwei Requests gleichzeitig eintreffen. Die meisten Node-und-Postgres-Tutorials hören bei den ersten zwei auf. Dieses hier baut alle vier: eine Express-API mit pg im Rücken, Migrationen, die in jeder Umgebung gleich laufen, einen Connection Pool, der bewusst dimensioniert ist statt zufällig, und ein Docker-Compose-Setup für die lokale Entwicklung, das dem entspricht, was tatsächlich in Produktion läuft.

Was du brauchst

  • Node.js 22 oder 24, lokal installiert.
  • Docker und das Docker-Compose-Plugin, für die lokale Entwicklung und um das Setup zu validieren, bevor es irgendwo anders hingeht.
  • Grundkenntnisse in SQL; die Migrationen unten sind reine SQL-Dateien, kein von einem ORM generiertes Schema.

Schritt 1: Die API schreiben

Express 5.3.0 ist die aktuelle Version, und pg 8.23.1 ist die aktuelle Version von node-postgres, dem Standard-Treiber, um ohne ORM mit Postgres von Node aus zu sprechen:

npm init -y
npm install express@^5.3.0 pg@^8.23.1
npm install --save-dev node-pg-migrate@^9.0.0 nodemon@^3.1.0

server.js:

const express = require("express");
const { Pool } = require("pg");

const app = express();
app.use(express.json());

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: Number(process.env.PG_POOL_MAX || 10),
  idleTimeoutMillis: 30000,
});

app.get("/health", (req, res) => res.json({ status: "ok" }));

app.get("/widgets", async (req, res) => {
  const result = await pool.query(
    "SELECT id, name, created_at FROM widgets ORDER BY id DESC LIMIT 20"
  );
  res.json(result.rows);
});

app.post("/widgets", async (req, res) => {
  const { name } = req.body;
  if (!name) return res.status(400).json({ error: "name is required" });
  const result = await pool.query(
    "INSERT INTO widgets (name) VALUES ($1) RETURNING id, name, created_at",
    [name]
  );
  res.status(201).json(result.rows[0]);
});

app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({ error: "internal error" });
});

const port = process.env.PORT || 3000;
app.listen(port, "0.0.0.0", () => console.log(`listening on ${port}`));

Wie bei jeder Express-5-App brauchen die async Route-Handler oben kein try/catch um ihre Datenbankaufrufe: Express 5 leitet ein abgelehntes Promise aus einem async Handler automatisch an die Error-Handling-Middleware weiter, genauso wie ein synchroner throw es würde, sodass der vierargumentige Handler unten eine fehlgeschlagene Query auffängt, ohne jeden Route-Body einzuwickeln.

Schritt 2: Echte Migrationen schreiben, kein Schema, das du einmal von Hand ausführst

CREATE TABLE direkt in psql eingetippt funktioniert genau einmal, beim ersten Mal. Beim zweiten Mal, wenn du eine neue Spalte brauchst, editierst du entweder denselben Befehl aus dem Gedächtnis, oder du hast den Überblick verloren, was wo tatsächlich angewendet wurde. node-pg-migrate löst das, indem jede Änderung als nummerierte, mit Zeitstempel versehene Datei aufbewahrt wird, die der Reihe nach läuft, mit einem Protokoll darüber, was bereits angewendet wurde, in der Datenbank selbst.

Erstelle migrations/1700000000000_create-widgets.js:

exports.up = (pgm) => {
  pgm.createTable("widgets", {
    id: "id",
    name: { type: "text", notNull: true },
    created_at: { type: "timestamptz", notNull: true, default: pgm.func("now()") },
  });
};

exports.down = (pgm) => {
  pgm.dropTable("widgets");
};

Füge Skripte zu package.json hinzu:

{
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js",
    "migrate:up": "node-pg-migrate up",
    "migrate:down": "node-pg-migrate down"
  }
}

node-pg-migrate liest seinen Connection-String standardmäßig aus der Umgebungsvariable DATABASE_URL, derselben Variable, die die App selbst nutzt, es gibt also genau eine Stelle, an der die Datenbankadresse konfiguriert wird. Führe es aus, sobald Postgres läuft (Schritt 4):

DATABASE_URL=postgres://myapp:mypassword@localhost:5432/myapp npm run migrate:up

Du solltest widgets als angewendet aufgeführt sehen, und \dt innerhalb von psql sollte die Tabelle zeigen. Der Sinn einer Migrationsdatei gegenüber einem einmaligen CREATE TABLE ist keine Förmlichkeit: Es ist, dass exakt dieselben Dateien in Entwicklung, in CI und in Produktion laufen, sodass “wurde das Schema in Produktion tatsächlich angewendet” eine Frage mit einer dateibasierten statt einer gedächtnisbasierten Antwort ist.

Schritt 3: Den Connection Pool bewusst dimensionieren

new Pool({ connectionString, max: 10 }) bedeutet, dass dieser eine Node-Prozess höchstens 10 gleichzeitige Verbindungen zu Postgres öffnet, die über Requests hinweg wiederverwendet werden, statt pro Query eine neue TCP-Verbindung zu öffnen. Diese Zahl ist nicht beliebig, und sie in eine der beiden Richtungen falsch zu setzen verursacht ein echtes Problem:

  • Zu niedrig, und Requests stauen sich hinter dem Pool, warten unter echter Parallelität auf eine freie Verbindung, was als Latenz auffällt, die nichts mit Query-Geschwindigkeit zu tun hat.
  • Zu hoch, und es ist überhaupt keine Pro-Prozess-Entscheidung mehr: Postgres selbst hat eine max_connections-Einstellung, standardmäßig 100, geteilt über jeden mit dieser Datenbank verbundenen Client. Betreibe drei Instanzen dieser App mit je max: 50, und du hast 150 Verbindungen reserviert, bevor auch nur ein weiterer Service, Migrationslauf oder Admin-psql-Session sich zu verbinden versucht, und Postgres beginnt, neue Verbindungen rundweg abzulehnen statt elegant zu degradieren.

Ein vernünftiger Ausgangspunkt für eine kleine App sind 10–20 Verbindungen pro Prozess, multipliziert mit der Anzahl der App-Instanzen, die du tatsächlich betreibst, geprüft gegen die tatsächlich konfigurierte max_connections der Datenbank statt angenommen. idleTimeoutMillis: 30000 oben schließt Verbindungen, die der Pool nach 30 Sekunden nicht nutzt, sodass eine ruhige Phase nicht umsonst Verbindungen offenhält.

Schritt 4: Docker Compose für die lokale Entwicklung

services:
  db:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: devpassword
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp"]
      interval: 5s
      timeout: 5s
      retries: 5

  web:
    build: .
    restart: unless-stopped
    environment:
      DATABASE_URL: postgres://myapp:devpassword@db:5432/myapp
      PORT: 3000
    ports:
      - "3000:3000"
    volumes:
      - .:/app
      - /app/node_modules
    command: npm run dev
    depends_on:
      db:
        condition: service_healthy

volumes:
  postgres_data:

db’s Port lokal zu veröffentlichen (anders als in einer Produktions-Compose-Datei) ist hier bewusst: Es erlaubt dir, einen GUI-Client zu verbinden oder psql von deiner eigenen Maschine gegen dieselbe Datenbank laufen zu lassen, die die App nutzt, was den zusätzlichen Komfort während der Entwicklung wert ist. Der web-Service mountet das Projektverzeichnis über das Image und führt npm run dev (nodemon) statt npm start aus, sodass das Editieren von server.js den Prozess neu startet, ohne das Image neu zu bauen; /app/node_modules als eigenes anonymes Volume verhindert, dass der Bind-Mount die node_modules überschattet, die das Dockerfile bereits im Image installiert hat.

Starte es und führe die Migration gegen die containerisierte Datenbank aus:

docker compose up -d
docker compose exec web npm run migrate:up

Dann sollten curl -X POST localhost:3000/widgets -H "Content-Type: application/json" -d '{"name":"first widget"}' gefolgt von curl localhost:3000/widgets die gerade eingefügte Zeile zurückgeben.

Schritt 5: Das Dockerfile für die Produktion

FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]

Ich habe die Produktions-Compose-Form (dieses Dockerfile plus ein db- und web-Service ohne die Dev-Bind-Mounts oder den veröffentlichten Postgres-Port) validiert mit:

docker compose -f /tmp/compose-check/docker-compose.yml config

Es parste saubereich, mit POSTGRES_PASSWORD und DATABASE_URL in der Umgebung gesetzt.

Der Deploy-Weg

Ein VPS, der das mit Docker Compose betreibt, ein cron-geplanter pg_dump, und Caddy davor für HTTPS funktionieren und sind ein gut erprobter Weg; siehe Deploying einer Express-App auf DigitalOcean für diesen Durchgang von Anfang bis Ende, inklusive Caddyfile und Firewall-Regeln.

Der schnellere Weg ist, Railyard auf dasselbe Repository zu richten. Nach seinen eigenen Erkennungsregeln: Eine pg-Abhängigkeit in package.json reicht Railyard, um automatisch eine Managed-Postgres-Datenbank zu provisionieren und DATABASE_URL zu injizieren, kein Addon von Hand hinzuzufügen; das start-Skript wird zum Web-Prozess, der $PORT liest, das Railyard genauso zuweist, wie die server.js dieser Anleitung es schon erwartet; und ein Release-Befehl (setze ihn auf npm run migrate:up in den Deploy-Einstellungen der App) läuft einmal, bevor jeder neue Release live geht, sodass die Migration aus Schritt 2 bei jedem Deploy automatisch passiert, statt ein Schritt zu sein, an den du dich erinnern musst, um dich per SSH einzuloggen und ihn auszuführen. Siehe die Node.js-Docs für die exakten Erkennungsregeln und Variablen und Secrets, wie DATABASE_URL und andere injizierte Variablen funktionieren.

Für die Pool-Dimensionierung aus Schritt 3 hat Railyards Datenbank-Konsole einen Insights-Tab, der Live-Verbindungen direkt gegen max_connections zeigt, statt zu raten, ob max: 10 pro Instanz sicher ist, kannst du also die tatsächliche Zahl gegen die tatsächliche Obergrenze beobachten, während der Traffic wächst; siehe Datenbanken und Backups für Backups, Point-in-Time-Restore und die Konsole.

Troubleshooting

ECONNREFUSED beim Verbinden zu Postgres. Innerhalb von Docker Compose muss DATABASE_URL auf db zeigen, den Service-Namen, nicht auf localhost; localhost innerhalb des web-Containers bedeutet den web-Container selbst, in dem kein Postgres läuft.

Migrationen sagen “already applied”, aber die Spalte ist nicht da. Das bedeutet meist, dass eine Migrationsdatei nach dem ersten Lauf bearbeitet wurde. Migrations-Tools verfolgen, was angewendet wurde, anhand von Dateiname/Zeitstempel, nicht durch Diffing des tatsächlichen Schemas, eine Änderung an einer bereits angewendeten Datei ist also für sie unsichtbar; füge eine neue Migration hinzu statt eine alte zu bearbeiten, auch um einen Fehler zu korrigieren.

Die App funktioniert lokal einwandfrei, aber Postgres beginnt in Produktion, Verbindungen abzulehnen. Das ist fast immer das Pool-Dimensionierungsproblem aus Schritt 3: mehr Instanzen, oder ein höheres max pro Instanz, als max_connections tatsächlich erlaubt. Prüfe aktuelle Verbindungen gegen das Limit (den Insights-Tab, oder SELECT count(*) FROM pg_stat_activity; gegen SHOW max_connections; von Hand), bevor du zu einem größeren Datenbank-Plan greifst.

Requests stauen sich unter Last, obwohl die CPU unauffällig aussieht. Das ist ein Pool, der für die tatsächlich beobachtete Parallelität zu klein ist, nicht notwendigerweise eine langsame Query; pool.waitingCount (eine Eigenschaft der Pool-Instanz) zeigt, wie viele Queries gerade auf eine freie Verbindung warten, die Zahl, die zu beobachten ist, bevor man annimmt, dass die Datenbank selbst der Flaschenhals ist.

FAQ

Brauche ich Docker für die lokale Entwicklung, oder kann ich Postgres einfach nativ laufen lassen? Docker Compose hält die Postgres-Version und Konfiguration zwischen deiner Maschine und Produktion konsistent, und das nutzt diese Anleitung, aber ein nativ installiertes Postgres mit DATABASE_URL, das auf localhost zeigt, funktioniert identisch; nichts in server.js oder den Migrationen interessiert sich dafür, wie Postgres selbst läuft.

Kann ich ein ORM wie Prisma oder Drizzle statt rohem pg und node-pg-migrate nutzen? Ja, und die Connection-Pooling- und max_connections-Mathematik aus Schritt 3 gilt genauso, da die meisten Node-ORMs pg.Pool (oder ein nahes Äquivalent) darunter einwickeln statt es zu ersetzen. Prisma und Drizzle liefern beide ihr eigenes Migrations-Tooling als Alternative zu node-pg-migrate, falls du Schema und Query-Layer lieber in einem Tool behalten möchtest.

Was ist eine sichere max_connections- und Pool-Größe für eine kleine App? Postgres setzt standardmäßig 100. Für eine einzelne App-Instanz mit einem bescheidenen Pool von 10–20 bleibt reichlich Spielraum; die Zahl, die tatsächlich zu beobachten ist, ist die Gesamtzahl der Verbindungen über jede Instanz, Migrationslauf und Admin-Session zusammen, gegen das, was die Datenbank tatsächlich konfiguriert hat, nicht gegen den Standardwert isoliert.

Warum ein Release-Befehl statt Migrationen beim Start innerhalb von server.js laufen zu lassen? Migrationen im Server-Startcode auszuführen bedeutet, dass jede bootende Replik versucht, sie laufen zu lassen, was für kleine Apps ein vernünftiger Ansatz ist, aber riskant wird, sobald mehr als eine Web-Instanz gleichzeitig startet. Ein Release-Befehl läuft genau einmal, bevor Traffic zum neuen Release geroutet wird, was der sicherere Standard ist, sobald eine App mehr als eine Instanz hat.

Die Datenbank ist der Teil von “ein Node-Backend mit Postgres deployen”, der leicht zum Laufen zu bringen und leicht auf eine Art falsch zu machen ist, die erst bei echtem Traffic auffällt. Migrationen, die Dateien statt Erinnerungen sind, und eine Pool-Größe, die eine Entscheidung statt ein Standardwert ist, sind die zwei Änderungen, die den Unterschied machen.

Warum Leute sich dafür Railyard entscheiden

  • Dein eigenes Cloud-Konto. Deploye auf DigitalOcean, Hetzner, Vultr, AWS, OVHcloud oder jedem Linux-Server per SSH, direkt von deinem Anbieter abgerechnet, ohne Aufschlag.
  • Ein fester Preis pro Team. Free ist für immer kostenlos: ein Server plus ein Datenbankserver, drei Apps, keine Karte. Pro kostet 19 $ im Monat für bis zu fünf Server und unbegrenzte Apps. Team kostet 49 $ im Monat mit unbegrenzten Servern. Keine Kosten pro App, pro Deployment, pro Sitz oder pro Gigabyte.
  • Templates mit einem Klick. Deploye Open-Source-Apps, wie Datenbanken, Analytics und Dev-Tools, mit einem Klick auf deinem eigenen Server.
  • Verwaltete Datenbanken. Postgres, MySQL und Redis mit geplanten, wiederherstellungsgeprüften Backups und Point-in-Time-Recovery.
  • Push-to-Deploy. Deploye von GitHub, GitLab oder der CLI, mit automatischem HTTPS und Releases ohne Ausfallzeit.

Spar dir das Datenbank-Setup

Push ein Repo mit einer pg-Abhängigkeit zu Railyard, und eine Managed-Postgres-Datenbank, DATABASE_URL und ein Migrations-Release-Schritt sind für dich verdrahtet.

Kostenloser Plan, keine Kreditkarte, und 14 Tage Pro für jedes neue Team. Ein fester Preis pro Team.