Getting Postgres + Node right the first time
Connecting Node to Postgres looks like a one-liner, then production teaches you about connection pools, SSL, connection limits, and migrations. This tutorial gets you to a setup that survives real traffic, using the standard pg driver and notes for Prisma and Drizzle.
The assumption: you have a managed Postgres and a connection string in DATABASE_URL. On PandaStack, provisioning a database auto-injects DATABASE_URL into your app's environment, so you skip the copy-paste step.
The connection string
A Postgres URL looks like:
postgresql://USER:PASSWORD@HOST:5432/DBNAME?sslmode=requireNever hardcode this. Read it from the environment so the same code runs in dev, staging, and prod with different values.
Use a pool, not a client per request
The single biggest mistake is opening a new connection on every request. Postgres connections are expensive and managed databases cap them (PandaStack free tier allows 50 connections; Pro 300). Use a pool:
// db.js
import pg from "pg";
export const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: 10, // keep below your DB connection limit
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
ssl: process.env.DATABASE_URL?.includes("localhost")
? false
: { rejectUnauthorized: false },
});
pool.on("error", (err) => {
console.error("Unexpected idle client error", err);
});Note the max setting. If you run 4 app instances each with max: 10, that's 40 connections — keep the total under your database's limit. Size the pool to limit / instances with headroom.
Querying safely
Always use parameterized queries. Never string-concatenate user input — that's SQL injection.
import { pool } from "./db.js";
export async function getUserByEmail(email) {
// $1 is a bound parameter — safe
const { rows } = await pool.query(
"select id, email, name from users where email = $1",
[email]
);
return rows[0] ?? null;
}For multi-statement transactions, check out a single client so all statements run on the same connection:
export async function transferCredits(fromId, toId, amount) {
const client = await pool.connect();
try {
await client.query("begin");
await client.query("update accounts set credits = credits - $1 where id = $2", [amount, fromId]);
await client.query("update accounts set credits = credits + $1 where id = $2", [amount, toId]);
await client.query("commit");
} catch (e) {
await client.query("rollback");
throw e;
} finally {
client.release(); // always release back to the pool
}
}The finally { client.release() } is non-negotiable — a leaked client exhausts the pool and your app hangs.
SSL notes
Managed Postgres almost always requires TLS. With pg, ssl: { rejectUnauthorized: false } works against providers that use their own CA without you shipping the CA cert. If your provider gives you a CA certificate and you want strict verification:
import fs from "node:fs";
ssl: {
rejectUnauthorized: true,
ca: fs.readFileSync(process.env.PG_CA_CERT_PATH).toString(),
}Start permissive to confirm connectivity, then tighten verification once it works.
With Prisma
Prisma reads DATABASE_URL from the env automatically:
// schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}npx prisma migrate deploy # apply migrations in production (not 'migrate dev')
npx prisma generateInstantiate the client once (singleton) — a new PrismaClient per request will exhaust connections fast:
import { PrismaClient } from "@prisma/client";
export const prisma = globalThis.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalThis.prisma = prisma;With Drizzle
import { drizzle } from "drizzle-orm/node-postgres";
import pg from "pg";
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
export const db = drizzle(pool);Drizzle is a thin layer over the same pg pool, so the pooling advice above applies directly.
Migrations as a release step
Don't run migrations in your app's start command if you scale to multiple instances — they'll race. Run them as a one-off:
# build step or release command, run once per deploy
npx prisma migrate deploy
# or with raw SQL / node-pg-migrate
npm run migrateDeploying on PandaStack
- 1Create a managed PostgreSQL (14.x or 16.x) in the dashboard.
- 2Attach it to your Node service —
DATABASE_URLis injected automatically. - 3Set your migration command as a release/build step.
- 4Deploy; watch live logs for connection success.
- 5Confirm pool size stays under the tier's connection limit.
Production checklist
- Pool sized to
connection_limit / instanceswith headroom. - Parameterized queries everywhere.
client.release()in everyfinally.- SSL enabled (
sslmode=require). - Migrations run once per deploy, not on every instance start.
- Backups configured (PandaStack does scheduled backups; verify retention fits your needs).
References
- node-postgres (pg) docs: https://node-postgres.com/
- node-postgres pooling: https://node-postgres.com/features/pooling
- Prisma deployment / migrate deploy: https://www.prisma.io/docs/orm/prisma-migrate/workflows/development-and-production
- Drizzle node-postgres: https://orm.drizzle.team/docs/get-started-postgresql#node-postgres
- PostgreSQL connection strings: https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING
---
Want a managed Postgres with the connection string auto-wired into your Node app? PandaStack injects DATABASE_URL for you. Provision one free at https://dashboard.pandastack.io