En la lección anterior aprendiste de dónde vienen las imágenes: un registro, un repositorio, una etiqueta que apunta a un digest. Y reservaste la dirección donde vivirá la tuya, auroralibros/aurora-api. Ahora toca fabricarla. Esta lección trata del proceso de construcción en sí: qué hace docker build desde que pulsas Enter, qué es ese punto final que todo el mundo copia sin saber qué significa, por qué una carpeta con node_modules dentro puede convertir una build de dos segundos en una de dos minutos, y cómo la caché de capas de BuildKit premia o castiga el orden en que escribes las instrucciones. No vas a estudiar todavía el Dockerfile instrucción por instrucción —eso es la lección siguiente—, sino la máquina que lo interpreta. Al terminar tendrás auroralibros/aurora-api:0.1.0 construida, corriendo en un contenedor y respondiendo a un curl.

Contenido

  1. Qué hace docker build realmente
  2. Anatomía del comando y el punto final
  3. El contexto de construcción
  4. El fichero .dockerignore
  5. Dockerfiles con otro nombre o ruta: la opción -f
  6. La caché de capas de BuildKit
  7. Dependencias antes que código: ineficiente frente a eficiente
  8. --no-cache y --pull: cuándo desconfiar de la caché
  9. Leer la salida de BuildKit
  10. De cero a imagen: auroralibros/aurora-api:0.1.0

  1. Qué hace docker build realmente

Recuerda la arquitectura cliente-servidor de la lección 01-03: el cliente docker no hace el trabajo, se lo pide al demonio. La construcción de imágenes sigue ese mismo esquema, con un actor más. Desde Docker Engine 23, el constructor por defecto es BuildKit, un motor de construcción independiente y mucho más capaz que el clásico.

sequenceDiagram
    participant U as Tú (terminal)
    participant C as Cliente docker
    participant D as dockerd
    participant B as BuildKit
    participant R as Registro

    U->>C: docker build -t aurora-api:0.1.0 .
    C->>C: Lee el Dockerfile y el .dockerignore
    C->>D: Envía el contexto (ficheros no ignorados)
    D->>B: Solicita la construcción
    B->>B: Analiza el Dockerfile y calcula el grafo de pasos
    B->>R: ¿Tengo node:22-alpine? Si no, la descarga
    R-->>B: Capas de la imagen base
    loop Por cada instrucción
        B->>B: ¿Hay caché válida para este paso?
        alt Hay caché
            B-->>B: CACHED (reutiliza la capa)
        else No hay caché
            B->>B: Ejecuta el paso y crea una capa nueva
        end
    end
    B->>D: Exporta las capas y el manifiesto
    D->>D: Guarda la imagen y le pone la etiqueta
    D-->>C: Build completada
    C-->>U: naming to docker.io/auroralibros/aurora-api:0.1.0

Tres consecuencias prácticas de este diagrama que conviene interiorizar antes de seguir:

  1. La build no ocurre en tu directorio de trabajo. Ocurre en el constructor, que puede estar en otra máquina. Por eso hay que enviarle los ficheros: es el contexto del apartado 3.
  2. BuildKit construye un grafo, no una lista. No ejecuta las instrucciones ciegamente de arriba abajo: calcula qué pasos dependen de cuáles y salta los que ya tiene resueltos. De ahí la potencia de la caché.
  3. La imagen resultante se queda en tu almacén local. docker build no publica nada en ningún registro; eso es docker push, y llega en la lección 02-06.

docker build y docker buildx build

Te encontrarás las dos formas y conviene aclararlas ya:

Comando Qué es
docker build El comando clásico. Desde Engine 23 delega en BuildKit de forma transparente
docker buildx build La interfaz completa de BuildKit, con opciones adicionales (multiplataforma, exportadores, constructores remotos)

Para todo lo de este módulo son equivalentes: docker build es más corto y es lo que usarás. Las capacidades exclusivas de buildx —construir para varias arquitecturas a la vez, montajes de caché, secretos de build— se ven en la lección 05-05. Puedes comprobar qué constructor estás usando:

docker buildx ls
NAME/NODE       DRIVER/ENDPOINT   STATUS    BUILDKIT   PLATFORMS
default*        docker
 \_ default      \_ default       running   v0.19.0    linux/amd64, linux/386

El asterisco marca el constructor activo. BUILDKIT v0.19.0 confirma que BuildKit está en marcha; si esa columna estuviera vacía, estarías con el constructor antiguo y no verías nada de lo que se describe en esta lección.

  1. Anatomía del comando y el punto final

La forma canónica es:

docker build -t auroralibros/aurora-api:0.1.0 .

Desmenuzada:

Parte Qué es Detalle
docker build El comando Construye una imagen
-t auroralibros/aurora-api:0.1.0 --tag: nombre y etiqueta de la imagen resultante Se puede repetir para dar varios nombres a la misma build
. El contexto de construcción El directorio cuyo contenido se envía al constructor

El punto final es la parte que más confusión genera. No significa "construye aquí" ni "usa el Dockerfile de este directorio", aunque por defecto produzca ambos efectos. Significa: el contexto de construcción es el directorio actual. Es decir, "envía al constructor todo lo que hay en . (menos lo ignorado), porque las instrucciones COPY van a buscar sus ficheros ahí dentro".

Se ve mejor con variantes:

# Contexto = directorio actual; Dockerfile = ./Dockerfile
docker build -t aurora-api:0.1.0 .

# Contexto = ./api ; Dockerfile = ./api/Dockerfile
docker build -t aurora-api:0.1.0 ./api

