Entendiendo el Dockerfile
En la unidad anterior construimos tastematch:1.0 y tastematch:1.1 con este Dockerfile:
FROM node:22
WORKDIR /app
COPY . .
RUN npm install
CMD ["npm", "start"]
Funcionaba, pero ahora necesitamos algo más que repetirlo. Intenta explicar cada línea sin mirar la unidad anterior:
- ¿de dónde parte la imagen?;
- ¿dónde quedan los archivos?;
- ¿qué se copia?;
- ¿qué sucede durante la construcción?;
- ¿qué sucederá al iniciar un contenedor?
Un Dockerfile describe paso a paso cómo queremos construir una imagen y qué comportamiento tendrá por defecto el contenedor creado a partir de ella. Vamos a leer el nuestro con más precisión y a evolucionarlo de forma razonada.
Elegir el punto de partida con FROM
TasteMatch está escrita para Node.js. Esta primera línea selecciona una imagen que ya proporciona ese runtime y herramientas como npm:
FROM node:22
node es el nombre de la imagen y 22 es su tag. El tag forma parte de la referencia: cambiarlo puede seleccionar otra variante o versión de la base.
node:22
├── filesystem de la imagen base
├── Node.js
└── npm
↓
añadimos TasteMatch
↓
nuestra imagen
No empezamos desde un sistema vacío. La imagen base puede aportar el runtime, herramientas y archivos que nuestra aplicación necesita. Elegirla es una decisión real del Dockerfile; en este recorrido conservaremos node:22 sin comparar tamaños ni variantes avanzadas.
Pregunta: si sustituyéramos node:22 por una imagen que no contiene Node.js, ¿qué ocurriría al intentar ejecutar npm install? El resto del Dockerfile seguiría escrito, pero faltaría una herramienta de la que depende.
Establecer el lugar de trabajo con WORKDIR
La siguiente instrucción establece un directorio de trabajo dentro de la imagen:
WORKDIR /app
A partir de ahí, las rutas relativas de instrucciones posteriores se interpretan respecto a /app cuando corresponda. También será el directorio de trabajo inicial del proceso del contenedor.
Por eso podemos escribir:
WORKDIR /app
COPY package.json .
El destino . representa aquí /app. El resultado que buscamos es:
/
└── app
└── package.json
Por qué RUN cd /app no lo sustituye
Podríamos encontrar algo así:
RUN cd /app
RUN npm install
El primer RUN ejecuta un comando durante un paso de construcción; no establece el directorio de trabajo para las instrucciones siguientes. Además, no expresa con claridad dónde queremos trabajar a partir de ese punto.
En cambio:
WORKDIR /app
RUN npm install
declara esa intención directamente. No necesitamos estudiar ahora el filesystem completo: basta con distinguir entre cambiar de directorio dentro de un comando y establecer el directorio de trabajo del Dockerfile.
Elegir qué archivos introducir con COPY
En nuestra primera imagen copiamos todo lo disponible de una vez:
COPY . .
COPY recibe un origen y un destino:
COPY <origen> <destino>
En el ejemplo:
- el primer
.se refiere al origen disponible para la construcción; - el segundo
.se refiere al destino relativo alWORKDIR,/app.
También podemos ser más específicos:
COPY package.json .
COPY app.js .
O, en un proyecto con una carpeta src:
COPY src/ ./src/
La barra final ayuda a leer que copiamos el contenido de un directorio a otro directorio. Las rutas concretas deben corresponder a la estructura real del proyecto.
No siempre interesa copiar todo. Una imagen necesita los archivos que permiten construir y ejecutar la aplicación, no cualquier nota o material que casualmente esté junto al código. En la siguiente unidad estudiaremos con detalle qué archivos están disponibles durante el build y cómo excluir los que no correspondan; aquí solo queremos tomar la decisión de copia de forma consciente.
COPY y ADD: reconocer la intención
También puedes encontrar esta instrucción en un Dockerfile:
ADD app.js /app/app.js
ADD puede incorporar archivos y dispone de comportamientos adicionales. No necesitamos recorrerlos ahora. Si nuestra única necesidad es copiar un archivo o directorio local, normalmente COPY expresa mejor lo que queremos hacer:
COPY app.js /app/app.js
La regla útil en este nivel es sencilla:
Si solo necesitas copiar archivos, utiliza
COPY.
Esto no significa que ADD sea inválido; significa que no debemos elegir una instrucción más amplia sin necesitar sus otros comportamientos.
Preparar la imagen con RUN
TasteMatch declara Express en package.json, pero copiar ese archivo no instala la dependencia. Esta instrucción realiza la preparación:
RUN npm install
RUN ejecuta un comando durante docker build. El resultado pasa a formar parte de la imagen que estamos construyendo.
docker build
↓
encuentra RUN npm install
↓
ejecuta npm install durante el build
↓
imagen con las dependencias preparadas
Otro proyecto podría necesitar otro comando de preparación, por ejemplo generar archivos antes de ejecutarse. No añadimos comandos porque sí: cada RUN debe responder a una necesidad de la aplicación.
Comprobación mental: cuando termina RUN npm install, ¿TasteMatch queda atendiendo peticiones en el puerto 3000? No. Hemos preparado la imagen, pero todavía no hemos iniciado un contenedor.
Definir el comportamiento por defecto con CMD
Al iniciar un contenedor de TasteMatch queremos ejecutar el script start:
CMD ["npm", "start"]
CMD define el comando o los argumentos que se utilizarán por defecto al iniciar un contenedor. Actúa en la fase de ejecución, no durante el build.
docker build docker run
↓ ↓
construye la imagen crea el contenedor
↓
utiliza CMD por defecto
Decimos por defecto porque quien ejecuta la imagen puede proporcionar otro comando. Por ejemplo, esta ejecución sustituye el CMD de TasteMatch por un comando que existe en la imagen base:
docker run --rm tastematch:1.1 npm --version
Ese contenedor muestra la versión de npm y termina; no inicia el servidor. No hemos modificado la imagen, solo hemos elegido otro comando para esa ejecución.
Un Dockerfile puede tener varias instrucciones RUN, porque puede necesitar varios pasos de preparación. En cambio, debe expresar con claridad cuál será su comportamiento por defecto al iniciar el contenedor.
Fijar un ejecutable principal con ENTRYPOINT
ENTRYPOINT también interviene al iniciar el contenedor, pero no es simplemente otro nombre para CMD. Permite declarar cuál es el ejecutable principal de una imagen.
Imagina una herramienta pequeña, tool.js, que recibe una categoría de recetas:
const category = process.argv[2] || "sin categoría";
console.log(`Buscando recetas de: ${category}`);
Podríamos diseñar su Dockerfile así:
FROM node:22
WORKDIR /app
COPY tool.js .
ENTRYPOINT ["node", "tool.js"]
CMD ["rápidas"]
En este diseño:
ENTRYPOINT ["node", "tool.js"]
→ ejecutable principal
CMD ["rápidas"]
→ argumento por defecto
Si la imagen se llama buscador-recetas, al ejecutar:
docker run --rm buscador-recetas
el diseño combina el ejecutable con el argumento predeterminado:
node tool.js rápidas
Y si proporcionamos otro argumento:
docker run --rm buscador-recetas postres
se utiliza postres en lugar del valor predeterminado:
node tool.js postres
La interacción exacta entre ENTRYPOINT y CMD depende de cómo se declaren. Para este primer modelo quédate con la intención: ENTRYPOINT fija el programa principal y CMD puede aportar valores por defecto. No todas las imágenes necesitan utilizar ambos.
RUN, CMD y ENTRYPOINT no actúan igual
Estas tres instrucciones pueden parecer relacionadas porque contienen algo ejecutable, pero pertenecen a momentos e intenciones diferentes:
| Instrucción | Momento principal | Idea |
|---|---|---|
RUN | Durante docker build | Preparar o modificar la imagen. |
CMD | Al iniciar el contenedor | Proporcionar comando o argumentos por defecto. |
ENTRYPOINT | Al iniciar el contenedor | Definir el ejecutable principal. |
Compara dos aplicaciones:
# Aplicación web
RUN npm install
CMD ["npm", "start"]
# Herramienta con argumentos
ENTRYPOINT ["node", "tool.js"]
CMD ["rápidas"]
En la aplicación web, RUN instala dependencias al construir y CMD inicia el servidor por defecto. En la herramienta, ENTRYPOINT fija el programa y CMD proporciona un argumento que el usuario puede cambiar.
RUN ≠ CMD
CMD ≠ ENTRYPOINT
Antes de añadir cualquiera de ellas, pregunta: ¿estoy preparando la imagen o describiendo qué debe pasar al iniciar el contenedor?
Dos formas de escribir instrucciones ejecutables
Puedes encontrar una forma con una lista JSON:
CMD ["npm", "start"]
Esta es la forma exec. Cada elemento está separado explícitamente: el ejecutable es npm y su argumento es start.
También existe la forma shell:
CMD npm start
No se ejecutan exactamente igual. La forma shell utiliza una shell implícita, mientras que la forma exec inicia directamente el ejecutable indicado cuando la instrucción lo permite.
En nuestro recorrido preferiremos la forma exec para CMD y ENTRYPOINT cuando sea adecuada porque hace explícitos el ejecutable y sus argumentos:
ENTRYPOINT ["node", "tool.js"]
CMD ["rápidas"]
No necesitamos entrar todavía en señales del sistema, PID 1 ni gestión avanzada de procesos. En esta unidad basta con reconocer ambas formas y saber que no son dos escrituras idénticas.
Añadir configuración base no sensible con ENV
Podemos definir una variable de entorno en la imagen:
ENV APP_ENV=production
La variable estará disponible para instrucciones posteriores y para los contenedores creados desde la imagen. Para que cambie el comportamiento de TasteMatch, su código tendría que consultar APP_ENV; definirla no obliga a la aplicación a utilizarla.
En esta unidad solo la emplearemos como configuración base no sensible. Más adelante aprenderemos a proporcionar configuración al ejecutar un contenedor y a reutilizar una imagen en entornos diferentes.
Nunca debemos incrustar credenciales en una imagen de esta manera:
# No hagas esto
ENV PASSWORD=mi_password
ENV API_TOKEN=token_secreto
Una imagen puede distribuirse, inspeccionarse o conservarse en distintos equipos. ENV no es un almacén de secretos y cambiar el nombre del valor no lo protege.
Documentar el puerto con EXPOSE
TasteMatch escucha en el puerto 3000 del contenedor. Podemos expresar esa intención en el Dockerfile:
EXPOSE 3000
EXPOSE documenta el puerto previsto por la imagen. No publica automáticamente ese puerto en el host y no hace que una aplicación empiece a escuchar.
Compara las dos piezas:
EXPOSE 3000
→ la imagen documenta que la aplicación utiliza el puerto 3000
docker run -p 8080:3000 ...
→ al crear el contenedor, publica su puerto 3000 en el 8080 del host
Por tanto:
EXPOSE 3000 ≠ -p 8080:3000
EXPOSE tampoco es obligatorio para que -p funcione. La aplicación debe escuchar realmente en el puerto interno correcto y la publicación se configura al ejecutar el contenedor, como ya comprobaste en Docker 0.
Evolucionar el Dockerfile de TasteMatch
Ya podemos reorganizar la lectura del Dockerfile alrededor de las necesidades de la aplicación. Partimos de la base y del directorio de trabajo:
FROM node:22
WORKDIR /app
Copiamos el archivo que declara la dependencia y la instalamos:
COPY package.json .
RUN npm install
Después introducimos el resto de los archivos de TasteMatch:
COPY . .
Finalmente añadimos configuración base no sensible, documentamos el puerto y declaramos el comando de inicio:
ENV APP_ENV=production
EXPOSE 3000
CMD ["npm", "start"]
El resultado completo es:
FROM node:22
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
ENV APP_ENV=production
EXPOSE 3000
CMD ["npm", "start"]
Ahora puedes leerlo de arriba abajo:
- parte de una imagen con Node.js;
- trabaja dentro de
/app; - introduce el manifiesto e instala la dependencia;
- copia los demás archivos de la aplicación;
- define una variable no sensible;
- documenta el puerto interno
3000; - inicia TasteMatch mediante
npm startpor defecto.
Este Dockerfile es apropiado para estudiar estas instrucciones. No afirmamos que sea una plantilla universal, que esté optimizado o que esté listo para cualquier entorno de producción. El orden también interviene en cómo se construye una imagen; estudiaremos ese proceso en la siguiente unidad.
Construir y comprobar TasteMatch 2.0
Sustituye el Dockerfile anterior de la carpeta tastematch por el evolucionado y construye una imagen nueva:
docker build -t tastematch:2.0 .
Comprueba que existe:
docker image ls tastematch
Usaremos un nombre y un puerto del host nuevos para no confundir esta práctica con los contenedores anteriores:
docker run -d --name tastematch-2-0 -p 8080:3000 tastematch:2.0
Comprueba estado y logs:
docker ps
docker logs tastematch-2-0
Abre http://localhost:8080. Debe aparecer el último mensaje que dejaste en app.js:
TasteMatch: mi primera aplicación contenerizada
Observa dos hechos:
- la aplicación funciona porque
CMDinicia el proceso previsto; - la URL del host funciona porque usamos
-p, no porque existaEXPOSE.
Cuando termines, retira únicamente el contenedor de esta práctica y conserva la imagen para poder inspeccionar tus decisiones:
docker stop tastematch-2-0
docker rm tastematch-2-0
docker ps -a
docker image ls tastematch
Practicar la lectura de Dockerfiles
No siempre escribirás el Dockerfile desde cero. Lee los siguientes ejemplos sin ejecutarlos y responde para cada uno:
- ¿Qué imagen base utiliza?
- ¿Qué directorio de trabajo establece?
- ¿Qué archivos copia y dónde?
- ¿Qué ocurre durante el build?
- ¿Qué ocurrirá por defecto al iniciar un contenedor?
- ¿Documenta algún puerto?
- ¿Aparece alguna instrucción que todavía no conocemos?
Dockerfile A: servicio Node.js
FROM node:22
WORKDIR /service
COPY package.json .
RUN npm install
COPY server.js .
EXPOSE 4000
CMD ["node", "server.js"]
Después de leerlo, explica por qué RUN npm install no inicia el servicio y por qué EXPOSE 4000 no permite por sí solo entrar desde localhost:4000.
Dockerfile B: herramienta de texto
FROM node:22
WORKDIR /tool
COPY formatter.js .
ENTRYPOINT ["node", "formatter.js"]
CMD ["resumen"]
Predice el programa y argumento utilizados al ejecutar la imagen sin añadir nada. Después predice qué cambiaría al proporcionar detalle al final de docker run.
Dockerfile C: una instrucción desconocida
FROM node:22
WORKDIR /app
COPY app.js .
USER node
CMD ["node", "app.js"]
Puedes explicar casi todo el archivo aunque USER todavía no forme parte de este recorrido. Señálala como desconocida y busca su documentación cuando realmente necesites comprenderla; no inventes su significado a partir del nombre.
La capacidad de lectura consiste en separar lo que sabes, lo que puedes deducir con evidencia y lo que necesitas investigar.
Errores habituales al escribir un Dockerfile
Utilizar RUN para iniciar la aplicación
Este Dockerfile intenta iniciar el servidor durante la construcción:
RUN npm start
RUN pertenece al build. Para expresar el comportamiento por defecto del contenedor usamos CMD en el diseño de TasteMatch:
CMD ["npm", "start"]
Pensar que CMD ocurre durante el build
docker build registra el comportamiento indicado por CMD, pero no inicia el servidor para dejarlo atendiendo peticiones. El comando se utiliza al crear e iniciar un contenedor.
Pensar que EXPOSE publica el puerto
Aunque el Dockerfile contenga:
EXPOSE 3000
seguimos necesitando una publicación como -p 8080:3000 para acceder desde ese puerto del host. EXPOSE documenta; -p publica.
Utilizar ADD para cualquier copia
Si solo queremos introducir app.js, esta instrucción expresa la intención con precisión:
COPY app.js .
No necesitamos elegir ADD por costumbre cuando no utilizamos sus otros comportamientos.
Copiar sin pensar qué necesita la imagen
COPY . . resulta cómodo, pero puede introducir elementos que la aplicación no necesita. Antes de copiar, identifica los archivos de ejecución y preparación. En la siguiente unidad resolveremos de forma práctica cómo controlar qué participa en la construcción.
Guardar secretos con ENV
No conviertas una credencial en parte de la imagen:
# Incorrecto: no incrustes secretos
ENV DATABASE_PASSWORD=supersecreto
Eliminar después la línea o cambiar el valor no convierte ese diseño en una gestión segura. En otra unidad separaremos la imagen de su configuración de ejecución; por ahora, utiliza ENV únicamente con valores no sensibles.
Mini reto: completa y explica antes de construir
Recibes una aplicación web pequeña con esta estructura:
panel-web/
├── package.json
└── server.js
package.json declara Express y contiene:
{
"name": "panel-web",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "5.1.0"
}
}
server.js escucha en 0.0.0.0:4000, escribe Panel web escucha en el puerto 4000 al iniciar y responde Panel web preparado en /.
Crea server.js con este contenido:
const express = require("express");
const app = express();
app.get("/", (_request, response) => {
response.send("Panel web preparado");
});
app.listen(4000, "0.0.0.0", () => {
console.log("Panel web escucha en el puerto 4000");
});
Completa este Dockerfile sin buscar una plantilla terminada:
FROM ______
WORKDIR ______
COPY ______
RUN ______
COPY ______
EXPOSE ______
CMD ______
Antes de construir, entrega una explicación línea por línea:
- qué aporta la imagen base;
- dónde trabajará la aplicación;
- qué archivos necesita y dónde se copian;
- qué ocurre durante el build;
- qué comando se utiliza al iniciar;
- qué puerto documenta la imagen.
Después:
- construye una imagen llamada
panel-web:1.0; - comprueba que existe;
- crea
panel-web-1-0y publica el puerto interno4000en un puerto libre del host; - comprueba estado, logs y respuesta HTTP;
- explica por qué
RUNyCMDno son intercambiables; - demuestra, mediante el comando utilizado, qué pieza publicó realmente el puerto;
- retira únicamente el contenedor del reto.
Como ampliación, crea una herramienta independiente que reciba una palabra como argumento. Diseña su Dockerfile con un ENTRYPOINT apropiado y un argumento predeterminado mediante CMD. Explica qué cambia cuando proporcionas otra palabra al ejecutar la imagen.
Ya puedes leer el recorrido completo
Un Dockerfile ha dejado de ser una receta opaca. Ahora puedes preguntarte, ante cada instrucción:
¿De dónde parto? → FROM
¿Dónde trabajo? → WORKDIR
¿Qué archivos introduzco? → COPY
¿Qué preparo durante el build? → RUN
¿Qué configuración base añado? → ENV
¿Qué puerto documento? → EXPOSE
¿Qué se ejecuta por defecto? → CMD
¿Cuál es el programa principal? → ENTRYPOINT
También puedes separar los dos momentos principales:
Construcción de la imagen Inicio del contenedor
FROM · WORKDIR · COPY · RUN CMD · ENTRYPOINT
En la siguiente unidad observaremos con más detalle cómo Docker procesa estas instrucciones durante la construcción. Estudiaremos qué resultados puede reutilizar, qué archivos intervienen y por qué el orden del Dockerfile puede cambiar el trabajo necesario.