You change one line of the compose.yaml, you run docker compose up -d again, and Compose replaces the database container. Does the database data survive? Yes, if it lives in a named volume declared by the stack. No, if you then run docker compose down -v, which deletes that volume.
This lesson is for a beginner who has already written a Compose stack. You build a small notes database, you recreate it in three different ways and each time you look at what happens to the data. You also see why an init script does not run again on a volume that is already filled, how to replay a migration without losing anything, and how to refuse a write with a read-only mount.
What you will learn
- Predict what
stop,up -d --force-recreate,downanddown -vdo to the containers, the network and the volume of a project. - Explain why the init scripts of the
postgresimage run only on an empty volume. - Read who owns the data of a volume and prove that another identity is refused.
- Replay a migration written to be repeatable, without losing a single row.
- Mount a folder read-only with
:roand see the write refused. - Recognize the anonymous volume created by a wrong mount path, and clean up the stack.
Prerequisites
You need a Linux host with Docker Engine 29.9.2 and its Compose 5.6.1 plugin, installed from the official repository as shown in the lesson on installing Docker. The images are pinned by their digest.
You wrote a first stack with the lesson Describe an application with Compose, which explains the project name and the difference between down and down -v. This lesson comes after the ones on interpolation and environment files and on organizing the networks of a stack, but it reuses nothing from them.
This page assumes you know what two lessons of the "Keep your data" module teach. The lesson on Docker volumes explains what a named volume, a bind mount and an anonymous volume are. The lesson on volume permissions explains how a file is owned by a UID and a GID. Here, you see what Compose adds to them.
You work in a ~/carnet folder, which the lesson has you create. The folder name becomes the project name: the containers are called carnet-... and the volume carnet_....
What happens to the data when Compose recreates a service?
The data of a named volume survives any container recreation, and even docker compose down. Among the Compose commands, only docker compose down -v destroys it. Compose replaces a container whenever it needs to, but it deletes a volume only if you ask for it with -v.
The table gives, for four commands, what stays and what disappears among the three objects of a project: the container, the network, and the volume with its data. Read it line by line. Only one cell makes you lose data: the last one.
| Command | Container | Network | Volume and data |
|---|---|---|---|
docker compose stop | stopped | kept | kept |
docker compose up -d --force-recreate | removed then recreated | kept | kept, reused |
docker compose down | removed | removed | kept |
docker compose down -v | removed | removed | removed, data lost |
Between down and down -v there is only one letter, and the second one erases your data. Type -v only to start from scratch, never to "clean up" a stack that is in use. The next sections check this table on a real database.
How do you declare the stack with its volume and its init script?
Declare the volume in the top-level volumes: block, mount it on the folder where the image stores its data, and mount the init script read-only. The file below describes a notes database, with a migrations folder that you fill in further down.
First create the folder with its two subfolders.
mkdir -p ~/carnet/init ~/carnet/migrations && cd ~/carnetOn first start, the postgres image runs the .sql files it finds in /docker-entrypoint-initdb.d. This file creates the table and writes a note into it.
-- ~/carnet/init/01-schema.sqlCREATE TABLE notes ( id serial PRIMARY KEY, texte text NOT NULL);INSERT INTO notes (texte) VALUES ('première note');Here is the compose.yaml.
services: base: image: postgres:18.6-alpine3.24@sha256:10a5c69a83035ab5239f5620d708520e8b28f5aa1f2b20143e6024532edadfb5 environment: POSTGRES_USER: carnet POSTGRES_PASSWORD: demo-seulement POSTGRES_DB: carnet volumes: - donnees-base:/var/lib/postgresql - ./init:/docker-entrypoint-initdb.d:ro - ./migrations:/migrations:ro
volumes: donnees-base:Read the base service line by line. The donnees-base:/var/lib/postgresql mount sends the database files into the named volume donnees-base, which the bottom block declares: the data lives in the volume, not in the container. The ./init:/docker-entrypoint-initdb.d:ro mount exposes your init folder to the container, and the final :ro forbids it from modifying it. The ./migrations:/migrations:ro mount does the same for the migrations, which you will run from the database container.
Start the stack and read the contents of the table.
docker compose up -ddocker compose exec base psql -U carnet -d carnet -tAc 'SELECT id, texte FROM notes ORDER BY id'On first start, the database is not ready: the first attempts fail with the message in the first block below. Run the read again until you see the note, as in the second block.
psql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: No such file or directory Is the server running locally and accepting connections on that socket?1|première noteThe database takes a few seconds to answer, because the image first starts a temporary server to run your scripts, stops it, then starts the real server. In the meantime, psql is refused. The message varies with the moment (No such file or directory, the database system is starting up or the database system is shutting down): just run the command again. The log confirms the sequence: docker compose logs base contains PostgreSQL init process complete; ready for start up., then database system is ready to accept connections for the real server.
Compose also created the volume carnet_donnees-base, that is, the project name followed by the key in the file. docker volume ls lists it.
Why does the init script run only once?
Because the postgres image runs the files in /docker-entrypoint-initdb.d only if the data folder is empty. On a volume that is already filled, the container starts on the existing database and ignores the script. The container is new, but the volume is not.
Predict what will happen. You add two notes by hand, you modify the script so that it writes one more note, then you recreate the database container. Will the table contain the note from the modified script?
docker compose exec base psql -U carnet -d carnet -c "INSERT INTO notes (texte) VALUES ('note ajoutée à la main'), ('seconde note à la main')"echo "INSERT INTO notes (texte) VALUES ('note du script modifié');" >> init/01-schema.sqldocker compose up -d --force-recreate basedocker compose exec base psql -U carnet -d carnet -tAc 'SELECT id, texte FROM notes ORDER BY id'INSERT 0 21|première note2|note ajoutée à la main3|seconde note à la mainThe table contains exactly the three notes from before, and the note from the modified script is missing. The container was indeed replaced, but the volume kept the database, and the image skipped the initialization. If psql refuses the connection right after the recreation, run the query again. The logs confirm the skip.
docker compose logs base | grep -i "skipping initialization"base-1 | PostgreSQL Database directory appears to contain a database; Skipping initializationThis line is the proof to look for when a modified script "does nothing". It says that the image found a database and did not touch it.
To see the modified script run, you must start from an empty volume. Here only, down -v is the right command, since you accept losing these demonstration notes. The volume starts empty: run the query again as long as psql refuses the connection, as on first start.
docker compose down -vdocker compose up -ddocker compose exec base psql -U carnet -d carnet -tAc 'SELECT id, texte FROM notes ORDER BY id'1|première note2|note du script modifiéThe two notes added by hand are gone, and the note from the modified script is there: the volume was new, so the image replayed the whole script. On a database that is in use, this route is closed. To change its schema without emptying it, you need a migration, which the section on migrations writes.
Insert a note by hand again, to have data to protect. Then check the down line of the table: a docker compose down followed by a docker compose up -d must keep this note.
docker compose exec base psql -U carnet -d carnet -c "INSERT INTO notes (texte) VALUES ('note à protéger')"docker compose downdocker compose up -ddocker compose exec base psql -U carnet -d carnet -tAc 'SELECT id, texte FROM notes ORDER BY id'INSERT 0 11|première note2|note du script modifié3|note à protégerThe down removed the container and the network, and the up recreated them. The volume did not move: the note added by hand is still there, and the other two with it.
Who owns the data, and who is refused?
The database files belong to the identity under which PostgreSQL runs, and they are closed to everyone else. This identity is a UID/GID pair, which you read with id in the container, then on the volume itself. As long as the recreated container keeps the same identity, it gets its permissions back.
First read the identity of PostgreSQL, then the owner and the mode of the data folder in the volume.
docker compose exec base id postgresdocker compose exec base stat -c '%u:%g %a %n' /var/lib/postgresql/18/dockeruid=70(postgres) gid=70(postgres) groups=70(postgres),70(postgres)70:0 700 /var/lib/postgresql/18/dockerThe PostgreSQL 18.6 image stores the data in a version subfolder, /var/lib/postgresql/18/docker, under the folder you mounted. This folder belongs to UID 70, the one of the postgres account, and its 700 mode lets in only its owner. Its group is 0 (root) and has no effect, since the mode gives nothing to the group. The volume therefore carries numbers, not an account name. The lesson on volume permissions details this point.
Now prove the refusal. Run a throwaway container under another identity, which mounts the same volume and tries to read the data folder. The volume name is the one Compose created: carnet_donnees-base.
docker run --rm --user 1234:1234 -v carnet_donnees-base:/v alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a ls /v/18/dockerls: can't open '/v/18/docker': Permission deniedThe refusal comes from the file permissions, not from the mount: the volume is mounted read-write, and the 1234:1234 identity has no right on a folder in 700 mode. This is the refusal you want to keep after a recreation. Check it: recreate the database, then read the owner again.
docker compose up -d --force-recreate basedocker compose exec base stat -c '%u:%g %a' /var/lib/postgresql/18/docker70:0 700The owner and the mode are identical to those before the recreation. The new container did not receive more permissions, and nobody had to give it any.
In a stack, the user: "UID:GID" key sets the identity of a service. If you change this identity without changing the owner of the volume, the service is refused on its own data. Fixing it with chmod 777 or by running the service as root widens the permissions instead of settling them: the lesson on volume permissions explains why these two answers hold up poorly, and how to align a group instead.
How do you replay a migration without losing data?
Write the migration so that it is repeatable, that is, so that it does nothing if its work is already done, then run it with psql from the database container. That way, replaying it after each deployment costs no data. A non-repeatable migration fails on the second run or, if it recreates the table, erases the rows.
The file adds an auteur column to the table. The IF NOT EXISTS keyword makes the addition repeatable.
-- ~/carnet/migrations/02-ajout-auteur.sqlALTER TABLE notes ADD COLUMN IF NOT EXISTS auteur text NOT NULL DEFAULT 'inconnu';The migrations folder is mounted in the container under /migrations. Run the file with the -f option of psql, twice in a row, then read the table again. The -v ON_ERROR_STOP=1 option stops psql at the first real error.
docker compose exec base psql -U carnet -d carnet -v ON_ERROR_STOP=1 -f /migrations/02-ajout-auteur.sqldocker compose exec base psql -U carnet -d carnet -v ON_ERROR_STOP=1 -f /migrations/02-ajout-auteur.sqldocker compose exec base psql -U carnet -d carnet -tAc 'SELECT id, texte, auteur FROM notes ORDER BY id'ALTER TABLEpsql:/migrations/02-ajout-auteur.sql:1: NOTICE: column "auteur" of relation "notes" already exists, skippingALTER TABLE1|première note|inconnu2|note du script modifié|inconnu3|note à protéger|inconnuRead the first two lines. On the first run, ALTER TABLE adds the column. On the second, PostgreSQL reports with a NOTICE that the column already exists and moves on, without an error. The three notes are still there, each with the default value of the new column.
The migrations folder is mounted :ro for the same reason as the init script: the container reads the file, and it has no reason to change it.
How do you forbid writing with a read-only mount?
Add :ro at the end of the volume in the short form. The container then reads the mounted content and is refused any write, whatever its identity. The lesson on Docker volumes showed this option with docker run; Compose takes it over as is.
Try to write into the database init folder, which your file mounted :ro.
docker compose exec base sh -c 'echo "DROP TABLE notes;" >> /docker-entrypoint-initdb.d/01-schema.sql'sh: can't create /docker-entrypoint-initdb.d/01-schema.sql: Read-only file systemThe Read-only file system message shows that the mount itself refuses the write. docker compose exec opens a root session here: docker compose exec base id prints uid=0(root) gid=0(root). The refusal therefore does not come from a lack of permissions, it comes from the mount, which root does not bypass.
Check the same thing by inspection, which reads what Docker actually mounted. The RW field is false for a read-only mount.
docker inspect -f '{{range .Mounts}}{{.Destination}} rw={{.RW}}{{"\n"}}{{end}}' carnet-base-1/docker-entrypoint-initdb.d rw=false/migrations rw=false/var/lib/postgresql rw=trueThe data volume is read-write, since the database must write to it. The two file folders are read-only. This is the setting to aim for: each mount gets only the access the service needs.
What should you do when the data is not kept as expected?
Start from what you observe, then find the mount or the command responsible. The table links the cases seen in this lesson to their cause.
| What you observe | Probable cause | What you do |
|---|---|---|
| The modified init script changes nothing | The volume already contains a database, the image skips the initialization | Look for Skipping initialization in docker compose logs base. To change the schema, write a migration. To start from scratch, docker compose down -v, which erases the data |
The data disappeared after down then up | The data folder is not on the named volume, or a down -v went through | Read docker inspect -f '{{range .Mounts}}{{.Destination}} {{.Name}}{{"\n"}}{{end}}' carnet-base-1: the named volume must be mounted on /var/lib/postgresql |
The database does not start, the log begins with Error: in 18+, these Docker images are configured to store database data, and a volume with a 64-character hexadecimal name appears in docker volume ls | Your named volume is mounted on /var/lib/postgresql/data, the old path from before PostgreSQL 18: Docker then creates an anonymous volume on /var/lib/postgresql, and the image refuses to start | Mount the named volume on /var/lib/postgresql and run docker compose up -d again. The anonymous volume already created stays: after docker compose down -v, spot it with docker volume ls -f dangling=true and remove it with docker volume rm |
Permission denied when the service accesses its volume | The identity of the service no longer matches the owner of the files | Compare id in the container and stat -c '%u:%g' on the folder. Align the identity, without chmod 777 |
Read-only file system when writing | The path is mounted :ro | Check the RW field with docker inspect. If the write is intended, remove :ro; otherwise, write to another folder |
psql answers No such file or directory, starting up or shutting down right after a start | The database is not ready yet: on an empty volume, the image first starts a temporary server for your scripts, then the real server | Run the command again after a few seconds, or read docker compose logs base until the last line database system is ready to accept connections |
When in doubt, come back to two commands: docker compose config, which shows the mounts as Compose applies them, and docker volume ls --filter label=com.docker.compose.project=carnet, which lists the volumes of the project.
How do you clean everything up after the demonstration?
Delete the stack with its volumes, check that no volume of the project lingers, then remove the images and the folder. The demonstration leaves one container, one network, one named volume, two images and the ~/carnet folder.
The down -v command removes the named volume of the project. It leaves in place an anonymous volume that the stack created then abandoned, for example after a mount on the wrong path fixed by recreating the container: no container uses it anymore, but Docker keeps it. If you did not make this mistake, the volume list is empty. Otherwise, it shows a 64-character hexadecimal name, which docker volume ls -f dangling=true isolates and docker volume rm removes. Avoid docker volume prune on a machine that hosts work other than yours.
cd ~/carnetdocker compose down -vdocker volume lsDRIVER VOLUME NAMEIf an anonymous volume appears, remove it by its name.
docker volume ls -f dangling=true -qdocker volume rm <nom-du-volume>Then remove the two images and the folder.
docker rmi postgres:18.6-alpine3.24@sha256:10a5c69a83035ab5239f5620d708520e8b28f5aa1f2b20143e6024532edadfb5 \ alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9acd ~ && rm -r carnetAfter that, docker ps -a, docker volume ls and docker network ls show nothing left of the lesson.
Practice
The lab gives a prepared image and no running stack. You write the compose.yaml of a single service with a named volume for its data. The service runs under a non-root identity, with the user: key cited in this lesson. After the first start, you set up once the owner and the mode of the volume that Compose created, as in the lesson on volume permissions: a volume created outside Compose would not carry the labels of the project. You check that the permissions stay the same after each recreation.
The validation looks at the state of the machine, not at the commands typed. It temporarily replaces your data with random values, force-recreates the container, replays a down then up cycle, and checks that the values survive. It also checks that another identity is refused. Reproducing the initial data in each new container proves nothing: only real persistence counts.
Key points
-
A named volume declared in the
volumes:block survivesup -d --force-recreateanddocker compose down. Among the Compose commands, onlydocker compose down -vremoves it, and it is typed only to start from scratch. -
The scripts in
/docker-entrypoint-initdb.drun only on an empty data folder. On a filled volume, the log printsSkipping initializationand the modified script is not replayed. -
To change the schema of a database that is in use, write a repeatable migration, with
IF NOT EXISTS, and run it withdocker compose exec base psql -f. Replayed, it loses no data. -
A volume stores UID/GID numbers, not account names. After a recreation, the container gets its permissions back as long as its identity is the same. A refusal is proved by a throwaway container under another identity, and it is settled neither with
chmod 777nor by switching the service to root. -
:rocloses a mount to writes for every identity, root included, and the error isRead-only file system. TheRWfield ofdocker inspectconfirms it. -
On first start, the database takes a few seconds to answer: run the query again, and read
docker compose logs baseuntildatabase system is ready to accept connections. -
With PostgreSQL 18, mount the named volume on
/var/lib/postgresql. On/var/lib/postgresql/data, the image refuses to start and Docker creates an anonymous volume. Once the path is fixed,down -vdoes not remove it: spot it withdocker volume ls -f dangling=trueand remove it withdocker volume rm.
Knowledge check
Check that the essentials of this lesson have stuck. The questions cover only what has just been explained here.
Knowledge check
Check what you have learned with this interactive quiz
How it works
- The timer starts when you click Start
- Multiple choice, true/false and short answer questions
- You can move between questions
- Detailed results are shown at the end
Starts the quiz and the timer
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: Exam: bring a Compose stack back into service
A new full quiz with random questions
Work again only on the questions you missed