# Contexto = directorio padre; Dockerfile = ../Dockerfile
docker build -t aurora-api:0.1.0 ..

# Contexto = un repositorio Git remoto, clonado por el constructor
docker build -t aurora-api:0.1.0 https://github.com/auroralibros/aurora-libros.git#main:api

La última forma es reveladora: el contexto ni siquiera tiene que estar en tu máquina. BuildKit puede clonar un repositorio y construir desde ahí. Eso demuestra que el contexto es un concepto abstracto ("el conjunto de ficheros disponibles para la build"), no "la carpeta donde estás".

Otras opciones de docker build que usarás en este módulo:

Opción Para qué
-t, --tag Nombre y etiqueta; repetible
-f, --file Ruta a un Dockerfile con otro nombre o ubicación (apartado 5)
--no-cache Ignora la caché por completo (apartado 8)
--pull Fuerza a descargar la versión más reciente de la imagen base (apartado 8)
--progress=plain Salida completa, sin la interfaz dinámica (apartado 9)
--build-arg Pasa un argumento de construcción (lección 02-04)
--target Construye hasta una etapa concreta (multi-etapa, lección 05-04)

  1. El contexto de construcción

Antes de ejecutar ninguna instrucción, el cliente empaqueta el contexto y se lo envía al constructor. Esto es importante porque todo lo que hay en ese directorio viaja, lo uses o no en algún COPY.

Comprueba el tamaño de tu contexto antes de construir. Es un hábito que vale su peso en oro:

cd ~/aurora-libros/api
du -sh .
23M     .

Veintitrés megas para un server.js de 100 líneas y un package.json. ¿De dónde salen?

du -sh * .[!.]* 2>/dev/null | sort -h
4,0K    package.json
8,0K    server.js
44K     package-lock.json
23M     node_modules

Ahí está: node_modules, generado cuando hiciste npm install en la lección 01-07. Y esos 23 MB no aportan nada a la imagen, porque las dependencias se instalarán dentro de ella con npm ci.

Consecuencias de un contexto grande:

  • La build es más lenta, y el retraso está antes de la primera instrucción: el cliente debe leer, comprimir y transmitir todos esos ficheros. En un proyecto con .git de 500 MB, esto son varios segundos en cada build.
  • Riesgo de filtración. Un COPY . . descuidado mete en la imagen todo lo que haya en el contexto, incluidos ficheros .env, claves SSH o volcados de base de datos. Es un incidente de seguridad real, no teórico.
  • Rompe la caché. Como verás en el apartado 6, la caché de un COPY . . se invalida si cualquier fichero del contexto cambia. Con node_modules dentro, un npm install local invalida la build entera.

El error clásico: COPY fuera del contexto

Este error lo vas a cometer, así que mejor entenderlo ahora. Supón que desde ~/aurora-libros/api intentas copiar el init.sql que está en ~/aurora-libros/db/:

FROM node:22-alpine
WORKDIR /app
COPY ../db/init.sql ./
cd ~/aurora-libros/api
docker build -t prueba .
ERROR: failed to solve: failed to compute cache key: failed to calculate checksum of
ref ...: "/db/init.sql": not found

El mensaje despista porque habla de "not found", pero el fichero existe perfectamente en tu disco. Lo que ocurre es que no existe en el contexto: como el contexto es . (o sea, api/), el constructor solo ha recibido lo que hay dentro de api/, y ../db/ está fuera. Las rutas de origen de COPY son siempre relativas a la raíz del contexto y jamás pueden salir de él. Es una restricción de seguridad deliberada: si no fuera así, cualquier Dockerfile podría copiar /etc/shadow o ~/.ssh/id_rsa de la máquina que construye.

Las dos soluciones legítimas:

# a) Ampliar el contexto a la raíz del proyecto y apuntar al Dockerfile con -f
cd ~/aurora-libros
docker build -t prueba -f api/Dockerfile .

# b) Dejar el contexto en api/ y copiar el fichero ahí (si de verdad pertenece a la API)

Para Aurora Libros elegiremos la opción de contexto reducido: la imagen de la API no necesita init.sql, que es cosa del contenedor de PostgreSQL (módulo 3). Cada imagen lleva solo lo suyo.

  1. El fichero .dockerignore

En la lección 01-07 quedó anunciado: la imagen de la API tendrá un .dockerignore que excluya node_modules/, .git/ y .env. Es el momento de escribirlo.

.dockerignore es un fichero de texto en la raíz del contexto que le dice al cliente qué no debe enviar al constructor. Su sintaxis recuerda a .gitignore, pero tiene reglas propias:

Patrón Qué excluye
node_modules Cualquier fichero o carpeta con ese nombre en la raíz del contexto
**/node_modules Ese nombre en cualquier nivel de profundidad
*.log Todos los ficheros con extensión .log en la raíz
**/*.log Todos los .log en cualquier subdirectorio
.git El directorio de Git completo
temp? temp1, tempA… (? = un carácter cualquiera)
!importante.log Excepción: vuelve a incluir ese fichero aunque un patrón anterior lo excluyera
# comentario Línea ignorada

Dos diferencias con .gitignore que causan sorpresas:

  • node_modules no es recursivo por sí solo, a diferencia de Git. Si tienes subproyectos con sus propias dependencias, necesitas **/node_modules.
  • El orden importa cuando usas !: la última regla que coincide es la que manda.

Crea ~/aurora-libros/api/.dockerignore:

# Dependencias: se instalan dentro de la imagen con npm ci
node_modules
**/node_modules

# Control de versiones
.git
.gitignore

