on this page

At some point your app stops being a toy. It needs to remember things between page loads: users, posts, orders, who logged in. The moment that happens, an array in memory stops cutting it, because the second you restart your dev server, everything you saved is gone. Now you need a real database running on your laptop, and probably a cache next to it.

This is the part of “becoming a real developer” nobody warns you about. You went from writing functions to running services, little programs that sit in the background holding your data. The good news: it is far less painful than the internet makes it sound, as long as you do it the boring way.

Why a real database, not a fake one

You could fake it. Save things to a JSON file, stub out a pretend database, keep a dictionary in memory. People do this on day one, and it works right up until it doesn’t.

The problem is that production runs PostgreSQL (an open-source relational database, the kind that stores data in tables with rows and columns and lets you query it with SQL). If you develop against a JSON file and deploy to Postgres, you are testing one thing and shipping another. The bugs that live in the gap between them, a column that allows nulls in your fake but not in real life, a query that works on an array but chokes on SQL, all show up in production where you cannot see them. Develop against the same kind of database you deploy to. That is the whole rule.

Most apps also want Redis (an in-memory key-value store, pronounced “red-iss”). Redis keeps data in RAM instead of on disk, which makes it very fast and a little forgetful. You reach for it when you want to cache the result of an expensive query so you do not run it a thousand times, or to hold login sessions, or to throttle how often someone can hit an endpoint. It is not a replacement for Postgres. Postgres is your long-term memory; Redis is the sticky note on the monitor.

So a typical real app, locally, means two services running: Postgres for the data that must survive, Redis for the data that can vanish without anyone crying.

Two ways to get them, and why one wins

You have two real options for running these on your machine.

The first is installing them directly on your operating system. On a Mac that is Homebrew; on Linux it is your package manager; on Windows it is an installer and a system service. This works. It is also fiddly in ways that bite later. Each install registers a background system service that boots with your computer and quietly holds a port whether you are using it or not. You get exactly one version, so when one project wants Postgres 15 and another wants Postgres 17, you are uninstalling and reinstalling. And every teammate’s machine is set up slightly differently, which means “works on my machine” becomes a daily phrase.

The second is Docker Compose, and it is the one most teams pick. Docker runs each service in a container, an isolated little box with its own copy of the software, so nothing touches your actual operating system. Compose is the tool that reads one file describing all your services and brings them up together with a single command. If the words “image,” “container,” and “volume” are fuzzy, go read Docker Without the Buzzwords first, then come back. The short version: you describe what you want once, and Docker makes it appear.

The reason Compose wins for local databases is the teardown. When you are done, one command stops and deletes the whole thing cleanly, with no leftover service running on boot, no version pinned to your machine forever. The exact Postgres version lives in a file your whole team shares, so everyone runs the same thing. This is why so many repos have a docker-compose.yml in the root and a README that just says “run docker compose up.”

A compose file that runs both

Here is a small docker-compose.yml with Postgres and Redis. It is close to what a real project ships with.

# docker-compose.yml
services:
  db:
    image: postgres:17 # the exact Postgres version, pinned for everyone
    ports:
      - "5432:5432" # publish container port 5432 to localhost:5432
    environment:
      POSTGRES_USER: dev
      POSTGRES_PASSWORD: devpass
      POSTGRES_DB: myapp # creates a database called "myapp" on first boot
    volumes:
      - pgdata:/var/lib/postgresql/data # keep the data between restarts

  cache:
    image: redis:7
    ports:
      - "6379:6379" # publish Redis on localhost:6379

volumes:
  pgdata: # a named volume Docker manages for you

A few things worth understanding rather than copying blindly.

image: postgres:17 tells Docker which prebuilt image to pull. The :17 is the version tag, and pinning it matters: leave it off and you get latest, which silently changes under you and is exactly the kind of surprise you started using Compose to avoid.

The volumes block is what stops this from being a disaster. By default a container forgets everything when it stops, so without that line, every docker compose down wipes your database. A named volume (pgdata here) is a chunk of storage Docker keeps on your real disk and reconnects to the container each time it starts. Your data survives restarts. The volumes guide section in Docker Without the Buzzwords goes deeper on why this is the one detail beginners get wrong.

