on this page

You spent twenty minutes seeding a local Postgres database with test users, a couple of orders, the exact messy data you needed to reproduce a bug. Then you ran docker compose down, tweaked a config line, ran up again, and every row was gone. Empty tables. Like you’d never touched it.

You didn’t break anything. Docker did exactly what it’s designed to do. The part nobody told you is that a container’s filesystem is disposable by default, and “the database” was sitting inside that disposable part the whole time.

This one builds on Docker without the buzzwords, so I’ll assume you already know that an image is the recipe and a container is the running thing. Here we’re answering the next question: where does the data actually go, and how do you keep it from vanishing every time you restart.

Why the data disappears

When you start a container, Docker stacks a thin writable layer on top of the read-only image. Every file the container creates or changes (rows your database writes, logs, uploaded images) lands in that writable layer.

Here’s the catch: that layer belongs to the container, not to you. When you remove the container, the layer goes with it. Not “recycle bin” gone. Gone gone.

This is on purpose, and it’s a feature. Containers are meant to be cattle, not pets: you should be able to kill one and spin up an identical replacement without a second thought. That’s the whole reason Docker scales and stays predictable. But a database is the one thing in your stack that genuinely needs to remember what happened yesterday, and the default behavior treats it like a scratch file.

So docker compose down removes the container. The writable layer dies with it. Your rows were in that layer. That’s the entire mystery.

The fix is to keep the data somewhere that is not inside the container.

The fix: a named volume

A volume is a storage area that Docker manages and keeps separate from any container. It lives on your host machine, Docker owns it, and crucially it outlives the containers that use it. You mount it into a container at a specific path, the container reads and writes there like a normal folder, and when the container is gone the volume and everything in it is still sitting there.

A named volume is just a volume you gave a name so you can refer to it later, like postgres_data. Postgres stores its actual database files at /var/lib/postgresql/data inside the container. If you mount a named volume at that exact path, every write goes into the volume instead of the disposable layer. Now you can destroy and recreate the container all day and the data stays put.

Here’s the bare docker run version so you can see the wiring with nothing else in the way:

# -v postgres_data:/var/lib/postgresql/data
#   left of the colon  = the named volume (Docker creates it if it doesn't exist)
#   right of the colon = the path INSIDE the container where Postgres keeps its data
# -e sets the password and a default database
# -p maps host port 5432 to the container's 5432 so you can connect from your laptop
docker run -d \
  --name my-postgres \
  -e POSTGRES_PASSWORD=devpassword \
  -e POSTGRES_DB=myapp \
  -p 5432:5432 \
  -v postgres_data:/var/lib/postgresql/data \
  postgres:16-alpine

The important line is the -v one. Without it, Postgres still runs and still works, it just writes everything into the throwaway layer, and you’re back to amnesia on the next down.

You can confirm the volume exists outside the container:

# List all volumes Docker is managing
docker volume ls

# Inspect one to see where it lives and what uses it
docker volume inspect postgres_data

Notice you never picked a folder on your own disk. Docker chose a spot it controls and handed you a name. That hands-off quality is exactly what makes named volumes the right tool for database data: you don’t want to be hand-managing the on-disk layout of Postgres files, you just want them to survive. If you ever want the full reference on volume drivers and options, the Docker volumes docs

Heads up. You're leaving raindev.fyi

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

Continue
cover it, but for normal development the name-and-mount pattern above is all you need.

Named volumes versus bind mounts

There’s a second way to get data out of the disposable layer, and beginners constantly mix the two up because they share the same -v flag and the same colon syntax.

A bind mount maps a specific folder on your host machine into the container. You point at a real path you can see in your file explorer, and the container reads and writes straight into it. It looks almost identical on the command line:

# Named volume: Docker manages the storage, you just name it
docker run -v postgres_data:/var/lib/postgresql/data postgres:16-alpine

# Bind mount: left side is a real path on YOUR machine
docker run -v $(pwd)/src:/app/src node:20-alpine

The difference is who owns the storage. With a named volume Docker owns it and tucks it away. With a bind mount you own it and it’s a folder you can open right now.

That makes bind mounts great for one specific job: live-editing source code in development. You mount your project’s src folder into the running container, and when you save a file on your laptop the container sees the change instantly, so your dev server hot-reloads. Your code stays where you can edit it, and the container just watches it.

Bind mounts are a poor fit for database files, though, and it’s worth knowing why before you get burned. Database engines are picky about file ownership, permissions, and locking, and a host folder (especially on Windows or macOS, where Docker runs inside a lightweight virtual machine) can hand the container subtly wrong permissions or slower I/O. You can hit corruption, or Postgres can simply refuse to start. Named volumes sidestep all of that because Docker sets them up the way the engine expects.

Quick rule of thumb: code you’re actively editing wants a bind mount, data you need to keep wants a named volume.

The same thing in Compose

Nobody types that giant docker run line twice. In real projects you write it once in a Compose file and forget it. Here’s a typical local setup: a Node and Express API talking to Postgres, with the volume wired in.

# docker-compose.yml
services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://postgres:devpassword@db:5432/myapp
    depends_on:
      - db

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: devpassword
      POSTGRES_DB: myapp
    volumes:
      # named_volume:/path/inside/container
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