# Secretos y configuración local: JAMÁS dentro de una imagen
.env
.env.*

# Registros y temporales
*.log
npm-debug.log*
.npm
.cache
tmp/

# Metadatos del sistema operativo y del editor
.DS_Store
Thumbs.db
.vscode
.idea

# El propio Dockerfile y sus auxiliares: el constructor ya los tiene
Dockerfile
Dockerfile.*
.dockerignore

# Pruebas y documentación: no se ejecutan en producción
test/
*.test.js
README.md
NOTAS-ONBOARDING.md

Justificación de las entradas menos evidentes:

  • node_modules: la razón principal. Además del tamaño, copiar el node_modules de tu máquina puede meter binarios compilados para tu sistema operativo y arquitectura que no funcionen dentro de una imagen Alpine (es exactamente el aviso del ejercicio 3 de 01-07).
  • .env: la regla innegociable del proyecto. Las credenciales no entran en la imagen. Un .dockerignore que lo excluya es la primera línea de defensa frente a un COPY . . descuidado.
  • Dockerfile: no hace falta dentro de la imagen. Excluirlo tiene un efecto secundario muy útil: editar el Dockerfile ya no invalida la caché del COPY . ..
  • README.md, test/: no se ejecutan en producción, y cada byte que no entra es un byte que no se descarga en cada despliegue.

Medir el antes y el después

La forma limpia de comprobar el efecto es con --progress=plain, que muestra la línea de transferencia del contexto. Primero, sin .dockerignore:

cd ~/aurora-libros/api
mv .dockerignore .dockerignore.off
docker build --no-cache --progress=plain -t medicion:sin -f Dockerfile . 2>&1 | grep -i "transferring context"
#2 [internal] load build context
#2 transferring context: 23.41MB 1.8s done

Ahora con él:

mv .dockerignore.off .dockerignore
docker build --no-cache --progress=plain -t medicion:con -f Dockerfile . 2>&1 | grep -i "transferring context"
#2 [internal] load build context
#2 transferring context: 47.83kB 0.0s done

De 23,41 MB a 47,83 kB: una reducción de más del 99 %, y el tiempo de transferencia baja de 1,8 segundos a prácticamente cero. En un proyecto real con .git voluminoso, artefactos de compilación y capturas de pantalla, la diferencia se mide en cientos de megas y decenas de segundos en cada build.

  1. Dockerfiles con otro nombre o ruta: la opción -f

Por defecto, docker build busca un fichero llamado exactamente Dockerfile en la raíz del contexto. -f rompe ese acoplamiento:

# Un Dockerfile con otro nombre, en el mismo directorio
docker build -t aurora-api:dev -f Dockerfile.dev .

# Contexto en la raíz del proyecto, Dockerfile dentro de api/
cd ~/aurora-libros
docker build -t auroralibros/aurora-api:0.1.0 -f api/Dockerfile ./api

# Dockerfiles centralizados en una carpeta aparte
docker build -t aurora-web:0.1.0 -f docker/web.Dockerfile ./web

Puntos clave:

  • -f y el contexto son independientes. Puedes tener el Dockerfile en cualquier sitio y el contexto en otro. Lo que nunca cambia es que las rutas de COPY se resuelven contra el contexto, no contra la ubicación del Dockerfile. Es la confusión número uno con -f.
  • La ruta de -f sí es relativa a tu directorio actual, no al contexto.
  • El caso de uso más común es tener variantes: Dockerfile para producción y Dockerfile.dev con herramientas de desarrollo. Aurora Libros usará un único Dockerfile; las diferencias entre entornos se resolverán con Compose (lección 04-06), que es un enfoque más limpio.

  1. La caché de capas de BuildKit

Aquí está la diferencia entre esperar noventa segundos en cada cambio de código o esperar dos. Recuerda de la lección 01-05 que una imagen es una pila de capas. Durante la construcción, cada instrucción del Dockerfile que modifica el sistema de archivos produce una capa, y BuildKit intenta reutilizar las que ya tiene.

Cómo decide BuildKit si reutiliza una capa

Para cada instrucción, BuildKit calcula una clave de caché a partir de:

  1. La clave de la capa anterior (por eso el orden lo es todo).
  2. El texto literal de la instrucción. Cambiar un espacio o un comentario dentro de un RUN la invalida.
  3. Para COPY y ADD, además, el checksum del contenido de los ficheros copiados (no la fecha de modificación: si tocas un fichero sin cambiar su contenido, la caché aguanta).

Si esa clave existe en la caché local, marca el paso como CACHED y no ejecuta nada. Si no existe, ejecuta el paso y todos los posteriores, sin excepción.

La invalidación en cascada

Esta es la propiedad que gobierna el diseño de todo Dockerfile:

flowchart TB
    A["FROM node:22-alpine<br/>capa base"] --> B["WORKDIR /app"]
    B --> C["COPY package*.json ./"]
    C --> D["RUN npm ci<br/>⏱ 45 s"]
    D --> E["COPY . .<br/>tu código"]
    E --> F["CMD [node, server.js]"]

    style C fill:#f4f0fa
    style D fill:#f4f0fa
    style E fill:#ffe0e0
    style F fill:#ffe0e0

Si cambias una línea de server.js, se invalida el COPY . . (paso E) y, en cascada, todo lo que viene después. Pero los pasos A–D siguen cacheados, incluido el npm ci de 45 segundos. Resultado: la build tarda un par de segundos.

Ahora invierte el orden —copia el código antes de instalar dependencias— y el mismo cambio en server.js invalida el COPY . ., que ahora está antes del npm ci. Consecuencia: el npm ci se vuelve a ejecutar entero. Cuarenta y cinco segundos, en cada cambio de una coma.