To bring it up:

docker compose up -d

up creates and starts the services. The -d flag means “detached,” so they run in the background and hand you your terminal back instead of filling it with logs. Now you have a Postgres listening on localhost:5432 and a Redis on localhost:6379, both reachable from the app you run normally on your machine.

Ports, or how your app finds the database

A port is a numbered door on a machine. Postgres listens on 5432 by convention, Redis on 6379. Memorizing those two numbers will save you a real amount of confusion, because half of all “it won’t connect” problems are a wrong port.

Inside Docker, those services are sealed off from your laptop unless you say otherwise. That is what ports: - "5432:5432" does: it publishes the container’s port to your host machine. The format is host:container. The left number is the door on your laptop; the right number is the door inside the container. So "5432:5432" means “anything hitting localhost:5432 on my machine gets forwarded into the database.” Your app runs on the host, the database runs in a container, and the published port is the tunnel between them.

If something else on your machine already grabbed 5432 (an old Postgres you installed directly, perhaps, see why we avoid that), you can move the host side: "5433:5432" puts it on localhost:5433 and your app connects there instead. The container does not care; only the left number changed.

Connection strings

Your app does not connect by clicking around. It needs one string that holds every detail about where the database is and how to log in. That is a connection string, and for Postgres it looks like this:

postgres://dev:devpass@localhost:5432/myapp

Cramped, but every piece means something:

PieceValue hereWhat it is
Protocolpostgres://Which kind of database to speak to
UserdevThe database username
PassworddevpassThat user’s password
HostlocalhostThe machine the database is on
Port5432The door to knock on
Database namemyappWhich database on that server

Notice every value matches the compose file above: same user, same password, same database name, same port. That is not a coincidence you can ignore. If your connection string and your compose file disagree on any one of those, the connection fails, and the error message is rarely kind enough to tell you which field is wrong.

This string does not belong in your code. It goes in an environment variable, almost always called DATABASE_URL, and lives in your .env file:

# .env
DATABASE_URL=postgres://dev:devpass@localhost:5432/myapp
REDIS_URL=redis://localhost:6379

If environment variables, .env files, and why secrets stay out of Git are new to you, read Environment variables explained. The reason a database URL lives in an env var rather than your source is the same reason any config does: the value changes between your laptop and production, and the production one is a real secret you must not commit.

Seeds: a database that isn’t depressingly empty

A fresh Postgres has the structure but no data. Empty tables, nothing to look at, every page of your app showing “no results.” You could add rows by hand every time, or you could write a seed script: a small program that inserts starter rows so a new database comes up with something in it.

// seed.js: insert a couple of starter rows
import { db } from "./db.js";

await db.user.create({
  data: { email: "[email protected]", name: "Ada" },
});
await db.post.create({
  data: { title: "Hello world", authorEmail: "[email protected]" },
});

console.log("Seeded.");

You run it once after creating the database, usually wired up as npm run seed. The win is that anyone who clones your repo gets the same starter data with one command, instead of a blank screen and a confused Slack message. Seeds are for development convenience and demos, not real records, so keep them obviously fake.

Migrations: changing the schema without hand-building it

Your schema is the shape of your database: which tables exist, what columns they have, what is required. It is going to change constantly as you build. Add a phone column, make email unique, create a comments table. The wrong way is to make those changes by hand in some database GUI, because then the shape of your database lives only in your head and your teammate’s database looks nothing like yours.

The right way is migrations: versioned, ordered files that describe each change to the schema. Each one is a step; run them in order and any empty database arrives at the current shape. They get checked into Git alongside your code, so the schema is reproducible instead of a thing you rebuild from memory.

In practice you almost never write the SQL yourself. Your ORM (Object-Relational Mapper, the library that lets your code talk to the database in objects instead of raw SQL, like Prisma

Heads up. You're leaving raindev.fyi

This link heads to www.prisma.io, an external site we don't control. Cool to keep going?

Continue
or Drizzle

Heads up. You're leaving raindev.fyi

This link heads to orm.drizzle.team, an external site we don't control. Cool to keep going?

Continue
) generates the migration from a change you describe, then applies it.

