Where your local database runs
Where your dev Postgres runs, across Docker, a Neon cloud branch, and Neon Local, all behind one DATABASE_URL.
Last lesson you designed the shape of your invoicing data: the tables, columns, and keys. That shape needs somewhere to live. You run pnpm dev, your app wakes up wanting to read and write invoices, and it reaches for Postgres. Nobody has told it where that Postgres runs.
There are two ways to get this wrong. Point at a cloud database, and a flight with no wifi or a Friday provider outage leaves you unable to touch your data. Run Postgres on your laptop, and you ship a bug that passes every local check yet fails in staging, because your laptop’s Postgres isn’t the one production runs.
In 2026 there are three credible answers. By the end of this lesson you’ll lay all three on a single axis and defend the one this course settles on.
One environment variable is the whole interface
Section titled “One environment variable is the whole interface”Your app doesn’t know where its database is. All it knows is a Postgres connection string read from an environment variable called DATABASE_URL. Hand it that string and it connects, whether the string points at your laptop or a server three time zones away.
Every option you’re about to meet, a container on your machine, a branch in the cloud, or a bridge between the two, sits on the other end of the same string. Switching between them means editing one line and restarting the dev server. No application code changes, because none of it ever knew where the database was.
Read left to right, the string gives a client everything it needs to reach the database.
postgresql:// is the scheme, postgres:postgres a username and password, @localhost the host, :5432 the port Postgres listens on by default, and /invoicing which database to open. The host is the one segment that answers where: @localhost puts the database on your machine, a host ending in ...neon.tech puts it in the cloud. Every other segment stays the same whichever option you pick.
One guardrail. Your DATABASE_URL holds a password, so it lives in .env.local, the local environment file from earlier chapters, and never goes into git. A connection string in your repository history is a leaked credential.
Just enough Docker to read a compose file
Section titled “Just enough Docker to read a compose file”The first option runs Postgres on your laptop inside Docker . Four words of Docker vocabulary are enough to read its config file; Docker is a large subject with its own course, and this is the minimum to follow along.
- An image is a read-only template: a frozen snapshot of a filesystem plus the program to run. Here it’s
postgres:18, a working Postgres packaged up. - A container is a running instance of an image, an isolated process started from that template. The image is the recipe; the container is the meal.
- A volume is persistent storage mounted into the container. A container is disposable: delete it and everything inside vanishes, tables included. The volume is the part that survives, so you mount one to keep the database files alive across restarts.
- A port mapping connects a port inside the container to a port on your machine. Postgres listens on port 5432 inside its container, unreachable on its own; mapping
5432:5432exposes it on the host, so your app finds it atlocalhost:5432.
With those four, the config file reads as plain English.
Option A: Postgres in a Docker container
Section titled “Option A: Postgres in a Docker container”You describe the container in a file called docker-compose.yml, run docker compose up, and Docker pulls the postgres:18 image and starts it. Your DATABASE_URL then points at postgresql://postgres:postgres@localhost:5432/invoicing, the @localhost host you now know how to spot.
This buys the one thing nothing else on this list can: it works offline. No account, no network, no provider. On a plane, through a coffee-shop wifi outage, on the Friday your cloud provider is having an incident, your database is right there on your disk.
The cost is the mirror image of that win. The Postgres in this container is a different build than the one production runs: it can lag a major version behind, it won’t have the extensions your cloud provider enables, and it pools connections differently. Each gap is a place a bug can hide, where code runs clean against your local container and then fails in CI or staging against the real Postgres. Make Docker your default and you pay that drift cost on every such gap.
The config is short, so let’s walk the four lines that matter.
services: db: image: postgres:18 environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: invoicing ports: - '5432:5432' volumes: - pgdata:/var/lib/postgresql
volumes: pgdata:The version pin. This one line decides which Postgres you get, and it must match production. Parity with production comes down to this number.
services: db: image: postgres:18 environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: invoicing ports: - '5432:5432' volumes: - pgdata:/var/lib/postgresql
volumes: pgdata:The credentials and database name. These three values become the user, password, and database-name segments of DATABASE_URL: the same postgres, postgres, and invoicing labeled in the connection-string figure above.
services: db: image: postgres:18 environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: invoicing ports: - '5432:5432' volumes: - pgdata:/var/lib/postgresql
volumes: pgdata:The port mapping. This line makes localhost:5432 resolve to the database. Without it the container runs but your app can’t reach it.
services: db: image: postgres:18 environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: invoicing ports: - '5432:5432' volumes: - pgdata:/var/lib/postgresql
volumes: pgdata:Persistence. The named volume pgdata mounts at the parent path /var/lib/postgresql, not the /var/lib/postgresql/data that pre-18 tutorials show, because Postgres 18 moved its data directory to a version-specific path. Mount the old path on postgres:18 and it silently fails to persist your rows: same image, one detail off, data quietly gone. This is version drift in practice.
When your project eventually needs an extension, like pgvector for similarity search later in the course, swapping to an image that ships with it is a one-line change.
Two operational facts are worth knowing before you trust this with anything. First, docker compose down stops the container but keeps the volume, so your data is there next time; adding -v (docker compose down -v) deletes the volume too, so reach for that flag deliberately. Second, this container wants host port 5432, and so does the third option we’ll meet. Only one process can own a host port at a time, so you run one or the other per project, not both at once.
Option B: A Neon dev branch in the cloud
Section titled “Option B: A Neon dev branch in the cloud”The second option runs nothing on your laptop. You create one development branch on a Neon project, free while idle, and the console hands you a connection string to paste into .env.local. No compose file, no container, no docker command: the copy-paste is the setup.
The host in that string ends in ...neon.tech, not localhost, because the database genuinely lives in Neon’s cloud. That buys prod-parity: same Postgres major version, same extensions, same connection pooler, same pricing behavior as production. Code that runs against this branch runs against production, so the “works on my machine” gap Docker opens never exists.
The costs are the inverse of Docker’s:
- It needs connectivity. No network, no database. This is the hard trade against Option A.
- There’s a cold start. To cost nothing while idle, a free-tier branch’s compute auto-suspends after five minutes. Your first query after a suspend pays roughly half a second to wake it; every query after is fast again. This is scale-to-zero , and the next lesson covers its economics.
- One branch is shared. Point at the same dev branch as a teammate and your test data mingles. The fix, a branch per person, is the next lesson.
There’s nothing to configure, so its one connection-string line is all you’ll see in the comparison below.
Option C: Neon Local, a localhost bridge to a cloud branch
Section titled “Option C: Neon Local, a localhost bridge to a cloud branch”The third option is for when some code or a teammate’s setup really wants localhost and you won’t give up parity to get it. Docker gives you localhost but a database that drifts from prod; a Neon branch gives you parity but a cloud host. Neon Local gives you both.
It’s a Docker container, but it doesn’t run Postgres. It runs a proxy : a stand-in that exposes a normal postgres://localhost:5432 interface and forwards every query to a real Neon branch in the cloud. Your app sees localhost; your data lives on Neon, version- and extension-matched to production. The branch is ephemeral: the container cuts a fresh, isolated branch on start and deletes it on stop, so every docker compose up gives you a clean database with nothing to clean up by hand.
The one thing it can’t fix is the network. The branch is in the cloud and only the proxy is local, so Neon Local still needs connectivity, plus a Neon API key and project id.
The image is neondatabase/neon_local. It takes three environment values: NEON_API_KEY, NEON_PROJECT_ID, and PARENT_BRANCH_ID. That last one is what makes the branch ephemeral: giving it a parent to branch from, rather than a fixed branch to attach to, tells it to cut a fresh child each run. Your app connects with a localhost string, postgres://neon:npg@localhost:5432/neondb, exactly as if a plain Postgres sat there. The API key is a credential, so it lives in .env.local and never gets committed, the same rule as the connection string.
Like a plain Docker Postgres, Neon Local wants host port 5432, so pick one per project.
Neon Local is a strong, defensible default for a team that wants both localhost and parity and has a reliable network. It is not this course’s default: it costs an extra API key and a running container to buy a localhost string, and since DATABASE_URL abstracts the host away, the course never needs that string to be localhost.
The three options, side by side
Section titled “The three options, side by side”All three honor the same contract: only one variable changes, and what runs behind it.
services: db: image: postgres:18 ports: ['5432:5432']DATABASE_URL=postgresql://postgres:postgres@localhost:5432/invoicingOffline-capable; drifts from prod. The host is localhost and the database is yours, so the network never matters and neither does matching production.
DATABASE_URL=postgresql://neondb_owner:...@ep-cool-name-123.us-east-2.aws.neon.tech/neondbProd-parity; needs connectivity. Nothing to run: paste the string the console gives you. The ...neon.tech host is the same cloud database production uses.
services: db: image: neondatabase/neon_local ports: ['5432:5432'] environment: NEON_API_KEY: ${NEON_API_KEY} NEON_PROJECT_ID: ${NEON_PROJECT_ID} PARENT_BRANCH_ID: ${PARENT_BRANCH_ID}DATABASE_URL=postgres://neon:npg@localhost:5432/neondbLocalhost string and prod-parity; still needs connectivity. A localhost host like Docker, but the proxy routes to an ephemeral Neon branch, so the database behind it still matches prod.
The query, schema, and component never changed across those tabs. Only DATABASE_URL and whatever answers on the other end did. That is the payoff of the contract: three genuinely different setups, one unchanged application.
Making the decision: parity by default, offline by exception
Section titled “Making the decision: parity by default, offline by exception”The skill isn’t memorizing a recommendation; it’s the order you ask the questions in. Walk the decision yourself.
The only option that needs no network. Accept that it drifts from production — a different major version, missing extensions, different pooling — and lean on CI running against a Neon branch to catch the drift before it ships.
Prod-parity with the fewest moving parts: no container, no extra credential, just a connection string from the console. Tolerate the half-second cold start after idle, and branch per feature when you collaborate (next lesson).
A localhost string and prod-parity, via an ephemeral branch the proxy cuts on every start.
Costs a Neon API key and a running container, and, because the branch is in the cloud, still needs the network.
Offline comes first because it’s the one property only Docker has: need it, and the decision is made, with parity the cost you accept. Otherwise the second question, whether the host must be localhost, separates the two Neon options.
Here is where the course commits: the chapters that follow assume a Neon dev branch. For a web app on this stack, parity prevents more bugs than offline saves hours, since a “works on my machine” failure that surfaces in staging costs more than the rare afternoon without wifi. The default is safe to commit to, and a recommendation rather than a lock-in, because your app only ever reads DATABASE_URL: the same migrations, seed script, and Drizzle db client run unchanged against whichever option you pick. To browse tables and rows, you’ll use Drizzle Studio, introduced alongside migrations.
If you remember one thing, make it this: Postgres major-version drift between local and production is the single most common cause of the “works on my machine” bug. That is why the default optimizes for parity, and why this lesson pinned postgres:18 for both the Docker image and the Neon branch, so even the offline option drifts as little as it can.
Test the logic on a scenario rather than the recommendation.
A team ships PR-preview deployments on Neon and works mostly in-office on a reliable network. One engineer, on a policy that forbids putting development data on any cloud provider, can’t use Neon at all. What fits that engineer — without changing what the rest of the team runs?
When you’re ready to act on any of this, these are the pages worth opening.
External resources
Section titled “External resources”Run the localhost-to-cloud proxy container against an ephemeral Neon branch.
How idle compute suspends and wakes — the source of the dev cold start.
Neon's own breakdown of the connection string you paste into DATABASE_URL.
Where the version-18 data-directory change and volume path are documented.
The official reference for the docker-compose.yml file Option A is built on.