La plataforma funciona. curl http://localhost:3000/libros devuelve los ocho títulos y la web carga en el navegador. Pero hay una grieta en los cimientos que ya has tropezado dos veces sin darle importancia: cada vez que recreas aurora-db, el catálogo desaparece y tienes que volver a cargar init.sql a mano con docker cp. En una máquina de desarrollo eso es una molestia. En el servidor donde vive el catálogo real de una librería, es una catástrofe.

Esta lección resuelve el segundo gran problema de los contenedores, después de la red: los datos. Vas a demostrar la pérdida con un experimento que da un poco de miedo, vas a conocer los tres tipos de montaje que ofrece Docker y cuándo usar cada uno, vas a entender dónde viven de verdad los volúmenes y por qué no debes tocarlos a mano, y vas a dar a PostgreSQL un volumen con nombre para que el catálogo de Aurora Libros sobreviva a la destrucción de su contenedor. Al final repetirás el experimento del principio y el resultado será el contrario.

Contenido

  1. El problema, demostrado
  2. Los tres tipos de montaje
  3. Volúmenes gestionados por Docker
  4. Volúmenes anónimos y el problema de los huérfanos
  5. Bind mounts: ficheros de tu máquina dentro del contenedor
  6. -v frente a --mount
  7. Montajes de solo lectura
  8. tmpfs: datos en memoria
  9. Práctica: aurora-datos y la prueba de destrucción
  10. Copias de seguridad y restauración
  11. Buenas prácticas

  1. El problema, demostrado

Empecemos por mirar el desastre a la cara. Añade un libro nuevo al catálogo:

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

Nueve libros. La API los sirve. Ahora simula lo que ocurre en cualquier operación normal: actualizar la imagen de PostgreSQL, cambiar una variable de entorno, añadir un límite de memoria... todo eso obliga a recrear el contenedor, como sabes desde la lección 03-01.

docker rm -f aurora-db

docker run -d --name aurora-db --network aurora-net \
  --label proyecto=aurora-libros --label componente=base-de-datos \
  -e POSTGRES_USER=aurora -e POSTGRES_PASSWORD=aurora_secreta -e POSTGRES_DB=aurora_libros \
  -p 127.0.0.1:5432:5432 \
  postgres:16-alpine

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

No es que falte El Aleph: es que ya no existe la tabla. Todo el catálogo de Aurora Libros se ha evaporado. Y la API, coherente:

curl -s http://localhost:3000/libros | jq -c
{"error":"No se pudo obtener el catálogo","detalle":"relation \"libros\" does not exist"}

Esto no es un fallo de Docker: es su diseño, y ya lo conocías desde la lección 01-05. Un contenedor escribe en una capa de escritura que nace con él y muere con él. Las nueve filas estaban en un volumen anónimo que Docker creó automáticamente —lo descubriste en el ejercicio 2 de la lección 03-03— y que al recrear el contenedor fue sustituido por otro nuevo y vacío.

flowchart TB
    subgraph SIN["Sin volumen: los datos mueren con el contenedor"]
        direction TB
        S1["docker run postgres"] --> S2["Capa de escritura<br/>+ volumen anónimo nuevo"]
        S2 --> S3["INSERT ... 9 libros"]
        S3 --> S4["docker rm -f"]
        S4 --> S5["Todo destruido<br/>o huérfano e inalcanzable"]
    end
    subgraph CON["Con volumen con nombre: los datos son independientes"]
        direction TB
        V1["docker volume create aurora-datos"] --> V2["docker run -v aurora-datos:/var/lib/postgresql/data"]
        V2 --> V3["INSERT ... 9 libros"]
        V3 --> V4["docker rm -f"]
        V4 --> V5["El volumen SIGUE ahí"]
        V5 --> V6["docker run ... mismo volumen<br/>→ los 9 libros vuelven"]
    end

La solución es sacar los datos del contenedor. Vamos a ello.

  1. Los tres tipos de montaje

Docker ofrece tres formas de que una ruta dentro del contenedor apunte a algo que no está en su capa de escritura:

Volumen Bind mount tmpfs
Dónde viven los datos En un área gestionada por Docker (/var/lib/docker/volumes/) En una ruta que tú eliges de tu máquina En la memoria RAM del host
Quién lo gestiona Docker El kernel
¿Sobrevive a docker rm? Sí (es tu fichero) No, se evapora
¿Sobrevive a reiniciar el host? No
Portabilidad entre máquinas Alta (mismo comando en todas partes) Baja (depende de las rutas del host) Alta
Rendimiento en Docker Desktop Bueno Peor (cruza la frontera con la VM) El mejor
Copias de seguridad Con docker run + tar, o docker volume Con las herramientas del host No aplica
Caso de uso natural Datos de producción: bases de datos, subidas de usuarios Desarrollo y ficheros de configuración Secretos y ficheros temporales sensibles
Sintaxis --mount type=volume type=bind type=tmpfs
flowchart LR
    subgraph HOST["Host"]
        VOL["/var/lib/docker/volumes/aurora-datos/_data<br/>(gestionado por Docker)"]
        DIR["~/aurora-libros/db/init.sql<br/>(fichero tuyo)"]
        RAM["Memoria RAM"]
    end
    subgraph CONT["Contenedor aurora-db"]
        M1["/var/lib/postgresql/data"]
        M2["/docker-entrypoint-initdb.d/init.sql"]
        M3["/tmp/sensible"]
    end
    VOL -- "volumen" --> M1
    DIR -- "bind mount :ro" --> M2
    RAM -- "tmpfs" --> M3

La regla de decisión, en una frase: si los datos importan y los gestiona la aplicación, un volumen; si el fichero es tuyo y quieres editarlo desde tu editor, un bind mount; si no debe tocar el disco jamás, tmpfs.

  1. Volúmenes gestionados por Docker

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

Ese segundo nombre ilegible es un volumen anónimo huérfano, resto de alguno de los aurora-db que has borrado. Volveremos a él.

Comando Qué hace
docker volume create NOMBRE Crea un volumen
docker volume ls Lista todos
docker volume ls -f dangling=true Solo los que no usa ningún contenedor
docker volume inspect NOMBRE Muestra su ruta real y sus metadatos
docker volume rm NOMBRE Lo borra (falla si está en uso)
docker volume prune Borra todos los volúmenes sin usar

¿Dónde viven realmente?

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

Mountpoint es la ruta en el host. Y aquí va el aviso más importante del apartado:

No entres ahí a tocar ficheros. Esa ruta es propiedad del demonio de Docker: pertenece a root, sus permisos y su contexto de seguridad los gestiona Docker, y en Docker Desktop (macOS y Windows) ni siquiera existe en tu máquina, sino dentro de una máquina virtual Linux a la que no tienes acceso directo. Escribir ahí a mano es una forma excelente de corromper una base de datos.

La forma correcta de mirar dentro de un volumen es con un contenedor:

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

Vacío, recién creado. Este patrón —un contenedor efímero de Alpine que monta el volumen— es la navaja suiza para inspeccionar, copiar y restaurar volúmenes, y lo usarás en el apartado 10.

Etiquetas en volúmenes

docker volume create --label proyecto=aurora-libros aurora-copias
docker volume ls --filter label=proyecto=aurora-libros
aurora-copias
DRIVER    VOLUME NAME
local     aurora-copias

Igual que con los contenedores, etiquetar permite limpiar de forma selectiva:

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

  1. Volúmenes anónimos y el problema de los huérfanos

Un volumen anónimo es el que Docker crea por su cuenta, sin que se lo pidas, en dos situaciones:

  1. Cuando la imagen declara VOLUME /ruta en su Dockerfile (lo hacen postgres, redis, mysql, mongo…).
  2. Cuando montas con -v /ruta indicando solo el destino, sin origen.
docker run -d --name demo-anonimo -v /datos alpine:3.20 sleep 60
docker inspect -f '{{range .Mounts}}{{.Type}} | {{.Name}} | {{.Destination}}{{end}}' demo-anonimo
volume | e83f1c7a92b40d651fe8a3c9d7b2f4e6a0c8b1d5f3a7c9e1b8d6f4a2c0e8b6d4 | /datos

Ese nombre de 64 caracteres es el volumen anónimo. Y aquí está su problema:

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

El volumen sigue ahí, huérfano. Sin contenedor que lo use, con un nombre que nadie puede recordar, y ocupando disco indefinidamente. Cada postgres que has borrado en este módulo dejó atrás uno de estos, con sus datos dentro, irrecuperables en la práctica.

Volumen anónimo Volumen con nombre
Cómo se crea Automáticamente (VOLUME del Dockerfile o -v /destino) docker volume create o -v nombre:/destino
Nombre Un hash de 64 caracteres El que tú elijas
Se reutiliza al recrear el contenedor No, se crea otro vacío , es el mismo
Lo borra docker rm -v No
Lo borra docker volume prune si está huérfano Sí si está huérfano
Recomendación Evitarlos para datos que importen Siempre para datos que importen

Limpia los huérfanos ahora, comprobando antes qué te vas a llevar por delante:

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

Total reclaimed space: 47.83MB

Cuidado con este comando: docker volume prune borra datos de verdad y no hay papelera. Un volumen "sin usar" puede ser el de una base de datos cuyo contenedor está simplemente parado. Desde Docker 23, prune solo toca los anónimos por defecto; para incluir los que tienen nombre hace falta --all, lo cual es una red de seguridad que conviene no desactivar a la ligera.

  1. Bind mounts: ficheros de tu máquina dentro del contenedor