De ahí la regla de oro: ordena las instrucciones de menos a más volátil. Lo que casi nunca cambia (la imagen base, la instalación de paquetes del sistema, las dependencias) va arriba; lo que cambias cincuenta veces al día (tu código) va abajo.

  1. Dependencias antes que código: ineficiente frente a eficiente

Vamos a medirlo, porque el número convence más que la explicación.

Versión INEFICIENTE

FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
CMD ["node", "server.js"]

Parece razonable: copio el proyecto y luego instalo. Y funciona. El problema es que COPY . . incluye server.js, así que cualquier cambio en el código invalida esa capa y, con ella, el npm ci.

Versión EFICIENTE

FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]

La diferencia es un COPY partido en dos. Primero se copian solo los manifiestos de dependencias, se instalan, y después se copia el resto del código. Como package.json y package-lock.json cambian rara vez, el npm ci queda cacheado casi siempre.

La medición

Guarda cada versión en su fichero y cronometra el ciclo realista: primera build en frío, y segunda build tras tocar el código.

cd ~/aurora-libros/api

# Primera build de cada variante (ambas en frío)
time docker build --no-cache -q -t ineficiente -f Dockerfile.ineficiente . > /dev/null
time docker build --no-cache -q -t eficiente   -f Dockerfile.eficiente   . > /dev/null

# Simula un cambio de código, que es lo que pasa cincuenta veces al día
echo "// ajuste menor" >> server.js

# Reconstruye ambas, ahora aprovechando la caché
time docker build -q -t ineficiente -f Dockerfile.ineficiente . > /dev/null
time docker build -q -t eficiente   -f Dockerfile.eficiente   . > /dev/null

Resultados típicos en una máquina de desarrollo:

Escenario Ineficiente Eficiente
Primera build (en frío) 48,3 s 49,1 s
Tras cambiar server.js 46,7 s 1,4 s
Tras cambiar package.json 47,9 s 48,5 s

Lee la tabla con atención, porque contiene los tres mensajes de la lección:

  • En frío son iguales (la eficiente incluso un pelín más lenta, por tener un COPY más). La caché no acelera la primera vez.
  • Tras un cambio de código, la diferencia es de 33×. Multiplícalo por cincuenta builds al día y por cada persona del equipo de Aurora Libros: son horas.
  • Tras cambiar las dependencias, vuelven a igualarse, y es lo correcto: si package.json cambia, hay que reinstalar. La caché no está mintiendo, está haciendo su trabajo.

Un matiz sobre npm ci que justifica su uso frente a npm install, y que en la lección 02-03 se retoma: npm ci borra node_modules e instala exactamente las versiones fijadas en package-lock.json, fallando si el lock y el package.json no concuerdan. npm install puede resolver versiones distintas según cuándo se ejecute, lo que rompe la reproducibilidad. En una imagen, siempre npm ci.

  1. --no-cache y --pull: cuándo desconfiar de la caché

La caché es una optimización basada en una suposición: que la misma instrucción con las mismas entradas produce el mismo resultado. A veces esa suposición es falsa.

# Ignora toda la caché: ejecuta cada instrucción desde cero
docker build --no-cache -t auroralibros/aurora-api:0.1.0 .

# Comprueba si hay una versión más reciente de la imagen base y la descarga
docker build --pull -t auroralibros/aurora-api:0.1.0 .

# Ambas: la build más reproducible posible
docker build --no-cache --pull -t auroralibros/aurora-api:0.1.0 .

Cuándo usar cada una:

Situación Opción Por qué
Build de release o de CI --pull (y a menudo --no-cache) Garantiza base actualizada y ausencia de estado heredado
apt-get install / apk add que trae paquetes viejos --no-cache La instrucción es idéntica, pero los repositorios remotos han cambiado
RUN git clone o curl de un recurso remoto --no-cache Igual: la instrucción no cambia, el contenido remoto sí
Sospechas de una caché corrupta o de un comportamiento inexplicable --no-cache Descarta la caché como variable del problema
Desarrollo diario Ninguna Estarías tirando a la basura la ventaja del apartado 7

El caso de --pull merece una explicación aparte, porque es sutil. Si tu Dockerfile dice FROM node:22-alpine y ya tienes esa etiqueta descargada, BuildKit la usa sin comprobar si ha cambiado en el registro. Pero 22-alpine es una etiqueta móvil (lección 02-01): Node publica parches y esa etiqueta pasa a apuntar a otro digest. Sin --pull, puedes estar construyendo durante meses sobre una base con vulnerabilidades ya corregidas aguas arriba. Por eso --pull es prácticamente obligatorio en los pipelines de construcción (lección 06-02).

Y un aviso: --no-cache no borra la caché existente, solo la ignora en esa build. Para liberar el espacio que ocupa realmente hay un comando específico, docker builder prune, que verás en la lección 02-05.

  1. Leer la salida de BuildKit

BuildKit imprime una interfaz dinámica que se reescribe en el sitio. Aprender a leerla es aprender a diagnosticar builds.

