The platform works. curl http://localhost:3000/books returns the eight titles and the site loads in the browser. But there is a crack in the foundations that you have already tripped over twice without paying attention: every time you recreate aurora-db, the catalog disappears and you have to load init.sql by hand again with docker cp. On a development machine that is an annoyance. On the server holding a real bookshop's catalog, it is a catastrophe.

This lesson solves containers' second big problem, after networking: data. You are going to demonstrate the loss with an experiment that is a little frightening, you are going to learn the three types of mount Docker offers and when to use each one, you are going to understand where volumes really live and why you should not touch them by hand, and you are going to give PostgreSQL a named volume so that the Aurora Libros catalog survives the destruction of its container. At the end you will repeat the opening experiment and the result will be the opposite.

Contents

  1. The problem, demonstrated
  2. The three types of mount
  3. Docker-managed volumes
  4. Anonymous volumes and the orphan problem
  5. Bind mounts: files from your machine inside the container
  6. -v versus --mount
  7. Read-only mounts
  8. tmpfs: data in memory
  9. Hands-on: aurora-data and the destruction test
  10. Backup and restore
  11. Best practices

  1. The problem, demonstrated

Let's start by looking the disaster in the face. Add a new book to the catalog:

docker exec aurora-db psql -U aurora -d aurora_books -c \
  "INSERT INTO books (title, author, isbn, price) VALUES
   ('El Aleph', 'Jorge Luis Borges', '978-84-206-3311-4', 15.20);"
docker exec aurora-db psql -U aurora -d aurora_books -t -c "SELECT COUNT(*) FROM books;"
curl -s http://localhost:3000/books | jq -r '.books | length'
INSERT 0 1
     9
9

Nine books. The API serves them. Now simulate what happens in any normal operation: updating the PostgreSQL image, changing an environment variable, adding a memory limit... all of that forces you to recreate the container, as you have known since lesson 03-01.

docker rm -f aurora-db

docker run -d --name aurora-db --network aurora-net \
  --label project=aurora-libros --label component=database \
  -e POSTGRES_USER=aurora -e POSTGRES_PASSWORD=aurora_secret -e POSTGRES_DB=aurora_books \
  -p 127.0.0.1:5432:5432 \
  postgres:16-alpine

sleep 6
docker exec aurora-db psql -U aurora -d aurora_books -c "SELECT COUNT(*) FROM books;"
ERROR:  relation "books" does not exist
LINE 1: SELECT COUNT(*) FROM books;
                             ^

It is not that El Aleph is missing: the table no longer exists. The whole Aurora Libros catalog has evaporated. And the API, consistently:

curl -s http://localhost:3000/books | jq -c
{"error":"Could not fetch the catalog","detail":"relation \"books\" does not exist"}

This is not a Docker bug: it is Docker's design, and you already knew it from lesson 01-05. A container writes to a writable layer that is born with it and dies with it. The nine rows were in an anonymous volume that Docker created automatically —you discovered it in exercise 2 of lesson 03-03— and which, when the container was recreated, was replaced by a new, empty one.

flowchart TB
    subgraph WITHOUT["Without a volume: the data dies with the container"]
        direction TB
        S1["docker run postgres"] --> S2["Writable layer<br/>+ new anonymous volume"]
        S2 --> S3["INSERT ... 9 books"]
        S3 --> S4["docker rm -f"]
        S4 --> S5["Everything destroyed<br/>or orphaned and unreachable"]
    end
    subgraph WITH["With a named volume: the data is independent"]
        direction TB
        V1["docker volume create aurora-data"] --> V2["docker run -v aurora-data:/var/lib/postgresql/data"]
        V2 --> V3["INSERT ... 9 books"]
        V3 --> V4["docker rm -f"]
        V4 --> V5["The volume is STILL there"]
        V5 --> V6["docker run ... same volume<br/>→ the 9 books come back"]
    end

The solution is to take the data out of the container. Let's get to it.

  1. The three types of mount

Docker offers three ways for a path inside the container to point at something that is not in its writable layer:

Volume Bind mount tmpfs
Where the data lives In an area managed by Docker (/var/lib/docker/volumes/) In a path you choose on your machine In the host's RAM
Who manages it Docker You The kernel
Does it survive docker rm? Yes Yes (it is your file) No, it evaporates
Does it survive a host reboot? Yes Yes No
Portability between machines High (the same command everywhere) Low (it depends on host paths) High
Performance on Docker Desktop Good Worse (it crosses the boundary with the VM) The best
Backups With docker run + tar, or docker volume With the host's tools Not applicable
Natural use case Production data: databases, user uploads Development and configuration files Secrets and sensitive temporary files
--mount syntax type=volume type=bind type=tmpfs
flowchart LR
    subgraph HOST["Host"]
        VOL["/var/lib/docker/volumes/aurora-data/_data<br/>(managed by Docker)"]
        DIR["~/aurora-libros/db/init.sql<br/>(your file)"]
        RAM["RAM"]
    end
    subgraph CONT["aurora-db container"]
        M1["/var/lib/postgresql/data"]
        M2["/docker-entrypoint-initdb.d/init.sql"]
        M3["/tmp/sensitive"]
    end
    VOL -- "volume" --> M1
    DIR -- "bind mount :ro" --> M2
    RAM -- "tmpfs" --> M3

