Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

By updated

Improve this page

Logo Docker

A backup that nobody has ever restored is a hypothesis, not a guarantee. You already know how to start a Compose stack with a database and files. Here, you back up the three parts of an application (the database, the files of the volumes, the configuration of the project), you restore them on a blank environment and you prove that the result is identical.

You will also run into two traps. Copying the volume of a running database gives a copy that may refuse to start. And the -T option of docker compose exec does not protect what you think it protects: an output redirected to a file stays intact without it.

What you will learn

  • Say what must be backed up in a Compose application, and what is sensitive in the backup.
  • Back up a PostgreSQL database with pg_dump -Fc and read the contents of the archive with pg_restore --list.
  • Explain what -T changes for docker compose exec.
  • Explain why a hot copy of a database volume is not a backup.
  • Back up the files of a volume with tar and restore the configuration of a project.
  • Keep the database and the files consistent with each other when the application writes to both.
  • Restore on a blank target, compare a control checksum and replay a restoration on a database that is already filled.

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 the lesson on installing Docker shows. The measurements in this lesson were made on Debian 13 with PostgreSQL 18.6.

You know how to write a Compose stack with a named volume, as Persist the data of a recreated stack shows, and you know the difference between a volume and a file mount, presented in Keep data and mount files. The stack reads its settings from a .env file (Interpolation and environment files) and the database password from a secret (Inject secrets and know their limits). The --wait option of docker compose up is explained in Startup, readiness and health. Finally, the lesson Read logs and attribute saturation has already presented -T as the form to use for a docker compose exec launched from a script.

This lesson covers the logical backup of a database, that is, an export of its contents. Physical backups (a copy of the database files, as pg_basebackup does), archiving of the transaction logs and point-in-time restoration are described in Sauvegarder et restaurer PostgreSQL (in French) and are not repeated here.

Which stack serves as the demonstration?

A small library in a ~/bibliotheque folder: a base service (PostgreSQL) and a site service (nginx) that serves cover images. The name of the folder becomes the project name, so the containers are called bibliotheque-... and the volumes bibliotheque_donnees and bibliotheque_couvertures.

The names are French (bibliotheque is a library, livres books, couvertures covers, sauv short for sauvegarde, a backup), because the commands and outputs shown are the ones that were run. They are identifiers and program output, so they stay as they are.

Create the folders, then the compose.yaml file. The ~/sauv folder will receive the backups, which contain all the data of the database: mkdir -m 700 reserves it for your account from the moment it is created, instead of restricting each file afterwards.

Terminal window
mkdir -p ~/bibliotheque/nginx ~/bibliotheque/secrets
mkdir -m 700 -p ~/sauv
cd ~/bibliotheque
services:
base:
image: postgres:18.6-alpine3.24@sha256:10a5c69a83035ab5239f5620d708520e8b28f5aa1f2b20143e6024532edadfb5
environment:
POSTGRES_USER: ${BASE_UTILISATEUR}
POSTGRES_DB: ${BASE_NOM}
POSTGRES_PASSWORD_FILE: /run/secrets/base_mdp
secrets:
- base_mdp
volumes:
- donnees:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${BASE_UTILISATEUR} -d ${BASE_NOM}"]
interval: 2s
timeout: 3s
retries: 30
site:
image: nginx:1.30.6-alpine@sha256:0e52bd054126cdc040b1276c0af347aeb02a41b9cfe305d6094767ac30e97949
ports:
- "${SITE_PORT}:80"
volumes:
- couvertures:/usr/share/nginx/html/couvertures:ro
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
secrets:
base_mdp:
file: ./secrets/base_mdp.txt
volumes:
donnees:
couvertures:

The donnees volume is mounted on /var/lib/postgresql: with PostgreSQL 18, the image puts the data in a subfolder of this path, which will matter in the section on the hot copy. The couvertures volume is mounted read-only in nginx.