[+] Building 12.4s (11/11) FINISHED                              docker:default
 => [internal] load build definition from Dockerfile                       0.0s
 => => transferring dockerfile: 421B                                       0.0s
 => [internal] load metadata for docker.io/library/node:22-alpine          1.1s
 => [internal] load .dockerignore                                          0.0s
 => => transferring context: 583B                                          0.0s
 => [1/5] FROM docker.io/library/node:22-alpine@sha256:9f2c...             2.8s
 => => resolve docker.io/library/node:22-alpine@sha256:9f2c...             0.0s
 => => sha256:9f2c... 1.72kB / 1.72kB                                      0.0s
 => => extracting sha256:4a1b...                                           0.4s
 => [internal] load build context                                          0.0s
 => => transferring context: 47.83kB                                       0.0s
 => CACHED [2/5] WORKDIR /app                                              0.0s
 => [3/5] COPY package.json package-lock.json ./                           0.0s
 => [4/5] RUN npm ci --omit=dev && npm cache clean --force                 7.9s
 => [5/5] COPY . .                                                         0.0s
 => exporting to image                                                     0.5s
 => => exporting layers                                                    0.4s
 => => writing image sha256:6b4d...                                        0.0s
 => => naming to docker.io/auroralibros/aurora-api:0.1.0                   0.0s

Clave de lectura, línea a línea:

Elemento Significado
[+] Building 12.4s (11/11) FINISHED Tiempo total y pasos completados / totales
[internal] … Pasos internos: leer el Dockerfile, el .dockerignore, resolver metadatos de la base
transferring context: 47.83kB El tamaño de tu contexto. Es la línea que vigilas tras escribir el .dockerignore
[1/5], [2/5] Pasos del Dockerfile, numerados. Solo cuentan las instrucciones que generan capa
CACHED Paso reutilizado de la caché. Cero segundos. Lo que quieres ver
exporting layers Escritura de las capas nuevas en el almacén local
writing image sha256:… El ID de la imagen resultante
naming to … La etiqueta que le has puesto con -t

El diagnóstico se hace mirando dónde deja de aparecer CACHED. Ese es el punto de invalidación, y todo lo que hay debajo se ha reejecutado. Si el primer paso no cacheado es antes de lo que esperabas, tienes un problema de orden de instrucciones o un fichero volátil colándose en un COPY.

--progress=plain

La interfaz dinámica es cómoda pero oculta la salida de los comandos: no ves lo que imprime npm ci. Cuando algo falla, necesitas verlo todo:

docker build --progress=plain --no-cache -t auroralibros/aurora-api:0.1.0 . 2>&1 | tail -30
#8 [4/5] RUN npm ci --omit=dev && npm cache clean --force
#8 3.412 npm warn config production Use `--omit=dev` instead.
#8 7.108 added 112 packages, and audited 113 packages in 7s
#8 7.115 found 0 vulnerabilities
#8 7.883 npm warn using --force Recommended protections disabled.
#8 DONE 7.9s

El formato es #<paso> <segundos desde el inicio del paso> <línea de salida>. Esos tiempos relativos son oro puro para saber qué parte de un RUN largo es la lenta. Tres usos habituales de --progress=plain:

  • Ver por qué falla un RUN (el mensaje de error completo del comando).
  • Medir el tamaño del contexto, como hiciste en el apartado 4.
  • Guardar un registro completo en CI, donde la interfaz dinámica produce basura ilegible.

  1. De cero a imagen: auroralibros/aurora-api:0.1.0

Es el momento de juntarlo todo. Vas a construir la primera imagen de Aurora Libros con un Dockerfile mínimo, presentado entero. Aquí solo se explica por encima qué hace cada instrucción; el detalle completo, las alternativas y las decisiones finas son la lección 02-03.

Crea ~/aurora-libros/api/Dockerfile:

# syntax=docker/dockerfile:1

# Imagen base: Node.js 22 sobre Alpine Linux, tal como se decidió en 01-07
FROM node:22-alpine

# Directorio de trabajo dentro de la imagen; el resto de rutas son relativas a él
WORKDIR /app

# Primero SOLO los manifiestos de dependencias: así el npm ci queda cacheado
COPY package.json package-lock.json ./

# Instalación reproducible, sin dependencias de desarrollo, limpiando la caché de npm
RUN npm ci --omit=dev && npm cache clean --force

# Ahora sí, el código de la aplicación
COPY . .

# Configuración por defecto. Las credenciales NO van aquí (regla del proyecto)
ENV NODE_ENV=production
ENV PORT=3000

# Documenta que el servicio escucha en el 3000 (no publica nada por sí solo)
EXPOSE 3000

# Proceso que se ejecuta al arrancar el contenedor
CMD ["node", "server.js"]

Un vistazo rápido a cada instrucción, sin entrar en profundidad:

  • # syntax=docker/dockerfile:1: elige el intérprete de Dockerfile más reciente de la serie 1.
  • FROM: de qué imagen se parte.
  • WORKDIR: fija el directorio de trabajo dentro de la imagen y lo crea si no existe.
  • COPY: trae ficheros del contexto a la imagen. Está partido en dos por lo del apartado 7.
  • RUN: ejecuta un comando durante la construcción y congela el resultado en una capa.
  • ENV: define variables de entorno que persisten en ejecución.
  • EXPOSE: documentación. No publica el puerto; eso sigue siendo cosa de -p (lección 01-06).
  • CMD: qué proceso arranca el contenedor.

Si te preguntas por qué npm ci y no npm install, por qué esa forma de CMD con corchetes, o por qué EXPOSE no hace lo que su nombre sugiere: todo eso es exactamente el contenido de la lección 02-03.

Construir