Un bind mount conecta una ruta concreta de tu host con una ruta del contenedor. Ya lo usaste en la lección 01-06 con tu index.html.

mkdir -p ~/aurora-libros/pruebas
echo "Contenido desde el host" > ~/aurora-libros/pruebas/nota.txt

docker run --rm -v ~/aurora-libros/pruebas:/datos alpine:3.20 cat /datos/nota.txt
docker run --rm -v ~/aurora-libros/pruebas:/datos alpine:3.20 \
  sh -c 'echo "Escrito desde el contenedor" >> /datos/nota.txt'
cat ~/aurora-libros/pruebas/nota.txt
Contenido desde el host
Contenido desde el host
Escrito desde el contenedor

Es el mismo fichero visto desde dos sitios: los cambios van en las dos direcciones y en tiempo real. Ese es su superpoder para desarrollo —editas en tu IDE y el contenedor lo ve al instante— y su peligro en producción.

Reglas y trampas:

Regla Detalle
Ruta absoluta obligatoria -v ./web:/html no funcionaba en versiones antiguas. Usa -v "$(pwd)/web":/html o ~/... (el shell expande la virgulilla)
Si la ruta del host no existe, Docker la crea Y la crea como directorio vacío y propiedad de root. Es la causa del clásico "monté mi fichero y aparece una carpeta"
El montaje oculta lo que hubiera en el destino Montar un directorio vacío sobre /usr/share/nginx/html deja Nginx sirviendo un 403
Los permisos son los del host Y ahí empieza el problema de los UID

El choque de UID

Este es el dolor de cabeza clásico de los bind mounts:

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

En este caso funciona por casualidad: tu usuario del host tiene UID 1000 y el usuario node de la imagen también. Pero prueba con un usuario distinto:

docker run --rm -u 1500 -v ~/aurora-libros/pruebas:/datos alpine:3.20 \
  sh -c 'touch /datos/prueba.txt || echo "SIN PERMISO"'
touch: /datos/prueba.txt: Permission denied
SIN PERMISO

El kernel compara números, no nombres. Dentro del contenedor no existe ningún "usuario del host": solo hay UID y GID, y los permisos del fichero son los que tenga en tu máquina. Las tres soluciones habituales:

Solución Cómo Cuándo
Ajustar el UID del contenedor -u $(id -u):$(id -g) Desarrollo, la más práctica
Ajustar los permisos en el host chmod 644 fichero / chown Ficheros de configuración de solo lectura
Usar un volumen en vez de un bind mount -v aurora-datos:/ruta Datos de producción: Docker gestiona los permisos

Y de ahí sale una regla que conviene memorizar: los bind mounts son para desarrollo y para ficheros de configuración; los datos de producción van en volúmenes.

  1. -v frente a --mount

Docker ofrece dos sintaxis para lo mismo:

# Sintaxis -v: compacta, posicional, ambigua
docker run -v aurora-datos:/var/lib/postgresql/data postgres:16-alpine

# Sintaxis --mount: verbosa, explícita, con clave=valor
docker run --mount type=volume,source=aurora-datos,target=/var/lib/postgresql/data postgres:16-alpine
-v / --volume --mount
Legibilidad Compacta pero críptica Verbosa y autoexplicativa
Si el origen no existe Lo crea (un directorio vacío en bind mounts) Falla con error
Tipo de montaje Se deduce de la sintaxis Explícito con type=
Opciones avanzadas (tmpfs, propagación, drivers) Limitadas Completas
Uso en Docker Compose y Swarm Soportada La forma nativa
Recomendación Correcta para ejemplos y comandos rápidos Preferida, sobre todo en scripts

Ese "si el origen no existe" es la diferencia práctica más importante. Con -v, un error de tecleo en la ruta te deja un directorio vacío y un contenedor que arranca sin sus datos; con --mount, obtienes un error claro y el contenedor no arranca.

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

Tabla de equivalencias para tenerla a mano:

Objetivo Con -v Con --mount
Volumen con nombre -v datos:/var/lib/data --mount type=volume,src=datos,dst=/var/lib/data
Volumen anónimo -v /var/lib/data --mount type=volume,dst=/var/lib/data
Bind mount -v /host/ruta:/cont/ruta --mount type=bind,src=/host/ruta,dst=/cont/ruta
Bind mount de solo lectura -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 y source son sinónimos, igual que dst, destination y target.

  1. Montajes de solo lectura

Un fichero de configuración no tiene por qué ser escribible por el contenedor. Marcarlo como solo lectura es una defensa barata y eficaz:

docker run --rm -v ~/aurora-libros/pruebas/nota.txt:/config/nota.txt:ro alpine:3.20 \
  sh -c 'cat /config/nota.txt; echo "intento escribir" >> /config/nota.txt || echo "BLOQUEADO"'
Contenido desde el host
Escrito desde el contenedor
sh: can't create /config/nota.txt: Read-only file system
BLOQUEADO

El kernel lo impide, no Docker. Es el mismo mecanismo que usaste con index.html y nginx.conf en la lección anterior. Úsalo siempre que montes:

  • Ficheros de configuración (nginx.conf, redis.conf, postgresql.conf).
  • Scripts de inicialización (init.sql).
  • Certificados y claves públicas.
  • Contenido estático que la aplicación no debe modificar.

  1. tmpfs: datos en memoria

Un montaje tmpfs crea un sistema de ficheros en la RAM del host. Nada toca el disco y todo desaparece al parar el contenedor:

docker run --rm --mount type=tmpfs,dst=/sensible,tmpfs-size=16m alpine:3.20 \
  sh -c 'echo "token-temporal-abc123" > /sensible/token; df -h /sensible; cat /sensible/token'
Filesystem                Size      Used Available Use% Mounted on
tmpfs                    16.0M      4.0K     16.0M   0% /sensible
token-temporal-abc123
Opción Para qué
tmpfs-size Tamaño máximo. Sin límite, puede llenar la RAM del host
tmpfs-mode Permisos del directorio, p. ej. 0700

Casos de uso reales: secretos desencriptados en tiempo de ejecución, ficheros de sesión, directorios temporales de aplicaciones que se ejecutan con el sistema de ficheros raíz en solo lectura, y cualquier dato que por normativa no deba quedar escrito en disco. Solo funciona en Linux.

  1. Práctica: aurora-datos y la prueba de destrucción

Es el momento de arreglar aurora-db de una vez.

El plan

Ruta en el contenedor Tipo de montaje Origen Por qué
/var/lib/postgresql/data Volumen aurora-datos Gestionado por Docker Son datos de producción: deben sobrevivir al contenedor
/docker-entrypoint-initdb.d/init.sql Bind mount :ro ~/aurora-libros/db/init.sql Es un fichero tuyo, versionado en Git, que el contenedor solo lee

La segunda fila usa una convención de la imagen oficial de PostgreSQL: todo lo que haya en /docker-entrypoint-initdb.d/ (ficheros .sql, .sql.gz o .sh) se ejecuta automáticamente, en orden alfabético, la primera vez que se inicializa la base de datos. Es exactamente el trabajo que llevas dos lecciones haciendo a mano con docker cp.

Recrear aurora-db como debe ser

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

docker run -d \
  --name aurora-db \
  --network aurora-net \
  --label proyecto=aurora-libros --label componente=base-de-datos \
  -e POSTGRES_USER=aurora \
  -e POSTGRES_PASSWORD=aurora_secreta \
  -e POSTGRES_DB=aurora_libros \
  -p 127.0.0.1:5432:5432 \
  --mount type=volume,src=aurora-datos,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

El chmod 644 no es decorativo: el proceso de PostgreSQL corre como el usuario postgres (UID 70), y si el fichero solo fuera legible por tu usuario, el script no podría ejecutarse. Es el choque de UID del apartado 5, aplicado.

Comprueba qué ha pasado en el arranque:

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

Los ocho libros se han cargado solos. Sin docker cp, sin psql -f, sin acordarse de nada.

Verifica los montajes:

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

Un volumen escribible para los datos y un bind mount de solo lectura para el script. Exactamente el plan.

La prueba de destrucción

Añade otra vez El Aleph:

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

Y ahora, destruye el contenedor. Sin miedo:

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

DRIVER    VOLUME NAME
local     aurora-datos

El contenedor ya no existe. El volumen sigue ahí. Recréalo con exactamente el mismo comando de antes:

docker run -d \
  --name aurora-db --network aurora-net \
  --label proyecto=aurora-libros --label componente=base-de-datos \
  -e POSTGRES_USER=aurora -e POSTGRES_PASSWORD=aurora_secreta -e POSTGRES_DB=aurora_libros \
  -p 127.0.0.1:5432:5432 \
  --mount type=volume,src=aurora-datos,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_libros -c \
  "SELECT id, titulo FROM libros WHERE titulo = 'El Aleph';"
curl -s http://localhost:3000/libros | jq -r '.libros | length'
 id |  titulo
----+----------
  9 | El Aleph
(1 row)

9

El Aleph sigue ahí. Has borrado el contenedor de la base de datos por completo y el catálogo ha sobrevivido intacto, con sus nueve libros. Compara con el apartado 1 de esta misma lección, donde el mismo experimento destruyó hasta la tabla.

Y hay un detalle en los logs que merece una mirada:

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