Next, write the nginx configuration, the .env file and the secret, keeping strict permissions on the last two. The password is a fake one, specific to the demonstration.

Terminal window
cat > nginx/default.conf <<'EOF'
server {
listen 80;
location /couvertures/ {
root /usr/share/nginx/html;
}
location = /sante {
default_type text/plain;
return 200 "ok\n";
}
}
EOF
cat > .env <<'EOF'
BASE_UTILISATEUR=biblio
BASE_NOM=bibliotheque
SITE_PORT=8080
EOF
printf 'mdp-de-demo-bibliotheque' > secrets/base_mdp.txt
chmod 600 .env secrets/base_mdp.txt
docker compose up -d --wait

Fill the database with five sample books. psql reads the text that follows on its standard input (the "heredoc"). The -T option tells Compose not to allocate a pseudo-terminal, a fake screen-and-keyboard that the section on -T explains.

Terminal window
docker compose exec -T base psql -U biblio -d bibliotheque -v ON_ERROR_STOP=1 <<'EOF'
CREATE TABLE livres (id serial PRIMARY KEY, titre text NOT NULL, auteur text NOT NULL, annee int NOT NULL);
INSERT INTO livres (titre, auteur, annee) VALUES
('Les Misérables','Victor Hugo',1862),
('Germinal','Émile Zola',1885),
('Le Petit Prince','Antoine de Saint-Exupéry',1943),
('L''Étranger','Albert Camus',1942),
('Bonjour tristesse','Françoise Sagan',1954);
EOF

Then create three cover files of 20,000 random bytes in the couvertures volume. The Alpine image is pinned by its digest, like the other two: without a tag or a digest, docker run alpine would pull alpine:latest, a reference that changes with each release. The ALPINE variable avoids repeating the reference.

Terminal window
ALPINE=alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
docker run --rm -v bibliotheque_couvertures:/dst "$ALPINE" \
sh -c 'for i in 1 2 3; do head -c 20000 /dev/urandom > /dst/couverture-$i.jpg; done'

What remains is to fix what the restoration will have to find again. A control checksum is a short result computed on the data: if it is identical before and after, the data is too. Write the query in a file, then record the checksum of the database and those of the files as nginx serves them. In curl -fsS, the -f option makes the command fail on an HTTP error (404, 500) instead of returning the error page: without it, sha256sum would hash that page and the checksum would look valid. -sS stays silent except on error.

Terminal window
cat > ~/sauv/empreinte.sql <<'EOF'
select count(*)||' lignes, md5 '||md5(string_agg(id||titre||auteur||annee,'|' order by id)) from livres
EOF
docker compose exec -T base psql -U biblio -d bibliotheque -Atf - < ~/sauv/empreinte.sql
for i in 1 2 3; do curl -fsS http://localhost:8080/couvertures/couverture-$i.jpg | sha256sum; done > ~/sauv/sha-source.txt
cat ~/sauv/sha-source.txt
5 lignes, md5 b52719a3d3e67d9d6e6b36e8a31f2877

The number of rows and the md5 form the value that the target will have to reproduce. The -Atf - options of psql ask for a raw output, without a header, with the query read from standard input.

The sha-source.txt file keeps the checksum of the three covers. cat must display three lines, each a SHA-256 checksum of 64 hexadecimal characters, and the three are different, since the files are random. The same checksum three times would mean that curl failed and that sha256sum hashed an empty input.

What must be backed up in a Compose application?

Three things, which are not backed up in the same way: the data of the database, the files of the volumes and the configuration of the project. Images are not on the list, since they can be found again by their digest as long as the registry serves them.

What mattersWhere it livesHow to back it upSensitivity
The database dataThe donnees volume, managed by PostgreSQLpg_dump run in the containerContains all the business data
The application filesThe couvertures volumetar run in a container that mounts the volumeDepends on the nature of the files
The project configurationcompose.yaml, .env, nginx/, secrets/ in the project foldertar of the folder, on the host sideContains the database password