The decision rule, in one sentence: if the data matters and the application manages it, a volume; if the file is yours and you want to edit it from your editor, a bind mount; if it must never touch disk, tmpfs.

  1. Docker-managed volumes

docker volume create aurora-data
docker volume ls
aurora-data
DRIVER    VOLUME NAME
local     aurora-data
local     b41f7c9e2a8d05f31c6e8b4a2d97f0c5e3a1b8d6f4c2e0a9b7d5f3c1e8a6b4d2

That second unreadable name is an orphaned anonymous volume, left over from one of the aurora-db containers you have deleted. We will come back to it.

Command What it does
docker volume create NAME Creates a volume
docker volume ls Lists them all
docker volume ls -f dangling=true Only the ones no container uses
docker volume inspect NAME Shows its real path and its metadata
docker volume rm NAME Deletes it (fails if it is in use)
docker volume prune Deletes all unused volumes

Where do they really live?

docker volume inspect aurora-data
[
  {
    "CreatedAt": "2026-08-04T22:03:11+02:00",
    "Driver": "local",
    "Labels": {},
    "Mountpoint": "/var/lib/docker/volumes/aurora-data/_data",
    "Name": "aurora-data",
    "Options": {},
    "Scope": "local"
  }
]

Mountpoint is the path on the host. And here comes the most important warning of this section:

Do not go in there and touch files. That path is the Docker daemon's property: it belongs to root, its permissions and its security context are managed by Docker, and on Docker Desktop (macOS and Windows) it does not even exist on your machine, but inside a Linux virtual machine you have no direct access to. Writing there by hand is an excellent way to corrupt a database.

The correct way to look inside a volume is with a container:

docker run --rm -v aurora-data:/data alpine:3.20 ls -la /data
total 8
drwxr-xr-x    2 root     root          4096 Aug  4 22:03 .
drwxr-xr-x    1 root     root          4096 Aug  4 22:05 ..

Empty, freshly created. This pattern —an ephemeral Alpine container that mounts the volume— is the Swiss army knife for inspecting, copying and restoring volumes, and you will use it in section 10.

Labels on volumes

docker volume create --label project=aurora-libros aurora-backups
docker volume ls --filter label=project=aurora-libros
aurora-backups
DRIVER    VOLUME NAME
local     aurora-backups

Just as with containers, labeling lets you clean up selectively:

docker volume prune --filter "label=project=aurora-libros" -f

  1. Anonymous volumes and the orphan problem

An anonymous volume is one Docker creates on its own, without you asking, in two situations:

  1. When the image declares VOLUME /path in its Dockerfile (postgres, redis, mysql, mongo… all do).
  2. When you mount with -v /path giving only the destination, with no source.
docker run -d --name demo-anonymous -v /data alpine:3.20 sleep 60
docker inspect -f '{{range .Mounts}}{{.Type}} | {{.Name}} | {{.Destination}}{{end}}' demo-anonymous
volume | e83f1c7a92b40d651fe8a3c9d7b2f4e6a0c8b1d5f3a7c9e1b8d6f4a2c0e8b6d4 | /data

That 64-character name is the anonymous volume. And here is its problem:

docker rm -f demo-anonymous
docker volume ls -f dangling=true --format "{{.Name}}" | head -3
demo-anonymous
b41f7c9e2a8d05f31c6e8b4a2d97f0c5e3a1b8d6f4c2e0a9b7d5f3c1e8a6b4d2
e83f1c7a92b40d651fe8a3c9d7b2f4e6a0c8b1d5f3a7c9e1b8d6f4a2c0e8b6d4

The volume is still there, orphaned. With no container using it, with a name nobody can remember, and taking up disk indefinitely. Every postgres you have deleted in this module left one of these behind, with its data inside, unrecoverable in practice.

