1. PostgreSQL and the container writable layer
If you are used to Spring Boot, you may have a comfortable illusion: “I launched the application — it works — so everything is durable.” With a database in a container, that illusion breaks very quickly. PostgreSQL is not just a service that responds to requests; it constantly writes data files. And the question “where does it write?” suddenly becomes just as important as “which port does it listen on?”
Every container has its own file system, and it has a part that is usually called the writable layer — the “writeable layer.” As long as the container is alive, it can write there. The problem is that the writable layer is tied to the container lifecycle. A container can be recreated, updated, replaced, accidentally removed — and along with it, what the database put there disappears. Docker documentation directly shows the typical scenario: a container with Postgres can be stopped and removed, and the data is preserved only if it is moved into a volume; otherwise you simply lose the entire database “disk.”
What makes it especially tricky is that in the short term everything looks “normal.” You run docker compose up, create a couple of CatalogItem objects through the API — they appear. Then you run docker compose down, then up again, and suddenly everything is empty. Not because Spring “forgot,” but because you destroyed the container with the database and, with it, the file state. In the official Compose material, this effect is described very directly: without persistence, after docker compose down and a subsequent up, data living in the writable layer disappears.
To make this less “magical,” let us fix a simple picture of where data can live.
| Where PostgreSQL data lives | How long it lives | What happens when the container is recreated |
|---|---|---|
| writable layer | While a specific container is alive | Almost certainly disappears |
| named volume | While the volume exists | The container can be recreated — data is preserved |
| bind mount | While the folder on the host exists | The container can be recreated — data is preserved |
And now the logical question: “Okay, so we need to persist it. How?”
2. Named volume as the baseline for a database
When a beginner developer first sees volumes: in Compose, they often think it is “just another YAML ritual.” In reality, a named volume is a pretty straightforward idea: Docker gives you a managed place on the host machine where the container can write data, and that place lives separately from the container. In other words, the container can be replaced, but the “disk” remains.
Why use a named volume instead of a bind mount, if both “preserve data”? Because databases have specific requirements. PostgreSQL stores data in the /var/lib/postgresql/data directory (in the official image), and there are many files, permissions, and internal formats there. A named volume is good because Docker manages the location itself and usually causes fewer cross-platform surprises. Unlike a bind mount, you are not tied to a specific path on disk, you do not argue with Windows/macOS about permissions, and you do not turn the course into “why is my folder not mounting?”
If we remember our previous experience with bind mounts (file export), the bind mount made sense there: we need to see files on the host, open the CSV, and make sure the export exists. For a database, you often need a different mode: “store it reliably, but I do not necessarily need to look at the files every day.”
Docker documentation explicitly demonstrates a named volume for Postgres: data is stored in the volume, the container can be removed, and then a new container can be created by connecting the same volume — and the table stays in place.
To make the difference stick, here is a neat comparison table without philosophy, just “what happens in real life”:
| Criterion | Named volume | Bind mount |
|---|---|---|
| Where data actually lives | In a Docker-managed area | In your folder on the host |
| Is a specific path required | No, only a name | Yes, a path is required |
| Convenience of “just works for everyone” | Usually higher | Often lower (permissions/paths/access) |
| Good for a database | Yes, this is the typical baseline | Sometimes yes, but often more hassle |
| Good for result files (exports) | Usually no | Yes |
In this lesson, we choose a named volume as the normal baseline for PostgreSQL.
3. Named volume in compose.yaml: two places
A very typical situation: someone added volumes: to the postgres service, Compose complained or silently did the wrong thing, and then the classic “well, it is YAML...” begins. Relax: in Compose, a named volume is almost always mentioned in two places, and that is normal.
The first place is in the specific service, where you say: “connect this volume to this place inside the container.”
services:
postgres:
image: postgres:18
volumes:
# named volume "postgres-data" is mounted into the standard Postgres data directory
- postgres-data:/var/lib/postgresql/data
The second place is at the top level volumes:, where you say: “here is the list of volumes that exist in this project at all.”
volumes:
# declare the named volume as a Compose project entity
postgres-data:
Why is that? Because in Compose, a volume is a separate project entity. It is not permanently “attached” to the service. A service can use a volume, but it does not own it like a personal flash drive. In Docker workshops, this is explained exactly that way: when migrating from docker run -v ... to Compose, you need to define the volume in the top-level volumes: and then mount it into the service.
Now the most important detail for PostgreSQL: the mount path inside the container must be correct. For the official Postgres image, the standard data directory is /var/lib/postgresql/data. If you mount the volume somewhere “for aesthetics,” for example /data, PostgreSQL will not write its real data files there, and you will get persistence “on paper.”
I will show two contrasting mini-examples — one intentionally incorrect, the other correct.
Incorrect (the volume exists, but it is mounted in the wrong place):
services:
postgres:
image: postgres:18
volumes:
# error: Postgres does not expect the data directory here, and the real data will remain in the writable layer
- postgres-data:/data
volumes:
# the volume is declared correctly, but mounted in the wrong place
postgres-data:
Correct (the volume goes where Postgres stores data):
services:
postgres:
image: postgres:18
volumes:
# correct: this is the standard data storage directory in the official Postgres image
- postgres-data:/var/lib/postgresql/data
volumes:
postgres-data:
And one more small nuance: postgres-data is the volume name, not the service name. Our service is postgres, the volume is postgres-data. These are two different entities, and confusing them is as funny as confusing “method name” and “variable name.” They are both words, but the compiler and Compose do not understand humor.
Integrating the volume into the app + postgres stack
Now let us assemble app + postgres into one complete fragment, so that app, postgres, env vars, and the volume read like one configuration, not scattered pieces. It is important to keep the course direction: the application service (app) remains the same image/build, and we add a separate postgres service and a named volume for its data.
Note: there are no ports for postgres here. For app -> postgres communication, publishing is not needed; it is added only if you need to access the database from the host machine through psql, DBeaver, or an IDE.
Below is a minimal “grown-up” fragment. It shows exactly what we need: the application in postgres mode, the database with parameters, and a volume that survives container recreation.
services:
app:
build: .
ports:
- "8080:8080"
environment:
# application profile: switch to the PostgreSQL configuration
SPRING_PROFILES_ACTIVE: postgres
# URL to the "postgres" service inside the docker network (service name = hostname)
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
postgres:
image: postgres:18
environment:
# these variables are used by the Postgres container for initialization on first start (bootstrap)
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
# the key point: Postgres data lives in a named volume and survives container recreation
- postgres-data:/var/lib/postgresql/data
volumes:
# named volume (exists separately from containers)
postgres-data:
Notice that there are two “triplets” of variables here, and that is normal. The POSTGRES_* triplet is needed by the database container itself, so it can initialize the cluster and create the user/database on first start. The SPRING_DATASOURCE_* triplet is needed by the application, so it knows how to connect. The fact that their values often match does not make them “the same parameters.” They simply live in different layers.
And one more fact that often surprises beginners and looks like “Docker is hiding something again”: the POSTGRES_* variables are mainly applied on the first start of an empty database. If the database already exists (and with a named volume that is almost always the case), then changing POSTGRES_PASSWORD in YAML does not necessarily “reset the password in the existing database.” In Docker documentation, this is visible in the practical scenario: when the container is started again with the same volume, the password variable is no longer needed for bootstrap, because the data already exists.
4. Restart vs recreation: the role of volume
Now comes an important idea that saves a lot of nerves. In everyday speech we often say “I restarted the container,” meaning anything from “stopped/started” to “deleted and brought it back up.” Docker distinguishes these situations, and the meaning of volumes rests on that difference.
If you simply restart the container without deleting it, the writable layer still exists — and the data may remain even without a volume. But this is a very fragile model, because in real life containers are recreated: Compose may recreate them during configuration updates, with up --build, when the image changes, and so on. In Docker examples for Compose, this is explained very clearly: docker compose down removes containers, which means everything in the writable layer disappears. That is exactly why persistence should be done properly, instead of hoping the container will “live forever.”
A named volume makes the behavior predictable: you can “kill” the PostgreSQL container and start a new one, and the data will remain, because it lives not in the container, but in the volume. Docker documentation demonstrates this with Postgres: data remains even if the container is removed, provided the same volume is connected again.
But persistence is only half the story. Data may honestly survive container recreation, and the application may still fail to reach the database because of the profile, address, or password. So right after setting up the volume, the next practical question appears: how do we quickly confirm that app and postgres really live together, and are not merely both in Running at the same time?
To think about this as engineering reality rather than faith, it helps to know which commands are usually used to “look at the life” of a volume, without fanaticism, just for orientation:
docker compose down # removes containers (writable layer will disappear)
docker compose up -d # creates containers again
docker compose ps # shows the state of services
docker volume ls | head -n 5 # shows volumes (names and drivers)
docker compose logs -n 10 postgres # check: Postgres really started without errors
The important part here is the mindset: you do not just “hope” data was preserved — you think in terms of objects. A container is a process. A volume is a separate place for data. Compose is a declaration that brings these objects up and connects them.
If you want to quickly verify that the database really “remembers the past,” the usual direct route is to go into the postgres container and run SELECT. In Docker docs for Postgres, data durability is checked exactly this way — through psql inside the container. The example may look roughly like this:
# quick test: the table already exists and contains data (which means the volume worked)
docker compose exec postgres psql -U catalog -d catalog -c "SELECT count(*) FROM catalog_item;"
# count
# -----
# 2
# (1 row)
# another test: read a few rows (check that the data did not get "reset" after recreation)
docker compose exec postgres psql -U catalog -d catalog -c "SELECT sku, title FROM catalog_item LIMIT 3;"
# sku | title
# --------+---------------
# SKU-001 | Demo item
# SKU-002 | Another item
# (2 rows)
Of course, the table names and data depend on what is already in your database. But the point of the check is very simple: if you see rows after recreating the container, then the data lives in the volume. If after recreating the container everything is empty, then either you did not connect the volume or you connected it to the wrong place.
5. Typical mistakes
The topic of volumes seems simple right up to the first time when “everything looks correct, but the data still disappears.” Usually the reason is not that “Docker broke,” but a small inaccuracy: the wrong place, the wrong YAML level, the wrong mental model.
Mistake #1: assuming that “the container was not removed, so persistence is configured.”
As long as you only do restart, it may seem like everything is fine even without a volume. But as soon as the container is recreated (and in Compose that is normal life), the writable layer disappears and the database becomes “like new.” A named volume is needed precisely so you do not depend on how “long” the container lives.
Mistake #2: confusing a named volume with a bind mount, and then being surprised by permissions and paths.
A bind mount requires a specific folder on the host, and on different operating systems this can behave differently. For a database in a training environment, a named volume is usually simpler and more stable: fewer chances to create a quest for yourself called “why can’t Postgres write to the folder.”
Mistake #3: adding volumes: to the postgres service, but forgetting the top-level volumes:.
In Compose, a volume is part of the project, so it must be declared. In documentation examples for Compose, this is emphasized: when moving from docker run -v ... to Compose, you need to define the volume in the top-level volumes: and then mount it into the service.
Mistake #4: mounting the volume somewhere other than /var/lib/postgresql/data.
PostgreSQL will not guess your intentions. It writes data where it expects to write it. If you mount the volume to another path, the actual data will remain in the container’s writable layer, and “why did the data disappear?” will no longer be a mystery, but a pattern.
Mistake #5: changing POSTGRES_PASSWORD and expecting it to be “reset” in an already created database.
The POSTGRES_* variables matter during the initial initialization of empty storage. If storage already exists in the volume, the database is not required to re-apply the “bootstrap settings.” This is clearly visible in practical scenarios: when a container is started again with the same volume, the initialization variables are no longer a magic button for recreating the database.
GO TO FULL VERSION