The last row of the table is the trickiest. The configuration archive contains the secret file and the .env: it is as sensitive as the secret itself. Keep it out of any Git repository. The database dump is just as sensitive: it carries all the business data. That is why the backup folder is created with mode 700.

A backup is only judged by its restoration. Each part will therefore be restored below on a blank target, with the same control checksum to conclude.

How do you back up the database with pg_dump?

By running pg_dump in the database container with docker compose exec, and redirecting its output to a file on the host. pg_dump queries the server like any other client and reads the data in a transaction: according to the pg_dump documentation, it gets a consistent view of the database even while other clients are writing. The -Fc format, called the custom format, produces a compressed archive specific to PostgreSQL that pg_restore can read back, table by table if you wish.

Terminal window
cd ~/sauv
docker compose -f ~/bibliotheque/compose.yaml exec -T base pg_dump -Fc -U biblio bibliotheque > biblio.dump
ls -l biblio.dump

On the measurement machine, the command took 0.1 s and the archive is 2,887 bytes: the database is tiny. Remember the principle rather than the size: the biblio.dump file is created by the shell of the host (that is the > redirection), while pg_dump runs in the container and writes to its standard output.

The -f option of Compose designates the project file, which lets you run the command from ~/sauv. You can also move into ~/bibliotheque.

What does the -T option of docker compose exec change?

-T prevents Docker from allocating a pseudo-terminal, that is, a fake screen-and-keyboard, for the command. Without the option, docker compose exec may allocate one, and what matters for the result is where the standard output goes: a file or a terminal.

You might think that without -T, a pseudo-terminal damages an output redirected to a file. The measurement says otherwise. On the measurement machine, pg_dump -Fc run without -T and redirected to a file returned code 0 and a file of the same size as the first (2,887 bytes), with or without a pseudo-terminal on the input. pg_restore --list read that file. The > redirection therefore does not corrupt the archive.

The defect appears when the standard output is a terminal: a text pg_dump, without redirection, run under a pseudo-terminal produced CRLF line endings (a carriage return before each line feed, 47 counted in the output). Never send pg_dump -Fc to a terminal: a binary archive has no business on a screen, always redirect it to a file.

The real trap is elsewhere, and -T does not fix it.

In a script read on standard input (a command launched by ssh ... bash -s, or a while read loop), a docker compose exec that has no input redirection consumes the rest of the script: the following commands never run. The remedy is to give it an input of its own, with < /dev/null when the command has nothing to read. The rule comes down to two gestures: -T for any redirected or scripted command, which removes the pseudo-terminal, and < /dev/null so that exec does not touch the input of the script.

How do you check the contents of a backup without restoring it?

With pg_restore --list, which reads the archive and displays its table of contents without writing anything to a database. You run it in the container, the archive arrives on standard input, and -T avoids the pseudo-terminal.

Terminal window
docker compose -f ~/bibliotheque/compose.yaml exec -T base pg_restore --list < biblio.dump
;
; Archive created at 2026-11-03 05:47:10 UTC
; dbname: bibliotheque
; TOC Entries: 11
; Compression: gzip
; Dump Version: 1.16-0
; Format: CUSTOM
; Integer: 4 bytes
; Offset: 8 bytes
; Dumped from database version: 18.6
; Dumped by pg_dump version: 18.6
;
;
; Selected TOC Entries:
;
220; 1259 16386 TABLE public livres biblio
219; 1259 16385 SEQUENCE public livres_id_seq biblio
3481; 0 0 SEQUENCE OWNED BY public livres_id_seq biblio
3323; 2604 16389 DEFAULT public livres id biblio
3474; 0 16386 TABLE DATA public livres biblio
3482; 0 0 SEQUENCE SET public livres_id_seq biblio
3325; 2606 16397 CONSTRAINT public livres livres_pkey biblio