cd ~/aurora-libros/api
docker build -t auroralibros/aurora-api:0.1.0 .
[+] Building 13.7s (11/11) FINISHED                              docker:default
 => [internal] load build definition from Dockerfile                       0.0s
 => [internal] load metadata for docker.io/library/node:22-alpine          1.2s
 => [internal] load .dockerignore                                          0.0s
 => [1/5] FROM docker.io/library/node:22-alpine@sha256:9f2c...             3.1s
 => [internal] load build context                                          0.0s
 => => transferring context: 47.83kB                                       0.0s
 => [2/5] WORKDIR /app                                                     0.1s
 => [3/5] COPY package.json package-lock.json ./                           0.0s
 => [4/5] RUN npm ci --omit=dev && npm cache clean --force                 8.4s
 => [5/5] COPY . .                                                         0.0s
 => exporting to image                                                     0.6s
 => => naming to docker.io/auroralibros/aurora-api:0.1.0                   0.0s

Comprueba el resultado:

docker image ls auroralibros/aurora-api
REPOSITORY                 TAG       IMAGE ID       CREATED          SIZE
auroralibros/aurora-api    0.1.0     6b4d2f8e1a3c   9 seconds ago    167MB

167 MB, de los cuales unos 142 son la base node:22-alpine que ya conocías. Tu aplicación y sus dependencias aportan unos 25 MB.

Reconstruir y ver la caché en acción

docker build -t auroralibros/aurora-api:0.1.0 .
[+] Building 0.4s (11/11) FINISHED
 => CACHED [2/5] WORKDIR /app                                              0.0s
 => CACHED [3/5] COPY package.json package-lock.json ./                    0.0s
 => CACHED [4/5] RUN npm ci --omit=dev && npm cache clean --force          0.0s
 => CACHED [5/5] COPY . .                                                  0.0s

Todo CACHED, 0,4 segundos. De 13,7 s a 0,4 s sin cambiar nada.

Ejecutar la imagen

docker run -d --name aurora-api-test -p 3000:3000 auroralibros/aurora-api:0.1.0
docker ps --filter name=aurora-api-test
CONTAINER ID   IMAGE                             STATUS         PORTS                    NAMES
d3f8a1c9b7e2   auroralibros/aurora-api:0.1.0     Up 4 seconds   0.0.0.0:3000->3000/tcp   aurora-api-test

Comandos ya conocidos de 01-06: -d en segundo plano, --name para poder referirte a él, -p 3000:3000 para publicar el puerto. Mira los registros:

docker logs aurora-api-test
[aurora-api] escuchando en el puerto 3000
[aurora-api] base de datos: localhost:5432/aurora_libros
[aurora-api] caché: localhost:6379

La API ha arrancado dentro de un contenedor, sin que hayas instalado Node en tu máquina. Pruébala:

curl -s http://localhost:3000/salud
{"servicio":"aurora-api","version":"1.0.0","db":"ko","cache":"ko",
 "errorDb":"connect ECONNREFUSED 127.0.0.1:5432",
 "errorCache":"connect ECONNREFUSED 127.0.0.1:6379"}

El endpoint responde (HTTP 503), que es lo que queríamos comprobar: el proceso vive, Express escucha y la ruta funciona. Pero db y cache están en ko con los mismos ECONNREFUSED de la lección 01-07, y ahora por una razón nueva y muy instructiva: localhost dentro del contenedor es el propio contenedor, no tu máquina. Ahí dentro no hay ningún PostgreSQL ni ningún Redis escuchando, y por eso la conexión se rechaza. Aunque tuvieras los servicios corriendo en tu portátil, el contenedor no los vería con esa configuración.

Y /libros, que sí necesita la base de datos:

curl -s http://localhost:3000/libros
{"error":"No se pudo obtener el catálogo","detalle":"connect ECONNREFUSED 127.0.0.1:5432"}

Exactamente lo esperado. Este fallo no es un error tuyo: es el siguiente problema del curso. Conectar contenedores entre sí mediante redes propias, para que DB_HOST=aurora-db signifique algo, es el contenido de la lección 03-05; y darle a PostgreSQL un volumen donde guardar el catálogo, el de la 03-06.

Limpia antes de seguir:

docker stop aurora-api-test && docker rm aurora-api-test

La imagen se queda en tu almacén local para las lecciones siguientes. Un balance de lo conseguido: de los quince pasos manuales de la lección 01-07, los pasos 2, 3, 4 y 5 (descubrir la versión de Node, instalar nvm, instalar Node 22, ejecutar npm install) han desaparecido. Cualquiera con Docker puede ejecutar tu API sin instalar nada relacionado con Node.

Errores Comunes y Consejos

  • Creer que el punto final significa "aquí está el Dockerfile". Significa "este es el contexto". Son cosas separadas, y -f lo demuestra. En cuanto lo interiorizas, la mitad de los errores de COPY desaparecen.
  • COPY ../algo desde fuera del contexto. Nunca funcionará, por diseño. Amplía el contexto y usa -f, o reorganiza los ficheros. Si te ves peleando con esto, casi siempre es señal de que la imagen está intentando llevar dentro algo que no le corresponde.
  • Olvidar el .dockerignore. Contexto lento, imágenes hinchadas, caché que se invalida sola y riesgo real de filtrar un .env. Escríbelo antes del primer docker build, no después.
  • COPY . . antes de instalar dependencias. Es el error de rendimiento más caro y más frecuente. Dependencias arriba, código abajo.
  • Confiar en que la caché "detecta" cambios remotos. Un RUN apk add curl cacheado seguirá instalando la versión de hace tres meses aunque hoy haya una nueva. Para builds de release, --pull y, si procede, --no-cache.
  • Reconstruir con --no-cache "por si acaso" en desarrollo. Tiras a la basura la ventaja del apartado 7. Úsalo cuando tengas un motivo concreto.
  • Pensar que docker build sube la imagen. No sube nada. La imagen queda en tu máquina hasta que hagas docker push (lección 02-06).
  • Consejo: vigila la línea transferring context. Si crece con el tiempo, es que algo nuevo se está colando en el contexto. Es el chivato más barato que tienes.
  • Consejo: etiqueta desde el primer momento. Una build sin -t produce una imagen <none>:<none> que solo puedes referenciar por ID y que se convierte en basura acumulada (imágenes dangling, lección 02-05).

