Skip to content
Chapter 36Lesson 2

Where your local database runs

Where your dev Postgres lives, weighing Docker, a Neon cloud branch, and the Neon Local proxy behind a single DATABASE_URL.

In the last lesson you decided the shape of your invoicing data: the tables, the columns, the keys. That shape needs somewhere to live. So you run pnpm dev, your app wakes up wanting to read and write invoices, and it reaches for Postgres. The question nobody hands you an answer to is where that Postgres is actually running.

There are two ways to get this wrong, and they are mirror images of each other. You can point at a cloud database, and then one day you are on a flight with no wifi, or the provider has a Friday outage, and you can’t write a single line of code against your data. Or you can run Postgres on your laptop, and then you ship a bug that passes every local check and only fails in staging, because your laptop’s Postgres isn’t the one production runs.

There are three credible answers in 2026. By the end of this lesson you’ll be able to lay all three out on a single axis and defend the one this course settles on. This isn’t a feature you build; it’s a decision you make once and then stop thinking about.

One environment variable is the whole interface

Section titled “One environment variable is the whole interface”

Before we look at a single option, you need the idea that makes all of them small.

Your app does not know where its database is, and it never asks. All it knows is one thing: a Postgres connection string it reads from an environment variable called DATABASE_URL. Hand it that string and it connects. It has no opinion about whether the string points at your laptop or at a server three time zones away.

That one fact is what holds the whole lesson together. Every option you’re about to meet, whether a container on your machine, a branch in the cloud, or a bridge between the two, is the same thing on the other end of the same string. Switching between them means editing one line in a file and restarting the dev server. No line of your application code changes: not the query, not the schema, not the component that renders the invoice. They can’t change, because they never knew where the database was in the first place.

So before the options diverge, let’s make sure you can read that string. Once you can, you’ll recognize which option produced a given DATABASE_URL just by looking at it.

postgresql:// scheme postgres:postgres user : password where? @localhost host :5432 port /invoicing database name

The host is the only segment that tells you where the database lives. @localhost means it’s on your machine, and a ...neon.tech host means it’s in the cloud. Everything else is the same regardless of which option you pick.

Read it left to right and it gives up everything a client needs. postgresql:// is the scheme, the protocol to speak. postgres:postgres is a username and password. @localhost is the host, the machine the database is on. :5432 is the port Postgres listens on, which is its default. And /invoicing is which database on that server to open. The host is the segment that matters for this lesson: @localhost says the database is on your own machine, and a host ending in ...neon.tech says it’s in the cloud. The string has the same shape either way; only the host changes the answer to where.

One guardrail before we move on, because it’s cheap to get right and expensive to get wrong. Your DATABASE_URL holds a password. It lives in .env.local, the local environment file you met in earlier chapters, and that file is never committed to git. A connection string in your repository history is a leaked credential. A couple of chapters from now, in the lesson on environment variables, you’ll see how this string gets validated at build time and kept off the client. For now the rule is simple: it goes in .env.local, and it stays out of version control.

The first option runs Postgres on your laptop inside Docker. To follow it, you need exactly four words of Docker vocabulary, enough to read a config file and no more. Docker is a large subject with its own course; this is the minimum to follow along.

Here are the four, in the order they build on each other:

  • An image is a read-only template, a frozen snapshot of a filesystem and the program to run. Here it’s the official postgres:18 image: a working Postgres, packaged up.
  • A container is a running instance of an image, an isolated process started from that frozen template. The image is the recipe and the container is the meal. Your running Postgres is a container.
  • A volume is persistent storage mounted into the container. A container is disposable: delete it and everything inside vanishes, including your tables. 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, where by itself it is unreachable. Mapping 5432:5432 exposes it on the host, so your app finds it at localhost:5432.

That’s the whole vocabulary: image, container, volume, port mapping. With those four in hand, the config file reads as plain English.

You describe the container you want 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.

The one thing this buys you is the 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 and none of that touches it.

The cost is the mirror image of that win, and it’s worth stating precisely, because “it’s less realistic” is too vague to act on. The Postgres in this container is a different piece of software than the Postgres production runs. It can lag a major version behind. It won’t have the extensions your cloud provider enables. It pools connections differently. Each of those gaps is a place a bug can hide: code that runs clean against your local container and then fails in CI or staging, where the Postgres is the real one. This is why an experienced engineer hesitates to make Docker the default. The worry isn’t snobbery; it’s this specific class of “works on my machine” bug, which comes from exactly these gaps.