Read the header first: Format: CUSTOM confirms the format, and Dumped from database version: 18.6 says which version the archive comes from. Then look for the TABLE DATA public livres line: it is the one that carries the rows of the table. Without it, the archive would contain the structure but no data. The SEQUENCE SET line carries the position of the sequence, that is, the last number handed out, which the restoration must also recover.

A limit to know about: pg_dump only backs up the targeted database, not the roles (the users and their permissions), which belong to the whole server. The pg_dumpall --globals-only section of Sauvegarder et restaurer PostgreSQL (in French) explains how to export them.

How do you keep the database and the files consistent with each other?

When the application writes to both the database and the files, the two backups, taken at two different moments, can contradict each other: the database references a file that is missing from the archive, or the other way round. The remedy is to stop the service that writes during both backups, then restart it: docker compose stop site before, docker compose start site after (replace site with the service that writes in your case). In this demonstration, the site only reads, so the precaution is unnecessary; the guide Sauvegarder et restaurer Forgejo (in French) covers the case of a real application.

Why is copying the volume of a running database not a backup?

Because the files of a running database change during the copy: PostgreSQL continuously creates, fills and deletes segments of its transaction log (the WAL, its pg_wal files). A file copy taken at that moment mixes states from different times, and the copied database may not start on it. For an ordinary file copy (tar, cp), you must therefore stop the server before copying. A filesystem snapshot, a frozen image of the disk taken in a single step, would also do, but only some filesystems or volumes can produce one.

The measurement bench replayed this case five times on a 539 MB database, fed continuously by a writer (batches of 5,000 rows, with a CHECKPOINT every 0.3 s). It is a deliberately heavy load. Meanwhile, the volume was copied by a tar run in a container, then a PostgreSQL of the same image was started on the copy.

Here is the copy that was made. On your small database at rest, replaying it would not reproduce these failures: read the command and what it gave.

Terminal window
docker run --rm -v bibliotheque_donnees:/src:ro "$ALPINE" tar cf - -C /src . \
| docker run --rm -i -v copie1:/dst "$ALPINE" tar xf - -C /dst

Five attempts out of five failed, in the same way. The reading tar returned code 1, with messages of this kind for WAL segments that disappeared during the copy.

tar: ./18/docker/pg_wal/000000010000000000000048: No such file or directory
tar: ./18/docker/pg_wal/00000001000000000000004A: No such file or directory

The PostgreSQL started on the copy then stopped within a second.

2026-11-03 05:48:59.621 UTC [31] LOG: invalid checkpoint record
2026-11-03 05:48:59.621 UTC [31] PANIC: could not locate a valid checkpoint record at 0/47AD55F8

The word PANIC says that the database refuses to start: it does not degrade silently. One attempt out of five gave a similar message, PANIC: could not find redo location ... referenced by checkpoint record. A tricky detail: the part that writes the copy (tar xf) returned code 0 in all five attempts. With a pipe (the | between the two docker run), the shell only returns the code of the last command: the failure of the read is lost, and the faulty copy looks successful.

A control showed the other side. On a database at rest, with a CHECKPOINT just before the copy (the command that forces PostgreSQL to write to disk what it holds in memory) and no writes, the copy started and displayed database system was not properly shut down; automatic recovery in progress, then redo done, with the same number of rows. PostgreSQL treated the copy as a database that had been stopped abruptly and recovered it. That is luck, not a guarantee: a database at rest only differs from the other one at the moment you need to restore it.

One reservation is in order: the load of this bench is deliberately heavy, and under a normal load the failure may be less frequent. The conclusion still holds, because a tar run on a running volume is an ordinary file copy: it requires the server to be stopped.

For a cold copy (database stopped cleanly) or a physical backup with pg_basebackup, read Sauvegarder et restaurer PostgreSQL (in French) and check by a restoration on your version. This lesson keeps pg_dump, whose restoration is proven below.

How do you back up the files and the configuration?