El init.sql no se ha vuelto a ejecutar. La imagen detecta que el directorio de datos ya contiene una base de datos y se salta la inicialización. Eso es exactamente lo que quieres: si se ejecutara siempre, el CREATE TABLE IF NOT EXISTS no haría daño, pero un script menos cuidadoso podría borrar datos reales en cada arranque. Y la consecuencia práctica: si cambias init.sql, no bastará con reiniciar; tendrás que borrar el volumen para que vuelva a inicializarse desde cero.

# Así se empieza de cero, cuando de verdad quieres perder los datos
docker rm -f aurora-db && docker volume rm aurora-datos

(No lo ejecutes ahora: lo necesitas para la siguiente lección.)

aurora-web, ya montado

El contenedor de Nginx que creaste en la lección anterior ya usa bind mounts; ahora puedes leer aquel comando con otros ojos:

docker rm -f aurora-web
docker run -d \
  --name aurora-web --network aurora-net \
  --label proyecto=aurora-libros --label componente=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/libros | jq -r '.libros | length'
9

Bind mounts de solo lectura para dos ficheros que viven en tu repositorio de Git. Edita index.html en tu editor, recarga el navegador, y el cambio está ahí sin reconstruir ninguna imagen: ese es el flujo de desarrollo que se convierte en el protagonista de la lección 04-07.

  1. Copias de seguridad y restauración

Un volumen que no se copia es un volumen que se perderá. Hay dos estrategias y sirven para cosas distintas.

Copia a nivel de ficheros con un contenedor auxiliar

El patrón universal: un contenedor efímero que monta el volumen y un directorio del host, y ejecuta tar.

mkdir -p ~/aurora-libros/copias

# 1) Parar la base de datos para que la copia sea COHERENTE
docker stop --time 30 aurora-db

# 2) Empaquetar el volumen
docker run --rm \
  --mount type=volume,src=aurora-datos,dst=/datos,readonly \
  --mount type=bind,src=$HOME/aurora-libros/copias,dst=/copia \
  alpine:3.20 \
  tar czf /copia/aurora-datos-2026-08-04.tar.gz -C /datos .

# 3) Volver a arrancar
docker start aurora-db
ls -lh ~/aurora-libros/copias/
aurora-db
-rw-r--r-- 1 junior junior 8.4M Aug  4 22:41 aurora-datos-2026-08-04.tar.gz

Qué hace cada parte:

Fragmento Motivo
docker stop antes de copiar PostgreSQL mantiene datos en búfer. Copiar en caliente puede dar una copia corrupta
--rm El contenedor auxiliar se autodestruye
readonly en el volumen de origen El proceso de copia no puede estropear el original
-C /datos . Empaqueta el contenido, no el directorio, para que la restauración sea directa
alpine:3.20 8 MB con tar dentro; no hace falta nada más

La restauración es la operación inversa:

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

El ..?* del rm es para incluir los ficheros ocultos; sin él quedarían restos de la instalación anterior mezclados con la copia.

Copia lógica con pg_dump

Para una base de datos, casi siempre es mejor una copia lógica: un fichero SQL con el esquema y los datos.

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

Y la restauración, con -i para que psql lea de la entrada estándar:

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

Comparación de las dos estrategias:

Copia con tar del volumen Copia lógica con pg_dump
Qué copia Todos los bytes del directorio de datos Sentencias SQL que reconstruyen los datos
Requiere parar el servicio , para que sea coherente No, pg_dump es transaccional
Tamaño Grande (8,4 MB aquí) Pequeño (4,2 kB)
Portable entre versiones de PostgreSQL No: el formato de datos es específico de la versión mayor
Legible e inspeccionable No , es texto
Sirve para cualquier volumen , es genérico No, solo bases de datos
Cuándo usarla Volúmenes de aplicaciones sin herramienta propia Bases de datos, siempre que sea posible

La regla: usa la herramienta nativa del servicio si la tiene (pg_dump, mysqldump, redis-cli BGSAVE), y el truco del contenedor con tar para todo lo demás.

  1. Buenas prácticas

Práctica Motivo
Un volumen con nombre por cada estado que importe aurora-datos, aurora-subidas, aurora-copias. Nunca anónimos para datos reales
Nombres descriptivos con prefijo de proyecto aurora-datos dice qué es; data no dice nada en una máquina con veinte volúmenes
Etiqueta los volúmenes --label proyecto=aurora-libros permite prune selectivo
Bind mounts solo para configuración y desarrollo Dependen de rutas del host: no son portables
Todo lo que el contenedor no deba escribir, en :ro Configuración, scripts, certificados, contenido estático
Prefiere --mount en scripts Falla ruidosamente si el origen no existe, en vez de crear un directorio vacío
Nunca escribas dentro de /var/lib/docker/volumes Es territorio del demonio, y en Docker Desktop ni siquiera está en tu máquina
Copia de seguridad antes de cualquier prune docker volume prune no tiene deshacer
Prueba la restauración, no solo la copia Una copia que nunca se ha restaurado no es una copia, es una esperanza