Ejercicios

Ejercicio 1: mide el efecto del .dockerignore

Partiendo de ~/aurora-libros/api con node_modules instalado:

  1. Renombra temporalmente el .dockerignore y construye con --no-cache --progress=plain, anotando la línea transferring context.
  2. Restaura el .dockerignore y repite la medición.
  3. Calcula el porcentaje de reducción.
  4. Añade al proyecto un fichero .env con una contraseña ficticia y una carpeta capturas/ con 5 MB de datos falsos. Sin tocar el .dockerignore, comprueba si entran en el contexto y razona qué habría pasado con un COPY . . si el .dockerignore no existiera.

Pista para generar datos falsos: dd if=/dev/urandom of=capturas/dump.bin bs=1M count=5.

Ejercicio 2: demuestra la invalidación en cascada

Con el Dockerfile del apartado 10 ya construido:

  1. Reconstruye sin cambiar nada y comprueba que todos los pasos salen CACHED.
  2. Modifica una línea de server.js y reconstruye. ¿Qué pasos siguen cacheados y cuál es el primero que se reejecuta? ¿Cuánto tarda?
  3. Añade una dependencia al package.json (por ejemplo "dotenv": "^16.4.7"), regenera el lock con npm install --package-lock-only y reconstruye. ¿Cuántos pasos se reejecutan ahora? ¿Cuánto tarda?
  4. Cambia un comentario dentro del Dockerfile, en la línea inmediatamente anterior al RUN npm ci. Reconstruye. ¿Se mantiene la caché del RUN? Explica el resultado.

Ejercicio 3: arregla un Dockerfile roto

Este Dockerfile tiene cuatro problemas relacionados con lo visto en la lección. Localízalos, explica el síntoma de cada uno y escribe la versión corregida.

FROM node:22-alpine
COPY . .
COPY ../db/init.sql /app/init.sql
RUN npm install
WORKDIR /app
CMD ["node", "server.js"]

Soluciones

Solución al ejercicio 1

cd ~/aurora-libros/api

# 1. Sin .dockerignore
mv .dockerignore .dockerignore.off
docker build --no-cache --progress=plain -t medicion . 2>&1 | grep "transferring context"
#2 transferring context: 23.41MB 1.9s done
# 2. Con .dockerignore
mv .dockerignore.off .dockerignore
docker build --no-cache --progress=plain -t medicion . 2>&1 | grep "transferring context"
#2 transferring context: 47.83kB 0.0s done

3. Reducción: (23 410 − 48) / 23 410 ≈ 99,8 %. El tiempo de transferencia pasa de ~1,9 s a un valor no medible. En una jornada con cincuenta builds, son casi dos minutos de espera pura recuperados, y en un pipeline de CI el ahorro se multiplica por cada ejecución.

4. Con los ficheros nuevos:

echo "DB_PASSWORD=superSecreta2026" > .env
mkdir -p capturas && dd if=/dev/urandom of=capturas/dump.bin bs=1M count=5 2>/dev/null
docker build --no-cache --progress=plain -t medicion . 2>&1 | grep "transferring context"
#2 transferring context: 5.29MB 0.2s done

El contexto crece 5 MB: capturas/ entra porque no está en el .dockerignore. En cambio .env no entra, porque sí está excluido. Compruébalo mirando dentro de la imagen:

docker run --rm medicion ls -la /app
total 60
drwxr-xr-x    1 root     root          4096 Aug  4 10:22 .
drwxr-xr-x    5 root     root          4096 Aug  4 10:22 capturas
-rw-r--r--    1 root     root         44231 Aug  4 10:22 package-lock.json
-rw-r--r--    1 root     root           412 Aug  4 10:22 package.json
-rw-r--r--    1 root     root          6104 Aug  4 10:22 server.js

No hay .env y sí hay capturas/. Las dos lecciones:

  • Sin .dockerignore, ese .env con superSecreta2026 habría acabado dentro de la imagen y, al publicarla en el repositorio público del apartado 10 de la lección anterior, en Internet. Y no bastaría con borrarlo en una capa posterior: como aprendiste en 01-05, la capa inferior conserva el fichero y docker image history delata la operación.
  • El .dockerignore hay que mantenerlo. Ha protegido lo que se le pidió proteger, pero capturas/ es nuevo y nadie lo añadió. Añade capturas/ al fichero y vuelve a medir.

Solución al ejercicio 2

1. Sin cambios: [+] Building 0.4s, todos los pasos CACHED. La build es prácticamente instantánea porque BuildKit solo verifica claves de caché.

2. Tras tocar server.js:

 => CACHED [2/5] WORKDIR /app                                     0.0s
 => CACHED [3/5] COPY package.json package-lock.json ./           0.0s
 => CACHED [4/5] RUN npm ci --omit=dev && npm cache clean --force 0.0s
 => [5/5] COPY . .                                                0.1s
 => exporting to image                                            0.4s
[+] Building 1.1s (11/11) FINISHED

Solo se reejecuta el COPY . ., porque el checksum del contexto ha cambiado. El RUN npm ci sigue cacheado, que es justo el objetivo del diseño. Poco más de un segundo.