The files of a volume are backed up with tar, run in a container that mounts the volume read-only, together with the backup folder of the host. The files of a site are not a database: they only change when the application writes them, and here the site serves them read-only.

Terminal window
cd ~/sauv
docker run --rm -v bibliotheque_couvertures:/src:ro -v "$PWD":/sauvegarde "$ALPINE" \
tar czf /sauvegarde/couvertures.tar.gz -C /src .
ls -l couvertures.tar.gz

On the measurement machine, the archive weighs about 60,000 bytes (60,319 on the last attempt): three random files of 20,000 bytes do not compress, and the exact value varies from one attempt to the next. The file belongs to root, because tar runs as root in the container. You can read it, and delete it with rm, since the folder is yours; in a terminal, rm first asks for confirmation for this write-protected file.

For the configuration, it is a simple tar of the folder, on the host side. The archive includes the .env and the secret.

Terminal window
cd ~/bibliotheque
tar czf ~/sauv/config.tar.gz compose.yaml .env nginx secrets
tar tzvf ~/sauv/config.tar.gz
-rw-rw-r-- ansible/ansible 866 2026-11-03 05:47 compose.yaml
-rw------- ansible/ansible 61 2026-11-03 05:47 .env
drwxrwxr-x ansible/ansible 0 2026-11-03 05:46 nginx/
-rw-rw-r-- ansible/ansible 187 2026-11-03 05:47 nginx/default.conf
drwxrwxr-x ansible/ansible 0 2026-11-03 05:46 secrets/
-rw------- ansible/ansible 24 2026-11-03 05:47 secrets/base_mdp.txt

In the listing, tar kept the 600 mode of the .env and of the secret inside the archive. But the archive file itself gets the permissions that your umask (the mask that removes permissions from any created file) leaves: on the measurement machine (umask 0002), it is created as rw-rw-r--. Here, the ~/sauv folder in 700 stops other accounts from entering it, and therefore from reading the archive. If you move it elsewhere, check its permissions with ls -l and restrict it with chmod 600.

How do you restore on a blank target?

By recreating the project in another folder, in this order: configuration, volumes, files, startup, then database. A blank target is an environment that does not yet contain anything of the stack: no volume, no container, no data. Here, it is a second project, bibliotheque-restauree, started on the same machine for the demonstration. In a real recovery, it would be another machine: first copy the backup folder, then follow the same steps. That is also the reason to keep a copy off the machine: a backup left on the host disappears with it. The guide Sauvegarder et restaurer Forgejo (in French) shows an encrypted remote copy.

The diagram shows the path of each part. At the top, each source becomes a backup file. At the bottom, the red lines show at which step each file is used: the configuration first, the files once the volumes are created, the database last, when the service is running.

Two-phase diagram. At the top, three sources are backed up: the project folder becomes config.tar.gz with tar, the covers volume becomes couvertures.tar.gz with tar, the PostgreSQL database becomes biblio.dump with pg_dump. At the bottom, a chain of five steps on the blank target: extract the configuration, docker compose create, put the files back, docker compose up -d --wait, then pg_restore. config.tar.gz feeds the first step, couvertures.tar.gz the third and biblio.dump the fifth.tartarpg_dumpProject folderconfig.tar.gzCovers volumecouvertures.tar.gzPostgreSQL databasebiblio.dump1 · Extract the configuration2 · docker compose create3 · Put the files back4 · docker compose up -d --wait5 · pg_restore
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

Restore the configuration first. Since the target runs next to the source, a single setting changes: the published port, 8081 instead of 8080.

Terminal window
mkdir -p ~/bibliotheque-restauree && cd ~/bibliotheque-restauree
tar xzf ~/sauv/config.tar.gz
sed -i 's/^SITE_PORT=8080/SITE_PORT=8081/' .env
cat .env
BASE_UTILISATEUR=biblio
BASE_NOM=bibliotheque
SITE_PORT=8081