Anonymous volume Named volume
How it is created Automatically (the Dockerfile's VOLUME or -v /destination) docker volume create or -v name:/destination
Name A 64-character hash Whichever you choose
Reused when recreating the container No, another empty one is created Yes, it is the same one
Deleted by docker rm -v Yes No
Deleted by docker volume prune Yes if it is orphaned Yes if it is orphaned
Recommendation Avoid them for data that matters Always for data that matters

Clean up the orphans now, checking first what you are about to take down:

docker volume ls -f dangling=true
docker volume prune -f
Deleted Volumes:
b41f7c9e2a8d05f31c6e8b4a2d97f0c5e3a1b8d6f4c2e0a9b7d5f3c1e8a6b4d2
e83f1c7a92b40d651fe8a3c9d7b2f4e6a0c8b1d5f3a7c9e1b8d6f4a2c0e8b6d4

Total reclaimed space: 47.83MB

Careful with this command: docker volume prune deletes real data and there is no recycle bin. An "unused" volume may belong to a database whose container is simply stopped. Since Docker 23, prune only touches anonymous ones by default; to include named ones you need --all, which is a safety net worth not disabling lightly.

  1. Bind mounts: files from your machine inside the container

A bind mount connects a specific path on your host with a path in the container. You already used it in lesson 01-06 with your index.html.

mkdir -p ~/aurora-libros/tests
echo "Content from the host" > ~/aurora-libros/tests/note.txt

docker run --rm -v ~/aurora-libros/tests:/data alpine:3.20 cat /data/note.txt
docker run --rm -v ~/aurora-libros/tests:/data alpine:3.20 \
  sh -c 'echo "Written from the container" >> /data/note.txt'
cat ~/aurora-libros/tests/note.txt
Content from the host
Content from the host
Written from the container

It is the same file seen from two places: changes flow in both directions and in real time. That is its superpower for development —you edit in your IDE and the container sees it instantly— and its danger in production.

Rules and traps:

Rule Detail
An absolute path is mandatory -v ./web:/html did not work in older versions. Use -v "$(pwd)/web":/html or ~/... (the shell expands the tilde)
If the host path does not exist, Docker creates it And it creates it as an empty directory owned by root. It is the cause of the classic "I mounted my file and a folder appeared"
The mount hides whatever was at the destination Mounting an empty directory over /usr/share/nginx/html leaves Nginx serving a 403
The permissions are the host's And that is where the UID problem begins

The UID clash

This is the classic bind mount headache:

docker run --rm -u node --entrypoint sh -v ~/aurora-libros/tests:/data \
  auroralibros/aurora-api:1.2.0 -c 'id; ls -ln /data'
uid=1000(node) gid=1000(node) groups=1000(node)
-rw-r--r--    1 1000     1000            52 Aug  4 22:11 note.txt

In this case it works by coincidence: your host user has UID 1000 and the image's node user does too. But try it with a different user:

docker run --rm -u 1500 -v ~/aurora-libros/tests:/data alpine:3.20 \
  sh -c 'touch /data/test.txt || echo "NO PERMISSION"'
touch: /data/test.txt: Permission denied
NO PERMISSION

The kernel compares numbers, not names. Inside the container there is no such thing as a "host user": there are only UIDs and GIDs, and the file's permissions are whatever they are on your machine. The three usual solutions:

Solution How When
Adjust the container's UID -u $(id -u):$(id -g) Development, the most practical one
Adjust the permissions on the host chmod 644 file / chown Read-only configuration files
Use a volume instead of a bind mount -v aurora-data:/path Production data: Docker manages the permissions

And from that comes a rule worth memorizing: bind mounts are for development and configuration files; production data goes in volumes.

  1. -v versus --mount

Docker offers two syntaxes for the same thing:

# -v syntax: compact, positional, ambiguous
docker run -v aurora-data:/var/lib/postgresql/data postgres:16-alpine

# --mount syntax: verbose, explicit, with key=value
docker run --mount type=volume,source=aurora-data,target=/var/lib/postgresql/data postgres:16-alpine
-v / --volume --mount
Readability Compact but cryptic Verbose and self-explanatory
If the source does not exist It creates it (an empty directory for bind mounts) It fails with an error
Mount type Inferred from the syntax Explicit with type=
Advanced options (tmpfs, propagation, drivers) Limited Complete
Use in Docker Compose and Swarm Supported The native form
Recommendation Fine for examples and quick commands Preferred, especially in scripts

That "if the source does not exist" is the most important practical difference. With -v, a typo in the path leaves you with an empty directory and a container that starts without its data; with --mount, you get a clear error and the container does not start.

docker run --rm --mount type=bind,source=/path/that/does/not/exist,target=/data alpine:3.20 ls /data
docker: Error response from daemon: invalid mount config for type "bind":
bind source path does not exist: /path/that/does/not/exist

An equivalence table to keep at hand:

Goal With -v With --mount
Named volume -v data:/var/lib/data --mount type=volume,src=data,dst=/var/lib/data
Anonymous volume -v /var/lib/data --mount type=volume,dst=/var/lib/data
Bind mount -v /host/path:/cont/path --mount type=bind,src=/host/path,dst=/cont/path
Read-only bind mount -v /host/f:/cont/f:ro --mount type=bind,src=/host/f,dst=/cont/f,readonly
tmpfs --tmpfs /tmp --mount type=tmpfs,dst=/tmp

src and source are synonyms, as are dst, destination and target.

  1. Read-only mounts

A configuration file has no business being writable by the container. Marking it read-only is a cheap and effective defense:

docker run --rm -v ~/aurora-libros/tests/note.txt:/config/note.txt:ro alpine:3.20 \
  sh -c 'cat /config/note.txt; echo "trying to write" >> /config/note.txt || echo "BLOCKED"'
Content from the host
Written from the container
sh: can't create /config/note.txt: Read-only file system
BLOCKED

The kernel prevents it, not Docker. It is the same mechanism you used with index.html and nginx.conf in the previous lesson. Use it whenever you mount:

  • Configuration files (nginx.conf, redis.conf, postgresql.conf).
  • Initialization scripts (init.sql).
  • Certificates and public keys.
  • Static content the application must not modify.

  1. tmpfs: data in memory

A tmpfs mount creates a file system in the host's RAM. Nothing touches disk and everything disappears when the container stops:

docker run --rm --mount type=tmpfs,dst=/sensitive,tmpfs-size=16m alpine:3.20 \
  sh -c 'echo "temp-token-abc123" > /sensitive/token; df -h /sensitive; cat /sensitive/token'
Filesystem                Size      Used Available Use% Mounted on
tmpfs                    16.0M      4.0K     16.0M   0% /sensitive
temp-token-abc123
Option What for
tmpfs-size Maximum size. With no limit, it can fill the host's RAM
tmpfs-mode The directory's permissions, e.g. 0700

Real use cases: secrets decrypted at runtime, session files, temporary directories for applications running with a read-only root file system, and any data that by regulation must not be written to disk. It only works on Linux.

  1. Hands-on: aurora-data and the destruction test

It is time to fix aurora-db once and for all.

The plan

Path in the container Mount type Source Why
/var/lib/postgresql/data Volume aurora-data Managed by Docker It is production data: it must survive the container
/docker-entrypoint-initdb.d/init.sql Bind mount :ro ~/aurora-libros/db/init.sql It is your file, versioned in Git, which the container only reads

The second row uses a convention of the official PostgreSQL image: everything in /docker-entrypoint-initdb.d/ (.sql, .sql.gz or .sh files) is run automatically, in alphabetical order, the first time the database is initialized. It is exactly the job you have been doing by hand with docker cp for two lessons.

Recreating aurora-db properly

docker rm -f aurora-db
chmod 644 ~/aurora-libros/db/init.sql

docker run -d \
  --name aurora-db \
  --network aurora-net \
  --label project=aurora-libros --label component=database \
  -e POSTGRES_USER=aurora \
  -e POSTGRES_PASSWORD=aurora_secret \
  -e POSTGRES_DB=aurora_books \
  -p 127.0.0.1:5432:5432 \
  --mount type=volume,src=aurora-data,dst=/var/lib/postgresql/data \
  --mount type=bind,src=$HOME/aurora-libros/db/init.sql,dst=/docker-entrypoint-initdb.d/init.sql,readonly \
  postgres:16-alpine

The chmod 644 is not decorative: the PostgreSQL process runs as the postgres user (UID 70), and if the file were readable only by your user, the script could not run. It is the UID clash from section 5, applied.

Check what happened at startup:

sleep 8
docker logs aurora-db 2>&1 | grep -A2 "initdb.d"
docker exec aurora-db psql -U aurora -d aurora_books -t -c "SELECT COUNT(*) FROM books;"
/usr/local/bin/docker-entrypoint.sh: running /docker-entrypoint-initdb.d/init.sql
CREATE TABLE
CREATE INDEX
INSERT 0 8
     8

The eight books loaded themselves. No docker cp, no psql -f, nothing to remember.

Verify the mounts:

docker inspect -f '{{range .Mounts}}{{.Type}} | {{if .Name}}{{.Name}}{{else}}{{.Source}}{{end}} → {{.Destination}} | rw={{.RW}}{{println}}{{end}}' aurora-db
volume | aurora-data → /var/lib/postgresql/data | rw=true
bind | /home/junior/aurora-libros/db/init.sql → /docker-entrypoint-initdb.d/init.sql | rw=false

A writable volume for the data and a read-only bind mount for the script. Exactly the plan.

The destruction test

Add El Aleph again:

docker exec aurora-db psql -U aurora -d aurora_books -c \
  "INSERT INTO books (title, author, isbn, price) VALUES
   ('El Aleph', 'Jorge Luis Borges', '978-84-206-3311-4', 15.20);"
curl -s http://localhost:3000/books | jq -r '.books | length'
INSERT 0 1
9

And now, destroy the container. Without fear:

docker stop --time 30 aurora-db
docker rm aurora-db
docker ps -a --filter name=aurora-db -q
docker volume ls --filter name=aurora-data
aurora-db
aurora-db

DRIVER    VOLUME NAME
local     aurora-data

The container no longer exists. The volume is still there. Recreate it with exactly the same command as before:

docker run -d \
  --name aurora-db --network aurora-net \
  --label project=aurora-libros --label component=database \
  -e POSTGRES_USER=aurora -e POSTGRES_PASSWORD=aurora_secret -e POSTGRES_DB=aurora_books \
  -p 127.0.0.1:5432:5432 \
  --mount type=volume,src=aurora-data,dst=/var/lib/postgresql/data \
  --mount type=bind,src=$HOME/aurora-libros/db/init.sql,dst=/docker-entrypoint-initdb.d/init.sql,readonly \
  postgres:16-alpine

sleep 6
docker exec aurora-db psql -U aurora -d aurora_books -c \
  "SELECT id, title FROM books WHERE title = 'El Aleph';"
curl -s http://localhost:3000/books | jq -r '.books | length'
 id |  title
----+----------
  9 | El Aleph
(1 row)

9

El Aleph is still there. You have completely deleted the database container and the catalog has survived intact, with its nine books. Compare with section 1 of this very lesson, where the same experiment destroyed even the table.

And there is a detail in the logs worth a look:

docker logs aurora-db 2>&1 | grep -c "initdb.d"
docker logs aurora-db 2>&1 | head -2
0
PostgreSQL Database directory appears to contain a database; Skipping initialization

init.sql did not run again. The image detects that the data directory already contains a database and skips initialization. That is exactly what you want: if it ran every time, the CREATE TABLE IF NOT EXISTS would do no harm, but a less careful script could wipe real data on every start. And the practical consequence: if you change init.sql, restarting will not be enough; you will have to delete the volume for it to initialize from scratch again.

# This is how you start from scratch, when you really do want to lose the data
docker rm -f aurora-db && docker volume rm aurora-data

(Do not run it now: you need it for the next lesson.)

aurora-web, already mounted

The Nginx container you created in the previous lesson already uses bind mounts; now you can read that command with new eyes:

docker rm -f aurora-web
docker run -d \
  --name aurora-web --network aurora-net \
  --label project=aurora-libros --label component=web \
  -p 8080:80 \
  --mount type=bind,src=$HOME/aurora-libros/web/index.html,dst=/usr/share/nginx/html/index.html,readonly \
  --mount type=bind,src=$HOME/aurora-libros/web/nginx.conf,dst=/etc/nginx/conf.d/default.conf,readonly \
  --stop-signal SIGQUIT \
  nginx:alpine

curl -s http://localhost:8080/api/books | jq -r '.books | length'
9

Read-only bind mounts for two files that live in your Git repository. Edit index.html in your editor, reload the browser, and the change is there without rebuilding any image: that is the development workflow that becomes the star of lesson 04-07.

  1. Backup and restore

A volume that is not backed up is a volume that will be lost. There are two strategies and they serve different purposes.

File-level backup with a helper container

The universal pattern: an ephemeral container that mounts the volume and a host directory, and runs tar.

mkdir -p ~/aurora-libros/backups

# 1) Stop the database so the backup is CONSISTENT
docker stop --time 30 aurora-db

# 2) Package the volume
docker run --rm \
  --mount type=volume,src=aurora-data,dst=/data,readonly \
  --mount type=bind,src=$HOME/aurora-libros/backups,dst=/backup \
  alpine:3.20 \
  tar czf /backup/aurora-data-2026-08-04.tar.gz -C /data .

# 3) Start it again
docker start aurora-db
ls -lh ~/aurora-libros/backups/
aurora-db
-rw-r--r-- 1 junior junior 8.4M Aug  4 22:41 aurora-data-2026-08-04.tar.gz

What each part does:

Fragment Reason
docker stop before copying PostgreSQL keeps data buffered. Copying it hot can give you a corrupt backup
--rm The helper container destroys itself
readonly on the source volume The copying process cannot damage the original
-C /data . Packages the contents, not the directory, so restoring is direct
alpine:3.20 8 MB with tar inside; nothing else is needed

Restoring is the reverse operation:

docker stop aurora-db
docker run --rm \
  --mount type=volume,src=aurora-data,dst=/data \
  --mount type=bind,src=$HOME/aurora-libros/backups,dst=/backup,readonly \
  alpine:3.20 \
  sh -c 'rm -rf /data/* /data/..?* && tar xzf /backup/aurora-data-2026-08-04.tar.gz -C /data'
docker start aurora-db
sleep 6
docker exec aurora-db psql -U aurora -d aurora_books -t -c "SELECT COUNT(*) FROM books;"
     9

The ..?* in the rm is there to include hidden files; without it, leftovers from the previous installation would end up mixed in with the backup.

Logical backup with pg_dump

For a database, a logical backup is almost always better: a SQL file with the schema and the data.

docker exec aurora-db pg_dump -U aurora -d aurora_books --clean --if-exists \
  > ~/aurora-libros/backups/aurora_books-2026-08-04.sql
ls -lh ~/aurora-libros/backups/*.sql
head -20 ~/aurora-libros/backups/aurora_books-2026-08-04.sql | tail -4
-rw-r--r-- 1 junior junior 4.2K Aug  4 22:45 aurora_books-2026-08-04.sql
DROP TABLE IF EXISTS public.books;
CREATE TABLE public.books (
    id integer NOT NULL,

And restoring it, with -i so that psql reads from standard input:

docker exec -i aurora-db psql -U aurora -d aurora_books \
  < ~/aurora-libros/backups/aurora_books-2026-08-04.sql
docker exec aurora-db psql -U aurora -d aurora_books -t -c "SELECT COUNT(*) FROM books;"
     9

A comparison of the two strategies:

tar backup of the volume Logical backup with pg_dump
What it copies Every byte of the data directory SQL statements that rebuild the data
Requires stopping the service Yes, for it to be consistent No, pg_dump is transactional
Size Large (8.4 MB here) Small (4.2 kB)
Portable between PostgreSQL versions No: the data format is specific to the major version Yes
Readable and inspectable No Yes, it is text
Works for any volume Yes, it is generic No, databases only
When to use it Volumes of applications with no tool of their own Databases, whenever possible

The rule: use the service's native tool if it has one (pg_dump, mysqldump, redis-cli BGSAVE), and the container-with-tar trick for everything else.

  1. Best practices

Practice Reason
One named volume per piece of state that matters aurora-data, aurora-uploads, aurora-backups. Never anonymous ones for real data
Descriptive names with a project prefix aurora-data says what it is; data says nothing on a machine with twenty volumes
Label your volumes --label project=aurora-libros enables a selective prune
Bind mounts only for configuration and development They depend on host paths: they are not portable
Anything the container must not write, in :ro Configuration, scripts, certificates, static content
Prefer --mount in scripts It fails loudly if the source does not exist, instead of creating an empty directory
Never write inside /var/lib/docker/volumes It is the daemon's territory, and on Docker Desktop it is not even on your machine
Back up before any prune docker volume prune has no undo
Test the restore, not just the backup A backup that has never been restored is not a backup, it is a hope

The current state of Aurora Libros's storage:

docker volume ls --format "table {{.Name}}\t{{.Driver}}"
docker system df --format "table {{.Type}}\t{{.TotalCount}}\t{{.Size}}\t{{.Reclaimable}}"
NAME          DRIVER
aurora-data   local

TYPE            TOTAL     SIZE      RECLAIMABLE
Images          11        1.021GB   612.4MB (59%)
Containers      4         3.87MB    0B (0%)
Local Volumes   1         48.2MB    0B (0%)
Build Cache     52        1.847GB   1.847GB (100%)

A single named volume, and 0 B reclaimable: not one orphan. That is the goal.

What this lesson does not cover and where you will see it: the daemon's storage drivers (overlay2 and friends), the volume drivers for NFS, SMB or cloud storage, and the advanced performance options are studied in lesson 05-02. How volumes are declared in a compose.yaml file, in lesson 04-02.

Common Mistakes and Tips

  • Trusting the writable layer for data that matters. It dies with the container, and recreating a container is a routine operation.
  • Using anonymous volumes without knowing it. Every image with VOLUME in its Dockerfile creates one. If you do not give it a name, your data depends on a hash nobody remembers.
  • Running docker volume prune without looking. It deletes real data with no second confirmation and no recycle bin. List first with docker volume ls -f dangling=true.
  • Writing by hand in /var/lib/docker/volumes/.... Wrong permissions, corruption, and on Docker Desktop that path does not even exist.
  • Relative paths in a bind mount. Use absolute paths or $(pwd); with -v, a misspelled path turns into an empty directory created as root.
  • Mounting an empty directory over one that had content. The mount hides what came from the image: Nginx will return 403 and PostgreSQL will reinitialize.
  • Expecting init.sql to run on every start. It only runs when the data directory is empty. If you change it, delete the volume.
  • Backing up a database volume hot with tar. It can come out corrupt. Stop the container, or use pg_dump.
  • Tip: write the docker run for your services with data in a notes file. It is long and you have to reproduce it exactly so as not to lose the volume... until module 4, where that is solved for good.
  • Tip: name your backups with the date and automate their rotation. And restore at least once, so you know it works.

Exercises

Exercise 1: check the three kinds of persistence

Create an alpine:3.20 container called three-mounts with sleep 600 and three mounts at once: a named volume test-vol at /vol, a bind mount of ~/aurora-libros/tests at /bind, and an 8 MB tmpfs at /mem. Write a different file in each path. Then:

  1. Restart the container with docker restart and check which of the three files remain.
  2. Delete it with docker rm -f (without -v), create a new one with the same mounts and check all three again.
  3. Explain the results and say which mount you would use for: the book catalog, the nginx.conf, and a session token that must not touch disk.

Exercise 2: migrate aurora-cache to a named volume

Redis stores its snapshot in /data, which the official image declares as a VOLUME and which is therefore an anonymous volume today. Fix that:

  1. Check with docker inspect that the current aurora-cache uses an anonymous volume.
  2. Recreate it with a named volume aurora-cache-data and with persistence enabled via the command redis-server --appendonly yes.
  3. Write a key, force a save, destroy the container and recreate it. Check whether the key survives.
  4. Answer: does it make sense to persist a cache? Argue for and against.

Exercise 3: simulate a disaster and recover

With the platform working and the nine books in place:

  1. Make a logical backup with pg_dump and a physical one with tar, noting the size of each.
  2. Cause the disaster: delete the aurora-db container and the aurora-data volume.
  3. Check what the API answers at that moment.
  4. Recover by the fastest route and explain which one you chose and why. Verify that the nine books —including El Aleph, which is not in init.sql— are available again through curl http://localhost:8080/api/books.

Solutions

Solution to exercise 1

docker volume create test-vol
docker run -d --name three-mounts \
  --mount type=volume,src=test-vol,dst=/vol \
  --mount type=bind,src=$HOME/aurora-libros/tests,dst=/bind \
  --mount type=tmpfs,dst=/mem,tmpfs-size=8m \
  alpine:3.20 sleep 600

docker exec three-mounts sh -c 'echo volume > /vol/f.txt; echo bind > /bind/f.txt; echo memory > /mem/f.txt'
docker exec three-mounts sh -c 'cat /vol/f.txt /bind/f.txt /mem/f.txt'
volume
bind
memory

1. After docker restart:

docker restart three-mounts && sleep 1
docker exec three-mounts sh -c 'cat /vol/f.txt 2>&1; cat /bind/f.txt 2>&1; cat /mem/f.txt 2>&1'
volume
bind
cat: can't open '/mem/f.txt': No such file or directory

The tmpfs has already been lost with a simple restart: it lived in RAM and the mount is recreated empty on every start.

2. After deleting and recreating:

docker rm -f three-mounts
docker run -d --name three-mounts \
  --mount type=volume,src=test-vol,dst=/vol \
  --mount type=bind,src=$HOME/aurora-libros/tests,dst=/bind \
  --mount type=tmpfs,dst=/mem,tmpfs-size=8m \
  alpine:3.20 sleep 600
docker exec three-mounts sh -c 'cat /vol/f.txt 2>&1; cat /bind/f.txt 2>&1; cat /mem/f.txt 2>&1'
docker rm -f three-mounts && docker volume rm test-vol
volume
bind
cat: can't open '/mem/f.txt': No such file or directory

3. Results and decisions:

Mount Survives restart? Survives rm + recreate? Why
Volume test-vol Yes Yes It is an object independent of the container, managed by Docker
Bind ~/aurora-libros/tests Yes Yes It is a directory on your machine; Docker only shows it
tmpfs /mem No No It lives in RAM and is discarded with the process

And the three decisions: the book catalog → a named volume (production data that must survive and whose permissions Docker manages); the nginx.conf → a read-only bind mount (a file versioned in Git that you want to edit in your IDE and that the container only reads); the session token → tmpfs (it must not be written to disk even for an instant, and it must evaporate when the container stops).

Solution to exercise 2

docker inspect -f '{{range .Mounts}}{{.Type}} | name={{.Name}} | {{.Destination}}{{end}}' aurora-cache
volume | name=a97c2f1e8b45d306... | /data

Anonymous volume confirmed: a 64-character name, created by the VOLUME /data in Redis's official Dockerfile.

docker volume create aurora-cache-data
docker rm -f aurora-cache
docker run -d --name aurora-cache --network aurora-net \
  --label project=aurora-libros --label component=cache \
  --mount type=volume,src=aurora-cache-data,dst=/data \
  redis:7-alpine redis-server --appendonly yes
docker exec aurora-cache redis-cli SET book:featured "Rayuela"
docker exec aurora-cache redis-cli BGREWRITEAOF
sleep 2
docker exec aurora-cache ls /data
docker rm -f aurora-cache
docker run -d --name aurora-cache --network aurora-net \
  --label project=aurora-libros --label component=cache \
  --mount type=volume,src=aurora-cache-data,dst=/data \
  redis:7-alpine redis-server --appendonly yes
sleep 2
docker exec aurora-cache redis-cli GET book:featured
OK
Background append only file rewriting started
appendonlydir  dump.rdb
aurora-cache
"Rayuela"

The key survives. Note that two things were needed: the named volume (so the files persist) and --appendonly yes (so Redis writes to them). A volume with persistence disabled would store a file that never gets updated.

4. Does it make sense to persist a cache?

In favor:

  • A warm start. After a restart, the cache already has data and there is no avalanche of requests against PostgreSQL (the phenomenon known as the thundering herd).
  • If Redis is also used for user sessions or job queues, then it is no longer a cache: it is data whose loss has visible consequences.

Against:

  • A cache, by definition, must be losable without consequences. If your system does not work without it, it is not a cache: it is a database in disguise, and it should be treated as one.
  • Persistence costs: disk I/O on every write and a slower startup.
  • Persisted data can become stale: on recovery, Redis serves an old catalog until the TTL expires.

For Aurora Libros, with a 60-second TTL and a small catalog, it is not worth it: it is cleaner to leave aurora-cache without persistence and let it refill itself on the first request. That is the criterion that will be applied in module 4's compose.yaml.

docker rm -f aurora-cache && docker volume rm aurora-cache-data
docker run -d --name aurora-cache --network aurora-net \
  --label project=aurora-libros --label component=cache redis:7-alpine

Solution to exercise 3

1. The two backups:

mkdir -p ~/aurora-libros/backups
docker exec aurora-db pg_dump -U aurora -d aurora_books --clean --if-exists \
  > ~/aurora-libros/backups/disaster.sql

docker stop --time 30 aurora-db
docker run --rm \
  --mount type=volume,src=aurora-data,dst=/data,readonly \
  --mount type=bind,src=$HOME/aurora-libros/backups,dst=/backup \
  alpine:3.20 tar czf /backup/disaster.tar.gz -C /data .
docker start aurora-db
ls -lh ~/aurora-libros/backups/disaster.*
-rw-r--r-- 1 junior junior 4.3K Aug  4 23:02 disaster.sql
-rw-r--r-- 1 junior junior 8.4M Aug  4 23:03 disaster.tar.gz

The logical one is two thousand times smaller than the physical one, for exactly the same nine books.

2 and 3. The disaster:

docker rm -f aurora-db
docker volume rm aurora-data
curl -s http://localhost:8080/api/books | jq -c
{"error":"Could not fetch the catalog","detail":"getaddrinfo ENOTFOUND aurora-db"}

Interesting: the error is not a database one, but a DNS one. Since the container does not exist, the name aurora-db does not resolve on aurora-net. It is the same ENOTFOUND from lesson 03-04, now with a different cause: there the network was missing, here the container is.

4. The recovery:

# Recreate the container: the volume is created empty and init.sql runs by itself
docker run -d --name aurora-db --network aurora-net \
  --label project=aurora-libros --label component=database \
  -e POSTGRES_USER=aurora -e POSTGRES_PASSWORD=aurora_secret -e POSTGRES_DB=aurora_books \
  -p 127.0.0.1:5432:5432 \
  --mount type=volume,src=aurora-data,dst=/var/lib/postgresql/data \
  --mount type=bind,src=$HOME/aurora-libros/db/init.sql,dst=/docker-entrypoint-initdb.d/init.sql,readonly \
  postgres:16-alpine
sleep 8
docker exec aurora-db psql -U aurora -d aurora_books -t -c "SELECT COUNT(*) FROM books;"
     8

Eight, not nine. init.sql restored the original catalog, but El Aleph is not there: it was inserted afterwards. This is where the backup stops being a formality and becomes the only thing that saves you:

docker exec -i aurora-db psql -U aurora -d aurora_books < ~/aurora-libros/backups/disaster.sql
docker exec aurora-db psql -U aurora -d aurora_books -t -c "SELECT COUNT(*) FROM books;"
curl -s http://localhost:8080/api/books | jq -r '.books[] | select(.title=="El Aleph") | .title'
     9
El Aleph

I chose the logical backup, for three reasons:

  1. It is faster to apply: a 4 kB psql versus stopping the service, emptying the volume and unpacking 8.4 MB.
  2. It requires stopping nothing: it restores onto the running database, while the physical backup forces you to stop the container.
  3. It is portable: if while recovering I had taken the chance to move up to PostgreSQL 17, the volume's tar would have been useless —the data directory's format is specific to each major version— and the .sql would not.

The physical backup still has its role: it is the only option for volumes of applications that have no dump tool of their own, and it is a bit-for-bit copy including configuration and state that pg_dump does not capture.

Conclusion

The second big problem is solved. You have seen with your own eyes that a destroyed container takes its data with it —the first time not even the books table was left— and you know why: the writable layer is born and dies with the container, and the anonymous volumes that images like postgres or redis create on their own are not reused, but left orphaned with their data inside, unreachable in practice.

You know the three types of mount and when to use each one: Docker-managed volumes for data that matters, bind mounts for configuration and development, and tmpfs for whatever must not touch disk. You know where volumes really live —/var/lib/docker/volumes/…, the daemon's territory, invisible on Docker Desktop— and that the correct way to look inside is an ephemeral Alpine container, never your editor. You understand bind mounts' UID clash, because the kernel compares numbers and not names, and you have the three ways of resolving it. And you prefer --mount over -v in scripts for one very specific reason: it fails loudly instead of creating an empty directory that silently ruins the startup.

Aurora Libros has changed category. aurora-db keeps its state in the named volume aurora-data and receives init.sql as a read-only bind mount at /docker-entrypoint-initdb.d/, so the catalog loads itself the first time and is never overwritten again. The definitive test came out as it should: you inserted El Aleph, destroyed the entire container, recreated it with the same command and the nine books were still there. And aurora-web serves index.html and nginx.conf from your repository in :ro, editable from your IDE without rebuilding anything. On top of that you know how to back up and restore: with a helper container and tar for any volume, and with pg_dump —smaller, hot and portable between versions— for the database.

Two risks remain standing, and they are the last of this module. You saw the first in docker stats in lesson 03-04: none of your containers has a memory limit, so a runaway query in PostgreSQL or a leak in Node can leave the whole machine out of RAM. The second is that if a container dies at three in the morning, nobody brings it back up. In the next lesson, Resource Limits and Restart Policies, you will close in on both: memory and CPU limits with their tables of combinations, the OOM killer triggered on purpose so you can see it with OOMKilled: true and its code 137, --pids-limit as a defense against a fork bomb, and the four restart policies with the subtle difference between always and unless-stopped. And by the end, you will have in front of you the complete list of commands needed to bring up Aurora Libros from scratch... and you will understand why Docker Compose exists.

Docker: From Beginner to Advanced

Module 1: Introduction to Docker

Module 2: Working with Docker Images

Module 3: Docker Containers

Module 4: Docker Compose

Module 5: Advanced Docker Concepts

Module 6: Docker in Production

Module 7: Docker Ecosystem and Tools

© Copyright 2026. All rights reserved