# Prisma: generate a migration from your schema and apply it
npx prisma migrate dev --name add_phone_to_user

That command looks at what changed, writes a new migration file, and runs it against your local database. The --name is just a label so the file is readable later. Commit the generated file, and now every machine and every environment can reach the same schema by running the same migrations. Nobody hand-builds tables.

Resets: nuking it back to clean

Local databases are disposable, and that is the point. Sometimes your data gets into a weird state, or a migration goes sideways, and the fastest fix is to throw the whole thing away and start over. That is a reset.

docker compose down -v   # stop the services AND delete their volumes
docker compose up -d     # bring fresh, empty services back

The dangerous flag is -v. Plain docker compose down stops the containers but leaves your named volume alone, so your data is still there next time. Adding -v deletes the volumes too, which means your database is genuinely gone, wiped, no undo. That is precisely what you want for a clean slate locally, and precisely what you must never run pointed at anything real. The reset and volume lifecycle is covered carefully in Docker Without the Buzzwords; the one-line takeaway is that -v is the difference between “stopped” and “destroyed.”

After a reset you re-run your migrations and your seed, usually bundled into one script so a full reset is a single command.

Local is not production, and that gap is where people get burned

Everything above describes your laptop. A container you can nuke, a password like devpass, data that exists only to test against. Throwing it away costs you nothing but the few seconds it takes to seed again.

Production is a different animal wearing the same clothes. There your database is a managed database, run for you by a host like Railway

Heads up. You're leaving raindev.fyi

This link heads to railway.app, an external site we don't control. Cool to keep going?

Continue
, Render

Heads up. You're leaving raindev.fyi

This link heads to render.com, an external site we don't control. Cool to keep going?

Continue
, or a dedicated managed Postgres provider. It has real credentials, automatic backups, and data belonging to actual people who will notice if it disappears. You do not run down -v on it. You do not casually run anything on it.

Here is the trap, and it is worth slowing down for. The connection string for production has the exact same shape as your local one:

# Local: disposable, lives in .env
DATABASE_URL=postgres://dev:devpass@localhost:5432/myapp

# Production: real data, lives in the host's dashboard
DATABASE_URL=postgres://app:••••••@db.railway.internal:5432/railway

Same postgres://, same fields, same everything except the values. The shape that makes connection strings convenient also makes them dangerous: the only thing standing between “reset my test data” and “delete the company’s database” is which URL your terminal happens to be holding. People run a destructive migration or a reset against production because their shell still had the production DATABASE_URL loaded from earlier, or because they copied a command from a runbook without checking which environment it pointed at. The command does not know it is a mistake. It just runs.

Classic traps

When a local database won’t cooperate, it is almost always one of these, and almost never something exotic.

BugSymptomFix
Wrong portECONNREFUSED or connection timeoutCheck the URL uses 5432 (Postgres) or 6379 (Redis), and that the port is published in compose
Wrong passwordpassword authentication failed for userMake the password in DATABASE_URL match POSTGRES_PASSWORD exactly
Database does not existdatabase "myapp" does not existMatch the name in the URL to POSTGRES_DB; recreate the volume if it was set after first boot
Stale dataOld rows you thought you deleted are still thereThe volume persisted; run down -v then up, re-migrate and re-seed
Nuked the wrong databaseProduction data is suddenly missingThere is no fix, only the backup; this is why you never load prod credentials in your dev shell

That last row has no good fix, which is the entire reason the warning above exists.

Bringing it back to shipping something real

A solid local data setup is what turns a project from a thing that resets every time you breathe on it into something that behaves like a real application. Postgres holds what matters, Redis takes the load off, a compose file makes all of it appear and disappear on command, and migrations plus seeds mean any machine can reach the same state from nothing.

That is also what makes a real deploy possible later. When your local schema is defined by migrations and your config lives in environment variables, shipping to a managed database is mostly pointing a different DATABASE_URL at the same code and running the same migrations there. The work you did to make your laptop sane is the work that lets your app run somewhere other people can reach it.

Just keep the two worlds separate in your head, and triple-check which database you are talking to before you run anything that ends in -v. The local one forgives you. The production one does not.