The volumes of the target must then exist, and belong to the project, before you put files in them. docker compose create creates the volumes with the labels that attach them to the project, then the network and the containers, without starting anything.

Terminal window
docker compose create
docker volume ls
Volume bibliotheque-restauree_donnees Creating
Network bibliotheque-restauree_default Creating
Volume bibliotheque-restauree_couvertures Creating
Volume bibliotheque-restauree_couvertures Created
Volume bibliotheque-restauree_donnees Created
Network bibliotheque-restauree_default Created
Container bibliotheque-restauree-site-1 Creating
Container bibliotheque-restauree-base-1 Creating
Container bibliotheque-restauree-site-1 Created
Container bibliotheque-restauree-base-1 Created
DRIVER VOLUME NAME
local bibliotheque-restauree_couvertures
local bibliotheque-restauree_donnees
local bibliotheque_couvertures
local bibliotheque_donnees

The project prefix separates the volumes of the target from those of the source. Next, restore the files into the volume of the target, with the same tar, in the opposite direction and with the backup folder mounted read-only.

Terminal window
docker run --rm -v bibliotheque-restauree_couvertures:/dst -v "$HOME/sauv":/sauvegarde:ro "$ALPINE" \
tar xzf /sauvegarde/couvertures.tar.gz -C /dst

Start the target and wait for the database to be ready. Then look at what it contains.

Terminal window
docker compose up -d --wait
docker compose exec -T base psql -U biblio -d bibliotheque -c '\dt'
Container bibliotheque-restauree-site-1 Starting
Container bibliotheque-restauree-base-1 Starting
Container bibliotheque-restauree-site-1 Started
Container bibliotheque-restauree-base-1 Started
Container bibliotheque-restauree-base-1 Waiting
Container bibliotheque-restauree-site-1 Waiting
Container bibliotheque-restauree-site-1 Healthy
Container bibliotheque-restauree-base-1 Healthy
Did not find any tables.

The last line says that the bibliotheque database exists but has no tables. The postgres image created it on the first start, from POSTGRES_DB, so there is no CREATE DATABASE to run. What remains is to pour the backup into it.

Terminal window
docker compose exec -T base pg_restore -U biblio -d bibliotheque --exit-on-error --no-owner < ~/sauv/biblio.dump
echo "rc=$?"
docker compose exec -T base psql -U biblio -d bibliotheque -Atf - < ~/sauv/empreinte.sql
rc=0
5 lignes, md5 b52719a3d3e67d9d6e6b36e8a31f2877

The restoration took 0.12 s on the measurement machine. --exit-on-error stops pg_restore at the first error instead of carrying on, and --no-owner avoids reassigning each object to its original owner: they belong to the user of the connection. Above all, compare the checksum: the number of rows and the md5 are those of the source, recorded earlier.

What should you do about the "already exists" error?

Nothing is broken: pg_restore refuses to recreate tables that already exist. You can see it by rerunning the same restoration, now that the database is filled. The --no-owner option is left out here and below: these two commands are played with the same biblio user that created the objects, so there is no owner to reassign.

Terminal window
docker compose exec -T base pg_restore -U biblio -d bibliotheque --exit-on-error < ~/sauv/biblio.dump
echo "rc=$?"
pg_restore: error: could not execute query: ERROR: relation "livres" already exists
Command was: CREATE TABLE public.livres (
id integer NOT NULL,
titre text NOT NULL,
auteur text NOT NULL,
annee integer NOT NULL
);
rc=1

The return code is 1 and the quoted query is the one that fails: pg_restore wanted to recreate the table and it exists. There are two ways out. The cleanest is to restore on a blank database, as above. To overwrite a database that exists, add --clean --if-exists: pg_restore drops each object before recreating it, without error if the object is absent.

Terminal window
docker compose exec -T base pg_restore -U biblio -d bibliotheque --clean --if-exists --exit-on-error < ~/sauv/biblio.dump
echo "rc=$?"