Estado actual del almacenamiento de Aurora Libros:

docker volume ls --format "table {{.Name}}\t{{.Driver}}"
docker system df --format "table {{.Type}}\t{{.TotalCount}}\t{{.Size}}\t{{.Reclaimable}}"
NAME           DRIVER
aurora-datos   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%)

Un solo volumen, con nombre, y 0 B recuperables: ni un huérfano. Ese es el objetivo.

Lo que esta lección no cubre y dónde se ve: los storage drivers del demonio (overlay2 y compañía), los drivers de volumen para NFS, SMB o almacenamiento en la nube, y las opciones avanzadas de rendimiento se estudian en la lección 05-02. Cómo se declaran los volúmenes en un fichero compose.yaml, en la lección 04-02.

Errores Comunes y Consejos

  • Confiar en la capa de escritura para datos que importan. Muere con el contenedor, y recrear un contenedor es una operación rutinaria.
  • Usar volúmenes anónimos sin saberlo. Toda imagen con VOLUME en su Dockerfile crea uno. Si no le pones nombre, tus datos dependen de un hash que nadie recuerda.
  • Ejecutar docker volume prune sin mirar. Borra datos reales sin confirmación por segunda vez y sin papelera. Lista antes con docker volume ls -f dangling=true.
  • Escribir a mano en /var/lib/docker/volumes/.... Permisos incorrectos, corrupción, y en Docker Desktop ni siquiera existe esa ruta.
  • Rutas relativas en un bind mount. Usa rutas absolutas o $(pwd); con -v, una ruta mal escrita se convierte en un directorio vacío creado como root.
  • Montar un directorio vacío sobre uno que tenía contenido. El montaje oculta lo de la imagen: Nginx devolverá 403 y PostgreSQL se reinicializará.
  • Esperar que init.sql se ejecute en cada arranque. Solo corre cuando el directorio de datos está vacío. Si lo cambias, borra el volumen.
  • Copiar un volumen de base de datos en caliente con tar. Puede salir corrupto. Para el contenedor, o usa pg_dump.
  • Consejo: escribe el docker run de tus servicios con datos en un fichero de notas. Es largo y hay que reproducirlo exactamente para no perder el volumen... hasta el módulo 4, donde eso se resuelve del todo.
  • Consejo: nombra las copias con la fecha y automatiza su rotación. Y restaura al menos una vez, para saber que funciona.

Ejercicios

Ejercicio 1: comprueba las tres persistencias

Crea un contenedor de alpine:3.20 llamado tres-montajes con sleep 600 y tres montajes a la vez: un volumen con nombre prueba-vol en /vol, un bind mount de ~/aurora-libros/pruebas en /bind, y un tmpfs de 8 MB en /mem. Escribe un fichero distinto en cada ruta. Después:

  1. Reinicia el contenedor con docker restart y comprueba cuáles de los tres ficheros siguen.
  2. Bórralo con docker rm -f (sin -v), crea uno nuevo con los mismos montajes y comprueba de nuevo los tres.
  3. Explica los resultados y di qué montaje usarías para: el catálogo de libros, el nginx.conf, y un token de sesión que no debe tocar el disco.

Ejercicio 2: migra aurora-cache a un volumen con nombre

Redis guarda su instantánea en /data, que la imagen oficial declara como VOLUME y por tanto hoy es un volumen anónimo. Arregla eso:

  1. Comprueba con docker inspect que el aurora-cache actual usa un volumen anónimo.
  2. Recréalo con un volumen con nombre aurora-cache-datos y activando la persistencia con el comando redis-server --appendonly yes.
  3. Escribe una clave, fuerza un guardado, destruye el contenedor y recréalo. Comprueba si la clave sobrevive.
  4. Responde: ¿tiene sentido persistir una caché? Argumenta a favor y en contra.

Ejercicio 3: simula un desastre y recupérate

Con la plataforma funcionando y los nueve libros en su sitio:

  1. Haz una copia de seguridad lógica con pg_dump y otra física con tar, anotando el tamaño de cada una.
  2. Provoca el desastre: borra el contenedor aurora-db y el volumen aurora-datos.
  3. Comprueba qué responde la API en ese momento.
  4. Recupérate por el camino más rápido y explica cuál elegiste y por qué. Verifica que los nueve libros —incluido El Aleph, que no está en init.sql— vuelven a estar disponibles a través de curl http://localhost:8080/api/libros.

Soluciones

Solución al ejercicio 1

docker volume create prueba-vol
docker run -d --name tres-montajes \
  --mount type=volume,src=prueba-vol,dst=/vol \
  --mount type=bind,src=$HOME/aurora-libros/pruebas,dst=/bind \
  --mount type=tmpfs,dst=/mem,tmpfs-size=8m \
  alpine:3.20 sleep 600

docker exec tres-montajes sh -c 'echo volumen > /vol/f.txt; echo bind > /bind/f.txt; echo memoria > /mem/f.txt'
docker exec tres-montajes sh -c 'cat /vol/f.txt /bind/f.txt /mem/f.txt'
volumen
bind
memoria

1. Tras docker restart:

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

El tmpfs ya se ha perdido con el simple reinicio: vivía en RAM y el montaje se recrea vacío en cada arranque.

2. Tras borrar y recrear:

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

3. Resultados y decisiones:

Montaje ¿Sobrevive a restart? ¿Sobrevive a rm + recrear? Por qué
Volumen prueba-vol Es un objeto independiente del contenedor, gestionado por Docker
Bind ~/aurora-libros/pruebas Es un directorio de tu máquina; Docker solo lo enseña
tmpfs /mem No No Vive en RAM y se descarta con el proceso

Y las tres decisiones: el catálogo de libros → volumen con nombre (dato de producción que debe sobrevivir y del que Docker gestiona los permisos); el nginx.conf → bind mount de solo lectura (fichero versionado en Git que quieres editar en tu IDE y que el contenedor solo lee); el token de sesión → tmpfs (no debe quedar escrito en disco ni siquiera un instante, y debe evaporarse al parar el contenedor).

Solución al ejercicio 2

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

Volumen anónimo confirmado: nombre de 64 caracteres, creado por el VOLUME /data del Dockerfile oficial de Redis.

docker volume create aurora-cache-datos
docker rm -f aurora-cache
docker run -d --name aurora-cache --network aurora-net \
  --label proyecto=aurora-libros --label componente=cache \
  --mount type=volume,src=aurora-cache-datos,dst=/data \
  redis:7-alpine redis-server --appendonly yes
docker exec aurora-cache redis-cli SET libro:destacado "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 proyecto=aurora-libros --label componente=cache \
  --mount type=volume,src=aurora-cache-datos,dst=/data \
  redis:7-alpine redis-server --appendonly yes
sleep 2
docker exec aurora-cache redis-cli GET libro:destacado
OK
Background append only file rewriting started
appendonlydir  dump.rdb
aurora-cache
"Rayuela"

La clave sobrevive. Nota que hicieron falta dos cosas: el volumen con nombre (para que los ficheros persistan) y --appendonly yes (para que Redis escriba en ellos). Un volumen sin persistencia activada guardaría un fichero que nunca se actualiza.

4. ¿Tiene sentido persistir una caché?

A favor:

  • Arranque en caliente. Tras un reinicio, la caché ya tiene datos y no hay una avalancha de peticiones contra PostgreSQL (el fenómeno conocido como thundering herd).
  • Si Redis se usa también para sesiones de usuario o colas de trabajo, ahí ya no es una caché: son datos que perder tiene consecuencias visibles.

En contra:

  • Una caché, por definición, debe poder perderse sin consecuencias. Si tu sistema no funciona sin ella, no es una caché: es una base de datos disfrazada, y debería tratarse como tal.
  • La persistencia cuesta: E/S de disco en cada escritura y un arranque más lento.
  • Los datos persistidos pueden quedar obsoletos: al recuperarse, Redis sirve un catálogo antiguo hasta que expire el TTL.

Para Aurora Libros, con un TTL de 60 segundos y un catálogo pequeño, no compensa: es más limpio dejar aurora-cache sin persistencia y que se rellene sola en la primera petición. Ese es el criterio que se aplicará en el compose.yaml del módulo 4.

docker rm -f aurora-cache && docker volume rm aurora-cache-datos
docker run -d --name aurora-cache --network aurora-net \
  --label proyecto=aurora-libros --label componente=cache redis:7-alpine

Solución al ejercicio 3

1. Las dos copias:

mkdir -p ~/aurora-libros/copias
docker exec aurora-db pg_dump -U aurora -d aurora_libros --clean --if-exists \
  > ~/aurora-libros/copias/desastre.sql

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

Dos mil veces más pequeña la lógica que la física, para exactamente los mismos nueve libros.

2 y 3. El desastre:

docker rm -f aurora-db
docker volume rm aurora-datos
curl -s http://localhost:8080/api/libros | jq -c
{"error":"No se pudo obtener el catálogo","detalle":"getaddrinfo ENOTFOUND aurora-db"}

Interesante: el error no es de base de datos, sino de DNS. Al no existir el contenedor, el nombre aurora-db no se resuelve en aurora-net. Es el mismo ENOTFOUND de la lección 03-04, ahora con una causa distinta: allí faltaba la red, aquí falta el contenedor.

4. La recuperación:

# Recrear el contenedor: el volumen se crea vacío y init.sql se ejecuta solo
docker run -d --name aurora-db --network aurora-net \
  --label proyecto=aurora-libros --label componente=base-de-datos \
  -e POSTGRES_USER=aurora -e POSTGRES_PASSWORD=aurora_secreta -e POSTGRES_DB=aurora_libros \
  -p 127.0.0.1:5432:5432 \
  --mount type=volume,src=aurora-datos,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_libros -t -c "SELECT COUNT(*) FROM libros;"
     8

Ocho, no nueve. init.sql restauró el catálogo original, pero El Aleph no está ahí: se insertó después. Aquí es donde la copia de seguridad deja de ser un trámite y pasa a ser lo único que te salva:

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

Elegí la copia lógica, y por tres motivos:

  1. Es más rápida de aplicar: un psql de 4 kB frente a parar el servicio, vaciar el volumen y desempaquetar 8,4 MB.
  2. No exige parar nada: se restaura sobre la base de datos en marcha, mientras la copia física obliga a detener el contenedor.
  3. Es portable: si al recuperarme hubiera aprovechado para subir a PostgreSQL 17, el tar del volumen no habría servido —el formato del directorio de datos es específico de cada versión mayor— y el .sql sí.

La copia física sigue teniendo su papel: es la única opción para volúmenes de aplicaciones que no tienen una herramienta de volcado propia, y es una copia bit a bit que incluye configuración y estado que pg_dump no recoge.

Conclusión

El segundo gran problema está resuelto. Has visto con tus propios ojos que un contenedor destruido se lleva sus datos —la primera vez ni siquiera quedaba la tabla libros— y sabes por qué: la capa de escritura nace y muere con el contenedor, y los volúmenes anónimos que crean por su cuenta imágenes como postgres o redis no se reutilizan, sino que quedan huérfanos con sus datos dentro, inalcanzables en la práctica.

Conoces los tres tipos de montaje y cuándo usar cada uno: volúmenes gestionados por Docker para los datos que importan, bind mounts para configuración y desarrollo, y tmpfs para lo que no debe tocar el disco. Sabes dónde viven realmente los volúmenes —/var/lib/docker/volumes/…, territorio del demonio, invisible en Docker Desktop— y que la forma correcta de mirar dentro es un contenedor efímero de Alpine, nunca tu editor. Entiendes el choque de UID de los bind mounts, porque el kernel compara números y no nombres, y tienes las tres formas de resolverlo. Y prefieres --mount a -v en scripts por una razón muy concreta: falla ruidosamente en vez de crear un directorio vacío que arruina el arranque en silencio.

Aurora Libros ha cambiado de categoría. aurora-db guarda su estado en el volumen con nombre aurora-datos y recibe init.sql como bind mount de solo lectura en /docker-entrypoint-initdb.d/, así que el catálogo se carga solo la primera vez y nunca más se pisa. La prueba definitiva salió como debía: insertaste El Aleph, destruiste el contenedor entero, lo recreaste con el mismo comando y los nueve libros seguían ahí. Y aurora-web sirve index.html y nginx.conf desde tu repositorio en :ro, editables desde tu IDE sin reconstruir nada. Además sabes copiar y restaurar: con un contenedor auxiliar y tar para cualquier volumen, y con pg_dump —más pequeña, en caliente y portable entre versiones— para la base de datos.

Quedan dos riesgos en pie, y son los últimos de este módulo. El primero lo viste en docker stats en la lección 03-04: ninguno de tus contenedores tiene límite de memoria, así que una consulta desbocada en PostgreSQL o una fuga en Node pueden dejar sin RAM a toda la máquina. El segundo es que si un contenedor muere de madrugada, nadie lo levanta. En la siguiente lección, Límites de Recursos y Políticas de Reinicio, pondrás cerco a ambos: límites de memoria y CPU con sus tablas de combinaciones, el OOM killer provocado a propósito para verlo con OOMKilled: true y su código 137, --pids-limit como defensa ante una fork bomb, y las cuatro políticas de reinicio con la diferencia sutil entre always y unless-stopped. Y al terminar, tendrás delante la lista completa de comandos que hace falta para levantar Aurora Libros desde cero... y entenderás por qué existe Docker Compose.

Docker: De Principiante a Avanzado

Módulo 1: Introducción a Docker

Módulo 2: Trabajando con Imágenes Docker

Módulo 3: Contenedores Docker

Módulo 4: Docker Compose

Módulo 5: Conceptos Avanzados de Docker

Módulo 6: Docker en Producción

Módulo 7: Ecosistema y Herramientas de Docker

© Copyright 2026. Todos los derechos reservados