TL;DR: Docker containers are ephemeral — when they stop, everything written inside them is gone. Volumes are how you persist data. There are two kinds: named volumes (Docker manages the storage, survives restarts, use for databases) and bind mounts (maps a folder from your machine into the container, use for live code reloading). If your database is losing data on every restart, you're missing a named volume. One line in your docker-compose.yml fixes it.
Why AI Coders Need This
When you ask Claude to "add a PostgreSQL database to my app" or "containerize my whole stack," it generates a docker-compose.yml with a volumes: section. Sometimes that section is there and correct. Sometimes it's missing entirely. Sometimes it's there but the declaration at the bottom of the file is missing, so Docker silently ignores it.
AI generates volumes syntax constantly, and rarely explains what it does. The result: you copy-paste the config, everything seems to work, you add some data, restart your containers — and it's all gone. The database is empty. The uploads folder is empty. You think something is broken. Really, you just didn't have a volume.
Understanding volumes is one of those things that takes five minutes to learn and saves you hours of confusion. Once you get it, you'll immediately recognize whether the AI-generated config you're looking at will actually persist your data or silently delete it every time you restart.
This article assumes you know what Docker is and have some exposure to Docker Compose. If not, read those first — they're short.
The Horror Story: Data Loss on Restart
This happens to almost everyone who vibe codes their first real app. Here's the exact scenario:
You're building a project management app. Claude sets up the whole stack — a Node.js API, a PostgreSQL database — in a docker-compose.yml. You run docker compose up, everything starts, you create a few projects and tasks to test it out. Looks great.
The next day you open your laptop. The containers have stopped. You run docker compose up again. You open the app and hit the projects endpoint.
Empty. All your test data: gone.
You check the database directly. Zero rows. Like you never created anything.
You ask Claude what happened. It says something about "ephemeral containers" and suggests adding a volume. You add it, restart, create some data again. Restart again. Data survives this time. You have no idea why it works now when it didn't before.
Here's exactly why. The docker-compose.yml Claude originally generated probably looked like this:
# ❌ Missing volume — data disappears every time
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
ports:
- "5432:5432"
# No volumes: section. Data lives only inside the container.
PostgreSQL stores your data at /var/lib/postgresql/data inside the container. When the container stops, that entire path is gone. Docker throws it away. The next time you start the container, PostgreSQL initializes fresh — empty database, no tables, no rows.
The fix is one entry in the service and one declaration at the bottom of the file:
# ✅ Named volume — data survives restarts
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data # ← this line
volumes:
postgres_data: # ← and this declaration
Now the data lives in a Docker-managed volume on your machine, not inside the container. The container can stop, be deleted, be recreated — the volume stays. The data is safe.
What Volumes Actually Do
Here's the mental model: a Docker container has its own isolated filesystem, like a mini computer with its own hard drive. By default, everything inside that filesystem is temporary. When the container stops, Docker tosses the filesystem. Start a new container from the same image and you get a fresh filesystem every time.
A volume punches a hole in that isolation. It says: "this specific path inside the container should actually point somewhere outside the container — somewhere persistent." When the database writes to /var/lib/postgresql/data, it's not writing to the throwaway container filesystem. It's writing through the volume to persistent storage that outlives any individual container.
The filing cabinet analogy: The container is like a desk. When you clear the desk, everything on it disappears. A volume is like a filing cabinet that sits next to the desk. When you clear the desk, the filing cabinet is untouched. Start a new desk (container), point it at the same filing cabinet (volume), and all your files are right there.
This is why volumes are required for anything stateful. Databases, uploaded user files, generated certificates, cache data that's expensive to rebuild — anything you'd be upset to lose needs a volume. Everything else (your app code in production, the OS layer, installed packages) is fine being ephemeral because you can always rebuild it from the image.
The volume itself lives on your host machine — the computer running Docker. On Linux, Docker stores volumes at /var/lib/docker/volumes/. On Mac and Windows, they're inside Docker Desktop's virtual machine, which is why you can't just browse to them in Finder or File Explorer. But they're real files on real storage, persistent across any number of container restarts.
Named Volumes vs Bind Mounts
There are two kinds of volumes you'll encounter in AI-generated Docker configs. They look similar but serve completely different purposes.
Named Volumes
A named volume looks like this in a service definition:
volumes:
- postgres_data:/var/lib/postgresql/data
The format is volume-name:path-inside-container. The name on the left (postgres_data) is arbitrary — you pick it. The path on the right is where the application inside the container expects to read and write its data.
Docker manages where on your host machine this data actually lives. You don't control the host path. Docker picks it, handles permissions, and makes sure the same volume is attached every time a container with that name starts up.
Named volumes also require a top-level declaration at the bottom of your docker-compose.yml:
volumes:
postgres_data: # just the name, no path needed
If you have the volumes: entry in the service but forget this declaration, Docker Compose will throw an error. If you have the declaration but not the service entry, the volume exists but nothing uses it. You need both.
Use named volumes for: databases (PostgreSQL, MySQL, MongoDB), Redis data, uploaded user files, any data you'd be upset to lose on restart.
Bind Mounts
A bind mount looks like this:
volumes:
- ./src:/app/src
The format is host-path:container-path. The left side is a real path on your machine — relative paths start with ./. The right side is where that path should appear inside the container.
Unlike named volumes, you control exactly what's being mapped. Changes you make to files on your machine appear instantly inside the container — no rebuild required. This is the live reload pattern: you edit ./src/routes.js on your laptop, the container immediately sees the new version at /app/src/routes.js.
# Typical development setup with bind mount
services:
app:
build: .
ports:
- "3000:3000"
volumes:
- .:/app # sync everything from project root into /app
- /app/node_modules # but don't overwrite node_modules inside container
That second line — /app/node_modules — is a common pattern that confuses people. It's called an anonymous volume, and it tells Docker: "don't sync node_modules from my machine — use what's already inside the container from the build step." Without it, your local node_modules (built for Mac or Windows) would overwrite the container's node_modules (built for Linux), causing cryptic binary compatibility errors.
Use bind mounts for: syncing your source code during development so you don't have to rebuild the image every time you change a file. Not for production — in production, your code is baked into the image.
The Key Difference at a Glance
# Named volume — Docker manages storage, data persists
volumes:
- postgres_data:/var/lib/postgresql/data
# Bind mount — you control the host path, for live sync
volumes:
- ./src:/app/src
# Anonymous volume — no name, no host path, container-only
volumes:
- /app/node_modules
Most docker-compose.yml files for a real app use all three types. The database gets a named volume. The app code gets a bind mount for development. Node modules (or Python packages, or compiled artifacts) get anonymous volumes to stay isolated from the host.
Reading Docker Compose Volume Syntax
When AI generates a docker-compose.yml, you'll see volumes in two places: inside service definitions and at the top level of the file. Here's how to read both.
Inside a Service Definition
services:
db:
image: postgres:16-alpine
volumes:
- postgres_data:/var/lib/postgresql/data
# ^^^^^^^^^^^^^^^^^^ named volume (Docker manages storage)
# ^^^^^^^^^^^^^^^^^^^^^^^^^ path inside container
app:
build: .
volumes:
- .:/app
# ^ host path (dot = current directory)
# ^^^^ container path
- /app/node_modules
# ^^^^^^^^^^^^^^^^^ anonymous volume (no host path)
The rule for telling them apart: if the left side before the colon is a path (starts with /, ./, or ~/), it's a bind mount. If it looks like a name without slashes, it's a named volume. If there's no colon at all, it's an anonymous volume.
At the Top Level of the File
volumes:
postgres_data: # simplest form — Docker uses default settings
redis_data: # another named volume
uploads:
driver: local # explicit driver — same as default, just verbose
external_volume:
external: true # volume already exists, don't create it
The top-level volumes: block is the declaration. Every named volume used by any service must be declared here. Most of the time the declaration is just the name followed by a colon and nothing else. The external: true option is worth knowing — it tells Docker Compose that this volume was created separately (maybe by another Compose project or a management tool like Coolify) and should not be created or deleted by this file.
Full Example: Dev vs Production
Here's how volumes differ between a development setup and a production setup for the same app:
# Development docker-compose.yml
services:
app:
build: .
ports:
- "3000:3000"
volumes:
- .:/app # ← bind mount for live code sync
- /app/node_modules # ← keep container's node_modules
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
volumes:
- postgres_data:/var/lib/postgresql/data # ← persist DB
volumes:
postgres_data:
# Production docker-compose.yml
services:
app:
image: myapp:latest # code is baked into the image — no bind mount
ports:
- "3000:3000"
environment:
NODE_ENV: production
DATABASE_URL: ${DATABASE_URL}
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data # ← same as dev
volumes:
postgres_data:
In production, the bind mount is gone — your code is already inside the image and doesn't need to sync from your laptop. The named volume for the database is identical. The rule holds: named volumes for data you persist, bind mounts only in development for live reload.
What AI Gets Wrong About Volumes
AI generates volumes syntax correctly most of the time, but there are four specific mistakes it makes regularly. Each one leads to data loss or broken development setups.
1. Missing the Volume Declaration
The most common mistake. AI adds the volume to the service but forgets the top-level declaration:
# ❌ Named volume without declaration — Docker Compose will error
services:
db:
image: postgres:16-alpine
volumes:
- postgres_data:/var/lib/postgresql/data
# No volumes: block at the bottom
# ✅ Both parts present
services:
db:
image: postgres:16-alpine
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
If you see service "db" refers to undefined volume postgres_data, this is why. Add the declaration.
2. No Volume for the Database at All
As shown in the horror story: AI generates a working Postgres service but omits volumes entirely. The database runs fine. Data gets created. Restart happens. Everything is gone.
Every time you see a database service in a docker-compose.yml, check for a volume. If it's not there:
# Add to the db service:
volumes:
- postgres_data:/var/lib/postgresql/data # PostgreSQL
# or for MySQL:
- mysql_data:/var/lib/mysql
# or for MongoDB:
- mongo_data:/data/db
# Add to the bottom of the file:
volumes:
postgres_data:
3. Wrong Container Path
AI occasionally gets the container-side path wrong. The path after the colon must match where the application actually stores its data. If it's wrong, the volume is mounted but the database writes to a different path inside the container — and that path is still ephemeral.
# ❌ Wrong path — PostgreSQL doesn't store data here
volumes:
- postgres_data:/var/lib/postgres/data # wrong directory name
# ✅ Correct path for each database
# PostgreSQL:
- postgres_data:/var/lib/postgresql/data
# MySQL / MariaDB:
- mysql_data:/var/lib/mysql
# MongoDB:
- mongo_data:/data/db
# Redis (if persisting):
- redis_data:/data
4. Using a Bind Mount for Production Code
AI trained on development examples sometimes includes a bind mount in production configs. In production, your app code should be baked into the image — not synced from your laptop. A bind mount in production means the container depends on a specific directory existing on the server, which breaks when you deploy to a new machine or use a container platform.
# ❌ Bind mount in production — wrong and fragile
services:
app:
image: myapp:latest
volumes:
- .:/app # ← this shouldn't be here in production
# ✅ No bind mount — code is in the image
services:
app:
image: myapp:latest
# no volumes needed unless the app generates files that need to persist
How to Debug Volume Issues
When something seems off with your data — it's not persisting, or you're seeing stale data when you expect fresh — here's the debugging sequence.
Step 1: Check What Volumes Exist
See all volumes Docker knows about on your machine:
docker volume ls
You'll see a list of volume names. Named volumes from Docker Compose are usually prefixed with the project name (the folder your docker-compose.yml is in). If you're in a folder called myapp with a volume named postgres_data, the volume will show up as myapp_postgres_data.
If you don't see your volume listed, it was never created — either the declaration is missing or docker compose up hasn't been run yet.
Step 2: Inspect a Volume
See where Docker is storing a volume and how big it is:
docker volume inspect myapp_postgres_data
This shows the Mountpoint — the actual path on the host filesystem. On Linux you can navigate there directly. On Mac and Windows, Docker Desktop runs in a VM so the path points inside the VM, not your actual filesystem. That's normal.
Step 3: Look Inside the Container
The most direct way to see if your data is actually there:
# Shell into the database container
docker compose exec db sh
# Then navigate to the data directory
ls /var/lib/postgresql/data
# For PostgreSQL specifically, connect and check
docker compose exec db psql -U postgres -c "\l" # list databases
docker compose exec db psql -U postgres -d myapp -c "\dt" # list tables
If the data directory is empty or the tables aren't there, the volume either isn't configured or was deleted.
Step 4: Check If You Ran down -v
This is the most brutal cause of data loss. The -v flag on docker compose down deletes all volumes:
docker compose down # ✅ stops containers, keeps volumes
docker compose down -v # ⚠️ stops containers AND deletes all volumes — data gone
Warning: docker compose down -v is not reversible. Once a volume is deleted, the data in it is gone. There's no undo. Only run this when you genuinely want to wipe the database and start fresh — like resetting a dev environment to a known state. Never run it in production unless you have a backup.
If you ran down -v by accident, the data is gone. Going forward, build a habit: before running docker compose down, ask yourself if you meant to keep the -v off.
Step 5: Recreate vs Restart
There's a difference between restarting a container and recreating it. Named volumes survive both. But it's worth knowing:
docker compose restart db # restarts the container, volume stays
docker compose up --force-recreate db # destroys and recreates container, volume stays
docker compose down && docker compose up # same: containers gone, volumes stay
docker compose down -v && docker compose up # ⚠️ volumes gone too
Step 6: Check Volume Permissions
Sometimes the volume mounts correctly but the container can't write to it because the volume was created with the wrong ownership. You'll see errors like permission denied or could not create directory in the container logs.
docker compose logs db | grep -i "error\|permission\|denied"
For PostgreSQL specifically, the container runs as the postgres user (UID 999). If you're on Linux and the volume directory has different ownership, you'll need to chown it. On Mac and Windows this is rarely a problem because Docker Desktop handles permissions through the VM layer.
When you need to inspect logs or volume state and want a proper CLI for it, the Docker CLI gives you the full toolset beyond what Docker Compose exposes.
What to Learn Next
Volumes make more sense once you understand the containers they're attached to. These articles fill in the surrounding context:
Frequently Asked Questions
Because Docker containers are ephemeral — everything written inside a container's filesystem disappears when the container stops. To persist database data across restarts, you need a named volume. In your docker-compose.yml, add a volumes entry to the database service pointing to where the database stores its files (e.g., /var/lib/postgresql/data for PostgreSQL), and declare the volume name at the bottom of the file under a top-level volumes: section.
A named volume (e.g., postgres_data:/var/lib/postgresql/data) is managed by Docker. Docker picks the location on your machine, handles permissions, and the data survives container restarts. Use named volumes for databases and anything you want to persist but not edit directly. A bind mount (e.g., ./src:/app/src) maps a specific folder from your machine into the container. Changes you make on your machine instantly appear inside the container. Use bind mounts for live code reloading during development.
The -v flag tells Docker Compose to delete named volumes when it removes the containers. Without -v, your database data survives docker compose down. With -v, the volumes are deleted and the data is gone permanently — there is no undo. Only use docker compose down -v when you intentionally want a fresh start with an empty database, like when resetting a dev environment.
The easiest way is to exec into the running container and browse the filesystem: docker compose exec db sh to get a shell, then navigate to the mounted path (e.g., /var/lib/postgresql/data). You can also run docker volume inspect [volume-name] to see where on your host machine Docker is storing the volume data. On Mac and Windows, Docker Desktop stores volumes inside a virtual machine, so the host path isn't directly accessible from Finder or Explorer.
You need volumes in both, but they look different. In production, named volumes persist your database data on the server. Platforms like Coolify handle volume management automatically when you deploy a database service. In development, you use both: named volumes for the database (same as production) and bind mounts to sync your code into the container so changes reload instantly without rebuilding the image.