This time the return code is 0. Record the checksum of the target with the psql -Atf - command already used: it must give the value of the source again. But --clean destroys what exists in the targeted database: use it only on the one you want to replace.

How do you prove that the restoration is complete?

By comparing the target to the source on each of the three parts, and by checking that the application behaves as before. The checksum of the database has already been compared. Record the one from the source again, then read the rows of the target.

Terminal window
docker compose -f ~/bibliotheque/compose.yaml exec -T base psql -U biblio -d bibliotheque -Atf - < ~/sauv/empreinte.sql
docker compose exec -T base psql -U biblio -d bibliotheque -c 'select id, titre, annee from livres order by id'
5 lignes, md5 b52719a3d3e67d9d6e6b36e8a31f2877
id | titre | annee
----+-------------------+-------
1 | Les Misérables | 1862
2 | Germinal | 1885
3 | Le Petit Prince | 1943
4 | L'Étranger | 1942
5 | Bonjour tristesse | 1954
(5 rows)

The accents are preserved. Then check the sequence, the counter that gives the number of new rows. A restoration that forgot it would replay numbers already taken. Add a row, then delete it.

Terminal window
docker compose exec -T base psql -U biblio -d bibliotheque -Atc "insert into livres (titre,auteur,annee) values ('Candide','Voltaire',1759) returning id"
docker compose exec -T base psql -U biblio -d bibliotheque -Atc "delete from livres where titre='Candide'"
6
INSERT 0 1
DELETE 1

The new book received the identifier 6: the sequence resumed after the last number of the source. For the files, compare what nginx serves on the target with what you recorded on the source, then test the health route.

Terminal window
for i in 1 2 3; do curl -fsS http://localhost:8081/couvertures/couverture-$i.jpg | sha256sum; done > ~/sauv/sha-cible.txt
diff ~/sauv/sha-source.txt ~/sauv/sha-cible.txt
curl -s http://localhost:8081/sante
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8081/sante
ok
200

The diff command displays nothing: the three checksums are identical. The /sante route answers ok with code 200. The configuration remains. docker compose config displays the file as Compose interprets it, variables replaced: compare the source and the target. For the source, -f designates its file from the folder of the target, and -p bibliotheque explicitly sets its project name, the one under which it runs.

Terminal window
docker compose -f ~/bibliotheque/compose.yaml -p bibliotheque config > ~/sauv/cfg-source.txt
docker compose config > ~/sauv/cfg-cible.txt
diff ~/sauv/cfg-source.txt ~/sauv/cfg-cible.txt
1c1
< name: bibliotheque
---
> name: bibliotheque-restauree
37c37
< source: /home/ansible/bibliotheque/nginx/default.conf
---
> source: /home/ansible/bibliotheque-restauree/nginx/default.conf
44c44
< published: "8080"
---
> published: "8081"
48c48
< name: bibliotheque_default
---
> name: bibliotheque-restauree_default
51c51
< name: bibliotheque_couvertures
---
> name: bibliotheque-restauree_couvertures
53c53
< name: bibliotheque_donnees
---
> name: bibliotheque-restauree_donnees
56,57c56,57
< name: bibliotheque_base_mdp
< file: /home/ansible/bibliotheque/secrets/base_mdp.txt
---
> name: bibliotheque-restauree_base_mdp
> file: /home/ansible/bibliotheque-restauree/secrets/base_mdp.txt

Each difference has an explanation: the project name, the absolute paths of the folder (the nginx mount and the secret file), the published port, chosen for the demonstration, and the names prefixed by the project (network, volumes, secret). Nothing else differs: the structure is the same. A difference that you cannot explain, on the other hand, signals an incomplete restored configuration.

The test is complete when the three readings agree: the checksum of the database, the checksums of the served files, and a configuration that differs only by what you changed.

What should you do when the backup or the restoration fails?

The table starts from what you observe, then gives the probable cause and the gesture that fixes the problem.