Here’s the config. It’s 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 is the line that must match what production runs. Whether your local database stays in parity comes down to this number, which is the call the decision section ends on.

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 values postgres, postgres, and invoicing are the same three you saw 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 is the line that 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 is mounted at the parent path /var/lib/postgresql, not the /var/lib/postgresql/data that every pre-18 tutorial still shows, 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. The same image, one detail off, and the data is quietly gone. This is what version drift looks like in practice.

1 / 1

When your project eventually needs an extension, say pgvector for similarity search much later in the course, it’s a one-line change to use an image that ships with it. It’s named here only so you know it’s a small addition when the time comes, not something to set up now.

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 still there next time. Adding the -v flag, docker compose down -v, deletes the volume along with it. That single flag is the difference between pausing the database and wiping it, so reach for it 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.

The second option doesn’t run anything on your laptop at all. You create one development branch on a Neon project, on the free tier, which costs nothing while idle, and the Neon console hands you a connection string. You paste it into .env.local. That’s the entire setup: no compose file, no container, no docker command. The configuration is a copy-paste.

The host in that string ends in ...neon.tech, not localhost, because the database genuinely lives in Neon’s cloud. That is the point, because of what it buys you: prod-parity. Same Postgres major version as production. Same extensions available. Same connection pooler sitting in front. Same pricing behavior. This branch is the same kind of database your production will be, so code that runs against it runs against production. That means the entire class of “works on my machine” bugs Docker invites simply cannot form here: there’s no gap between local and production for a bug to hide in.

The costs are the inverse of Docker’s, and three are worth naming:

  • It needs connectivity. No network means no database, and the flight is lost. This is the hard trade against Option A.
  • There’s a cold start. To cost nothing while it sits idle, a free-tier branch’s compute auto-suspends after five minutes of no activity. That window is fixed on the free tier and not something you tune. Your first query after a suspend pays roughly half a second to wake the compute, and every query after that is fast again. This is scale-to-zero working as designed, not a glitch: an idle database that bills you nothing is the whole point, and the half-second wake is what you pay for it. The economics are the next lesson’s subject; here it’s only the source of the small pause you’ll occasionally feel.
  • One branch is shared. If you and a teammate point at the same dev branch, your test data mingles. The fix is a branch per person or per feature, which is available today and is the workflow covered in the next lesson. It’s worth knowing now, but you don’t need to reach for it yet.

There’s no code block to show, because there’s nothing to configure. The connection string the console gives you is the setup. You’ll see its one line in the comparison below.

Option C: Neon Local, the localhost-to-cloud bridge

Section titled “Option C: Neon Local, the localhost-to-cloud bridge”

The third option exists for people who don’t want to choose between A and B.

Look at what each of the first two forces on you. Docker gives you a localhost string but a database that drifts from prod. A Neon branch gives you a prod-identical database but a cloud host. Neon Local is for the case where some code, or some teammate’s setup, really wants localhost, but you don’t want to give up parity to get it.

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 on your machine and forwards every query to a real Neon branch in the cloud. Your app sees localhost. Your data lives on Neon, version-matched and extension-matched to production. And the branch it creates is ephemeral: the container spins up a fresh, isolated branch when it starts and deletes it when it stops. Every docker compose up gives you a clean database with no leftover state and nothing to clean up by hand.

So it gives you both things at once, which neither A nor B could: the localhost ergonomics and the prod-parity, in one container. The one thing it can’t fix is the network. The branch is in the cloud, and the proxy in front of it is the only local part, so Neon Local still needs connectivity, plus a Neon API key and project id to do its job. It buys you localhost and parity, but not offline.

The configuration stays light, since the goal is to recognize the shape, not memorize a setup. The image is neondatabase/neon_local. It takes three things in its environment: a NEON_API_KEY, a NEON_PROJECT_ID, and a PARENT_BRANCH_ID. That last one is what makes the branch ephemeral. By giving it a parent to branch from rather than a fixed branch to attach to, you tell it to cut a fresh child branch each run. Your app then connects with a localhost string, postgres://neon:npg@localhost:5432/neondb, exactly as if a plain Postgres were sitting there. The API key is a credential, so it lives in .env.local and never gets committed, the same rule as the connection string itself.