# Volumes used above must be declared here too.
# This top-level block is what makes postgres_data a NAMED volume
# that Compose tracks and keeps around between runs.
volumes:
  postgres_data:

Two places mention postgres_data and both matter. The one under the db service says “mount this volume at the path Postgres writes to.” The top-level volumes: block declares it so Compose knows it’s a managed named volume rather than a typo. Leave out the top-level declaration and Compose complains; leave out the line under db and your data goes back to living in the disposable layer.

Start it the usual way:

# Build and start everything in the background
docker compose up -d

Now restart to your heart’s content. The container for db can be recreated a hundred times and postgres_data keeps the rows.

down versus down -v: the one that wipes your database

This is the command that catches everyone, so read this part twice.

Plain docker compose down stops and removes your containers and the network Compose created for them. It does not touch named volumes. Your data is safe.

docker compose down -v does all of that and deletes the named volumes declared in your Compose file. The -v means volumes. That’s the flag that takes your carefully seeded database and erases it.

# Removes containers + network. KEEPS your named volumes (data survives).
docker compose down

# Removes containers + network AND deletes named volumes (data is gone).
docker compose down -v

Both commands are legitimate. The problem is people copy down -v off a Stack Overflow answer, paste it into their own project out of habit, and only afterward realize “delete the volumes” meant “delete my actual development database.” There’s no undo and no confirmation prompt. It just does it.

Resetting a database on purpose

Sometimes a clean slate is exactly what you’re after: your migrations got tangled, your seed data is junk, and you want Postgres to come back fresh. That’s the good use of down -v.

# Wipe the database volume and rebuild from empty
docker compose down -v
docker compose up -d

That sequence destroys the volume, then up recreates it empty, and Postgres initializes a brand-new database on first boot. Deliberate, and fine, as long as you meant it.

If you’ve got several volumes and only want to nuke one, target it by name instead of taking everything down. The volume has to be unused first, so stop the container that holds it:

# Stop and remove containers (volume stays for now)
docker compose down

# Delete only the database volume by name
docker volume rm myapp_postgres_data

# Bring it back up; just this volume starts empty
docker compose up -d

One naming gotcha: Compose usually prefixes volume names with your project folder, so postgres_data shows up as something like myapp_postgres_data. Run docker volume ls to see the real name before you remove it, rather than guessing and deleting the wrong thing.

How to not nuke it by accident

A few habits keep the disasters away:

  • Don’t reflexively add -v. Make plain docker compose down your default. Reach for -v as a conscious decision, not muscle memory.
  • Name your volumes for what they hold. postgres_data tells you it matters. data or vol1 tells you nothing, and nameless volumes are the ones people delete without flinching.
  • Never point a dev command at a production database. This is the one that ends careers. Keep your production DATABASE_URL out of the env vars your local Compose file reads. A down -v aimed at the wrong connection is the bad kind of story.
  • Back up before destructive work. A throwaway dev database needs no ceremony, but if there’s anything you’d be sad to lose, dump it first.
# Dump a Postgres database to a file before doing anything destructive
docker exec my-postgres pg_dump -U postgres myapp > backup.sql

Common mistakes

MistakeWhat actually happensThe fix
No volume on the database at allData lives in the container’s writable layer and dies on every downMount a named volume at /var/lib/postgresql/data
Wrong mount pathVolume exists but Postgres writes elsewhere, so nothing persistsMatch the engine’s data path exactly (/var/lib/postgresql/data for Postgres)
Running down -v out of habitNamed volume deleted, dev database wipedDefault to plain down; add -v only on purpose
Bind-mounting Postgres dataPermission errors or corruption, especially on Windows and macOSUse a named volume for database files; save bind mounts for source code
Forgetting the top-level volumes: blockCompose errors out or treats it as an anonymous volume that’s hard to find laterDeclare every named volume at the bottom of the Compose file
Editing files inside the container directlyChanges vanish on recreate because they’re in the writable layerBind-mount the folder so edits live on your host

A quick map of your options

When you mount something with -v, you’re choosing one of three behaviors. Here’s the whole decision in one place:

TypeWhat persistsWho manages storageBest for
Named volumeSurvives container removal; stays until you delete the volumeDockerDatabase data (Postgres, Redis), anything you need to keep
Bind mountSurvives as long as the host folder exists; you can see and edit itYouLive-editing source code in dev, config files you tweak by hand
No volumeNothing; data dies with the containerNobodyTruly throwaway, stateless containers where losing everything is fine

If you remember only one row, make it the first. Database, named volume, done.

Tying it back to shipping

The reason any of this matters: you can’t build a real project on a database that forgets everything between restarts. Authentication you’re testing, the orders table you’re debugging, the seed data that reproduces a customer’s bug, all of it needs to survive a docker compose down so you can stop and resume work like a normal person.

Get the volume wired in once and the friction disappears. Your local Postgres holds its state across restarts, you reset it deliberately when you want a clean slate, and you stop losing an afternoon of setup to a stray flag. That’s the boring, dependable foundation real shipping is built on: when the data sticks around, you get to spend your time on the actual app instead of re-seeding the same fifteen rows for the fourth time today.