What you observeProbable causeWhat you do
pg_restore: error: ... relation "..." already exists, code 1The targeted database already contains the tablesRestore on a blank database, or add --clean --if-exists if you want to overwrite this database
Did not find any tables. right after up -d --wait on the targetThe database was created empty by the imageThis is normal: run pg_restore
tar returns No such file or directory on pg_wal files, then the copy refuses to start with PANICHot copy of the volume of a running databaseGive up this copy; back up with pg_dump
The copy looks successful but the database does not startIn a pipe, the shell returns the code of the last command, not that of the reading tarTest the return code of the read, and above all restore before trusting the backup
The following commands of a script never rundocker compose exec reads the standard input of the scriptAdd < /dev/null if the command reads nothing (and keep -T to remove the pseudo-terminal)
CRLF line endings in a text output of pg_dumpThe standard output is a terminal with a pseudo-terminalRedirect the output to a file with -T

How do you clean everything up after the demonstration?

The demonstration leaves two projects, four volumes (five if you replayed the hot copy), three images and three folders. docker compose down -v removes the containers, the network and the volumes of each project. Images are removed by their digest, which avoids touching another version.

Terminal window
cd ~/bibliotheque-restauree && docker compose down -v
cd ~/bibliotheque && docker compose down -v
docker rmi postgres:18.6-alpine3.24@sha256:10a5c69a83035ab5239f5620d708520e8b28f5aa1f2b20143e6024532edadfb5 \
nginx:1.30.6-alpine@sha256:0e52bd054126cdc040b1276c0af347aeb02a41b9cfe305d6094767ac30e97949
cd ~ && rm -r ~/sauv ~/bibliotheque ~/bibliotheque-restauree

The ~/sauv folder is deleted by the last rm, including couvertures.tar.gz (owned by root, but in a folder that is yours; rm -r may ask for confirmation, answer y). If you replayed the hot copy, also remove its volume, copie1, with docker volume rm copie1. Keep the Alpine image if you use it elsewhere, otherwise remove it by its digest: docker rmi alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a. After that, docker ps -a and docker volume ls no longer show anything from the demonstration.

Practice

The lab gives you a Compose stack that is already running, with a PostgreSQL database and a service that publishes files. You back it up, bring it back in a new project that runs next to it, then prove that the data served is the same, without costing the source anything.

The validation does not look at the commands you typed. It rereads the database backup to check that it is logical, checks that the files and the configuration are archived separately and that the target is a distinct project, up on its own port, then compares the checksums of the target with those of the source, which stayed intact. The files are read as the web service serves them, not only in the volume.

Key points

  1. A backup is only worth what its restoration proves: prove it with a control checksum compared between the source and the target.
  2. A Compose application has three parts to back up: the database (pg_dump), the files of the volumes (tar in a container) and the configuration of the project.
  3. The configuration archive and the database dump are sensitive: create the backup folder with mode 700 from the start.
  4. docker compose exec -T base pg_dump -Fc ... > file produces an archive that pg_restore --list lets you read without restoring anything.
  5. -T disables the pseudo-terminal: redirecting the output to a file does not corrupt the archive, but an output to a terminal can add CRLF. The trap of scripts is another one: in a script read on standard input, an exec without input redirection consumes the rest of the script, and the remedy is < /dev/null.
  6. Copying a database volume with tar while it runs is not a backup: on the measurement bench, under heavy load, five copies out of five failed with PANIC. Under a normal load the failure may be less frequent, and a copy that starts remains luck.
  7. Restore on a blank target in this order: configuration, docker compose create, files, up -d --wait, then pg_restore. The already exists error is fixed by a blank database or by --clean --if-exists, which destroys what exists.
  8. Pin by digest the images of your backup commands, Alpine included: without a tag, docker run alpine pulls latest.
  9. If the application writes to both the database and the files, stop the service that writes during both backups, then restart it.

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

8 questions
8 min
70% to pass

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

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn