@neondatabase/serverless is not a Postgres client in the usual sense. It never
opens a TCP connection to port 5432:
neon(url)sends every query as an HTTPSPOSTtohttps://<host>/sql.new Pool(...)(and anything built on it:drizzle-orm/neon-serverless, Prisma'sPrismaNeonadapter) opens a WebSocket towss://<host>/v2and tunnels the Postgres protocol through it.
Point either at postgresql://localhost:5432/app and you get a TLS error about a
certificate, because the driver is dialling https://localhost/sql. Nothing is
wrong with your database. Nothing is listening for that protocol.
The tempting fix, and why it is wrong
The obvious move is "if the host is localhost, use pg instead". It works on
day one and then lies to you. drizzle-orm/node-postgres can run
db.transaction(). drizzle-orm/neon-http, the client production uses, throws
on it. Code that passes every local run fails on its first deploy. The types
fork too (NodePgDatabase and NeonHttpDatabase are different), so every
helper that takes a db grows a union.
The right fix keeps the driver and changes where it sends its requests.
The fix: a local proxy and two settings
neonConfig has hooks for exactly this:
import { neonConfig } from "@neondatabase/serverless";
const proxy = process.env.NEON_LOCAL_PROXY; // "localhost:4444"
neonConfig.fetchEndpoint = `http://${proxy}/sql`;
neonConfig.wsProxy = (host, port) => `${proxy}/v2?address=${host}:${port}`;
neonConfig.useSecureWebSocket = false;
neonConfig.pipelineTLS = false;
neonConfig.pipelineConnect = false;
The last two matter. Pipelining sends the TLS handshake and a cleartext password
before the server has answered, which only works against Neon's own proxy. A
local Postgres using trust or SCRAM auth hangs on it.
The proxy on the other end has two jobs:
POST /sql: run{ query, params }(or a{ queries }batch, in one transaction) withpg, and answer in Neon's shape:{ command, rowCount, rows, fields }, rows as arrays of raw text, and errors as HTTP 400 with the Postgres fields (code,detail,constraint...), so the driver raises aNeonDbErrorwith the samecodeas production.GET /v2: accept the WebSocket and pipe its binary frames to the database's TCP port and back.
A generated repo ships this as scripts/neon-local-proxy.ts, run with
db:proxy. It is about 400 lines with no dependency beyond pg.
Two details that cost an hour each:
neon()refuses a connection string without a user and a password, even though a local Postgres ontrustauth ignores the password. Write one in (postgresql://you:local@localhost:5432/app).- Neon runs in UTC and a laptop's Postgres usually runs in the laptop's zone.
An adapter that sends timestamps as wall-clock text without an offset reads
them back shifted, which expires sessions early or late. Create the local
database with
alter database app set timezone to 'UTC'.
Apply it lazily, in one place
Read NEON_LOCAL_PROXY where the connection string is read, not at module
scope. A script that loads .env.local itself has not done so yet when its
imports run, so a module-scope check sees nothing. In this repo
databaseUrl() in src/db/client.ts calls applyNeonLocalProxy(), and every
client (the HTTP one, the pool, Prisma's adapter) calls databaseUrl() before
its first connection.
The same function refuses a local URL with no proxy set, with a message that says what to run. That turns the confusing certificate error into one line of instructions.
Safety
A proxy that opens database connections on request is a door. Three locks:
- Bind to
127.0.0.1, never0.0.0.0. - Only connect to a database on this machine. A connection string or
addressfor any other host is refused, so the proxy cannot be used to reach anything else. - Refuse any request with an
Originheader. The driver in Node never sends one. A browser always does, and browsers open WebSockets cross-origin with no preflight, so without this check any web page you visit could talk to your local Postgres through the proxy.
And one on the app side: the setting throws on a Vercel production deployment, where it can only be a value pasted from a laptop.
Migrations
drizzle-kit and the Prisma CLI do not use the Neon driver. drizzle-kit picks the
first Postgres driver it finds installed, in this order: pg, postgres,
@vercel/postgres, @neondatabase/serverless. With only the Neon driver
installed it tries a WebSocket to localhost and hangs with no output. Install
pg as a devDependency and it uses TCP, which works against Neon's direct
endpoint and against a local Postgres alike. Prisma Migrate always uses TCP.
A deploy-time migration runner built on drizzle-orm/neon-http/migrator does
use the driver, so it applies the proxy setting too.
When not to bother
If you already have a Neon account, a branch per developer is the better local database: it is the real service, with the real latency. The proxy is for the first hour of a project, for offline work, and for test runs that need a fresh database per run.