Skip to content

Local Postgres with the Neon serverless driver, no Neon account

The Neon driver speaks HTTPS and WebSocket, not the Postgres wire protocol. A small local proxy plus two neonConfig settings let it run against a Postgres on your laptop, with no code fork.

Neon4 min readships at docs/solutions/neon/local-postgres-without-a-neon-account.md

Tags: neon · postgres · local-development · testing · websocket · drizzle · prisma

@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 HTTPS POST to https://<host>/sql.
  • new Pool(...) (and anything built on it: drizzle-orm/neon-serverless, Prisma's PrismaNeon adapter) opens a WebSocket to wss://<host>/v2 and 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:

  1. POST /sql: run { query, params } (or a { queries } batch, in one transaction) with pg, 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 a NeonDbError with the same code as production.
  2. 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 on trust auth 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, never 0.0.0.0.
  • Only connect to a database on this machine. A connection string or address for any other host is refused, so the proxy cannot be used to reach anything else.
  • Refuse any request with an Origin header. 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.