# 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.

`@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:

```ts
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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