The same port note from Option A applies here from the other side: Neon Local wants host port 5432, just as a plain Docker Postgres does. Pick one per project.

Here’s the honest placement for Neon Local. It’s a strong choice for a team that wants both the localhost ergonomics and the parity and has a reliable network, and as a default that’s defensible. It is not this course’s default, for a plain reason: it costs an extra API key and a running container to buy you a localhost string, and since DATABASE_URL abstracts the host away anyway, the course never needs that string to be localhost. There’s no reason to pay for an ergonomic we don’t use.

All three options share one contract. Here they are next to each other, so you can see that the only things changing are the value of one variable and what’s running behind it.

docker-compose.yml
services:
db:
image: postgres:18
ports: ['5432:5432']
.env.local
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/invoicing

Offline-capable; drifts from prod. The host is localhost, the database is yours, and the network is irrelevant, as is whether it matches production.

Notice what didn’t change across those three tabs: nothing your app runs. The query is the same, the schema is the same, the component is the same. The only differences are the value of DATABASE_URL and the kind of thing answering on the other end of it. That’s the payoff of the contract we set up at the start: three genuinely different setups, one unchanged application.

Choosing: prod-parity is the default, offline is the exception

Section titled “Choosing: prod-parity is the default, offline is the exception”

Now that all three are on the table and you understand each, you can choose between them. The skill here isn’t memorizing a recommendation; it’s learning the order an experienced engineer asks the questions in. Walk the decision yourself.

Where should your local database run?

Notice that the offline question comes first, and that this ordering is deliberate. Offline-capability is the one property only Docker has, so an experienced engineer checks it before anything else. If you truly need it, the decision is already made and parity is a cost you accept. Only once offline is off the table does the second question, whether it has to be localhost, separate the two Neon options.

Here is where the course commits: the chapters that follow assume a Neon dev branch. The reasoning is short. For a SaaS on this stack, prod-parity prevents more bugs than offline prevents lost hours, because a “works on my machine” failure that surfaces in staging costs more than the rare afternoon without wifi. So the default optimizes for parity.

This is safe to commit to, because your app only ever reads DATABASE_URL, so a student who prefers Docker loses nothing. Every later lesson, every query, every migration, and every seed works against any of the three. The default is a recommendation, not a lock-in.

If you remember one thing to watch for from this lesson, make it this: Postgres major-version drift between your local database and production is the single most common cause of the “works on my machine” bug. That’s why the default optimizes for parity, and why this lesson pinned postgres:18 for both the Docker image and the Neon branch, so that even the offline option drifts as little as it can.

Before you move on, test the decision logic on a scenario rather than the recommendation itself.

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?

That engineer runs Docker Postgres; everyone else stays on their Neon branch.
The whole team moves to Neon Local so everyone shares one local setup.
The whole team moves to Docker Postgres to keep everyone consistent.
Everyone, including that engineer, shares one Neon dev branch.

What runs the database, runs the same migrations

Section titled “What runs the database, runs the same migrations”

It’s worth closing on why this decision was safe to make on its own, without it tangling everything downstream. The answer is the contract. Because the app only reads DATABASE_URL, the choice of A, B, or C is genuinely independent of everything that comes after it. Whichever one you run:

  • the same Drizzle Kit migrations apply, shaping the tables;
  • the same seed script populates your dev data;
  • the same Drizzle db client, the code that actually issues your queries, connects.

None of those care where the database is. They’re the next chapters’ work, named here only so you can see the seam they’ll plug into.

You’ll also want eyes on your data, a GUI to browse tables and rows. The course teaches Drizzle Studio as the in-stack default, which you’ll meet alongside migrations. Plenty of good standalone tools exist too, including TablePlus, pgAdmin, DataGrip, and Neon’s own web console, and you can adopt any of them later. They’re named here for recognition, not as instruction.

So here’s the whole lesson in one line, the thing to carry forward: the app reads DATABASE_URL, you decide what’s on the other end, and the course points it at a Neon branch.

When you’re ready to act on any of this, these are the pages worth opening.