3. Tras cambiar package.json y el lock:

 => CACHED [2/5] WORKDIR /app                                     0.0s
 => [3/5] COPY package.json package-lock.json ./                  0.0s
 => [4/5] RUN npm ci --omit=dev && npm cache clean --force        9.2s
 => [5/5] COPY . .                                                0.1s
[+] Building 10.4s (11/11) FINISHED

Se invalida desde el paso 3, y en cascada el npm ci y el COPY . .. Diez segundos. Y es lo correcto: has cambiado las dependencias, así que hay que reinstalarlas. La caché no falla, funciona exactamente como debe.

4. Cambiar un comentario inmediatamente anterior al RUN:

 => CACHED [4/5] RUN npm ci --omit=dev && npm cache clean --force 0.0s

La caché se mantiene. La clave de caché se calcula sobre el texto de la instrucción, y los comentarios no son instrucciones: el intérprete los descarta antes de calcular nada. En cambio, si modificas el texto del propio RUN —aunque solo sea añadir un espacio o reordenar dos opciones equivalentes—, la caché se invalida, porque la comparación es textual, no semántica. Es la razón de que reformatear un RUN largo "para que quede más bonito" dispare una build completa.

Solución al ejercicio 3

Los cuatro problemas:

  1. COPY . . antes de WORKDIR. Sin WORKDIR definido, el directorio de trabajo es /, así que los ficheros aterrizan en la raíz del sistema de archivos, mezclados con /bin, /etc y /usr. Luego el WORKDIR /app del final crea un /app vacío, y el CMD falla con Cannot find module '/app/server.js'.
  2. COPY ../db/init.sql apunta fuera del contexto: error de build inmediato ("/db/init.sql": not found). Además, ese fichero no pertenece a la imagen de la API: es la inicialización de PostgreSQL y se resolverá en el módulo 3.
  3. COPY . . antes de instalar dependencias. Invalidación en cascada: cada cambio en server.js fuerza la reinstalación completa de dependencias.
  4. npm install en lugar de npm ci --omit=dev. npm install puede resolver versiones distintas de las fijadas en package-lock.json, con lo que dos builds del mismo código pueden producir imágenes diferentes, e instala también las dependencias de desarrollo, que engordan la imagen sin aportar nada en ejecución.

Versión corregida:

# syntax=docker/dockerfile:1
FROM node:22-alpine

# 1. WORKDIR ANTES de cualquier COPY: fija dónde aterrizan los ficheros
WORKDIR /app

# 2. init.sql eliminado: no pertenece a esta imagen
# 3. Dependencias primero, para preservar la caché
COPY package.json package-lock.json ./

# 4. npm ci: reproducible y sin dependencias de desarrollo
RUN npm ci --omit=dev && npm cache clean --force

# El código, en último lugar por ser lo más volátil
COPY . .

ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
CMD ["node", "server.js"]

Verifica que ahora los ficheros están donde deben:

docker build -t aurora-api:corregido .
docker run --rm aurora-api:corregido ls /app
node_modules
package-lock.json
package.json
server.js

Conclusión

Ya sabes fabricar imágenes. docker build no ejecuta nada en tu carpeta: empaqueta el contexto de construcción y se lo envía a BuildKit, que interpreta el Dockerfile como un grafo de pasos y produce capas. Ese punto final del comando es el contexto, no el Dockerfile, y de ahí se derivan tanto el error de COPY fuera del contexto como la necesidad del .dockerignore, que en Aurora Libros ha recortado el envío de 23,41 MB a 47,83 kB y, de paso, ha impedido que un .env con credenciales acabe dentro de la imagen. La opción -f desacopla la ubicación del Dockerfile del contexto, pero las rutas de COPY siguen resolviéndose siempre contra este último.

Has visto también la pieza que más tiempo te va a ahorrar en tu vida diaria: la caché de capas. BuildKit calcula una clave por instrucción a partir de la capa anterior, del texto de la instrucción y del contenido de los ficheros copiados; si esa clave existe, el paso sale CACHED en cero segundos, y si no, se reejecuta ese paso y todos los siguientes. De esa invalidación en cascada nace la regla de oro de ordenar de menos a más volátil, que has medido: 46,7 s frente a 1,4 s ante un simple cambio de código, treinta y tres veces más rápido solo por partir un COPY en dos. Y sabes cuándo desconfiar de la caché con --no-cache y --pull, y cómo leer la salida de BuildKit para localizar el punto exacto en que dejó de haber CACHED.

Lo más importante: auroralibros/aurora-api:0.1.0 existe. Son 167 MB que arrancan Express en un contenedor y responden a curl http://localhost:3000/salud en una máquina donde no hay Node instalado. Cuatro de los quince pasos de onboarding han caído de golpe. Que /libros devuelva ECONNREFUSED no es un fallo: es el siguiente problema del temario, y se resolverá cuando conectes contenedores en red en el módulo 3.

Ahora bien, ese Dockerfile lo has escrito casi a ciegas: sabes qué hace cada línea a grandes rasgos, pero no por qué está escrita así. Por qué npm ci y no npm install, por qué el CMD lleva corchetes y comillas, por qué EXPOSE no expone nada, cuándo usar ADD en lugar de COPY y por qué encadenar comandos con && produce imágenes más pequeñas. Todo eso es la próxima lección, Conceptos Básicos de Dockerfile, donde recorrerás el lenguaje instrucción por instrucción y terminarás con el Dockerfile definitivo de aurora-api, comentado línea a línea y justificado decisión a decisión.

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