Publicado en

Dockerfile: cómo construir imágenes Docker

Dockerfile con instrucciones FROM, WORKDIR, COPY, RUN, EXPOSE y CMD coloreadas sobre fondo oscuro

Hasta ahora hemos visto Docker como una herramienta para ejecutar contenedores de otros: descargar una imagen de nginx, levantar una base de datos PostgreSQL. Pero la utilidad real de Docker aparece cuando empiezas a empaquetar tu propia aplicación. Para eso existe el Dockerfile.

¿Qué es un Dockerfile?

Un Dockerfile es un archivo de texto con instrucciones que Docker lee para construir una imagen. Cada instrucción genera una capa en el sistema de archivos de la imagen. El resultado es un artefacto reproducible: cualquier persona, en cualquier máquina, ejecutando docker build con el mismo Dockerfile obtiene exactamente la misma imagen.

El sistema de capas

Sistema de capas de una imagen Docker: cada instrucción genera una capa, y el orden importa para aprovechar la caché durante la construcción

Cada instrucción del Dockerfile añade una capa sobre las anteriores. Docker cachea estas capas: si nada ha cambiado en una capa ni en las anteriores, la reutiliza en la siguiente construcción. Por eso el orden importa.

El error más habitual es copiar todo el código antes de instalar las dependencias. Si cambias una sola línea de código, Docker invalida esa capa y todas las posteriores, incluyendo la instalación de dependencias. El orden correcto es instalar dependencias primero (con su package.json o equivalente) y copiar el código después.

Las instrucciones esenciales

Tabla de instrucciones Dockerfile de referencia: FROM, RUN, COPY, ADD, WORKDIR, ENV, ARG, CMD, ENTRYPOINT, EXPOSE, VOLUME, USER, HEALTHCHECK y LABEL con su descripción

Un Dockerfile mínimo funcional para una aplicación Node.js tiene esta forma:

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]

Cada línea tiene un propósito concreto. FROM establece la imagen base: node:20-alpine es una imagen oficial de Node.js basada en Alpine Linux, que pesa unos 50 MB frente a los 300+ MB de la versión completa. WORKDIR define el directorio de trabajo dentro del contenedor; si no existe, Docker lo crea. EXPOSE documenta el puerto pero no lo publica: eso se hace al ejecutar el contenedor con -p.

CMD vs ENTRYPOINT

Ambas definen qué se ejecuta al arrancar el contenedor, pero se comportan de forma diferente al pasarle argumentos a docker run:

# CMD: se puede sobreescribir completo
CMD ["node", "server.js"]
# docker run mi-app worker.js → ejecuta: node worker.js NO, ejecuta: worker.js directamente

# ENTRYPOINT: el ejecutable es fijo, docker run añade argumentos
ENTRYPOINT ["node"]
CMD ["server.js"]
# docker run mi-app worker.js → ejecuta: node worker.js

La combinación habitual es ENTRYPOINT para el ejecutable y CMD para los argumentos por defecto.

Construir y ejecutar

# Construir la imagen con un tag
docker build -t mi-app:1.0 .

# Ver las imágenes disponibles
docker images

# Ejecutar un contenedor de esa imagen
docker run -d -p 8080:3000 --name mi-contenedor mi-app:1.0

# Ver logs
docker logs -f mi-contenedor

El archivo .dockerignore

Funciona igual que .gitignore: excluye archivos y directorios del contexto que Docker envía al daemon al construir. Sin él, COPY . . enviaría también el directorio node_modules, que puede pesar cientos de megabytes y además no debe incluirse en la imagen (las dependencias se instalan dentro del contenedor).

node_modules
.git
.env
*.log
dist
coverage

Buenas prácticas

  • Usa imágenes base ligeras: alpine o slim en lugar de la versión completa. Menos superficie de ataque y descargas más rápidas.
  • Fija la versión exacta: node:20.11-alpine en lugar de node:latest. Las imágenes con :latest cambian sin avisar y rompen builds.
  • Combina comandos RUN: cada RUN genera una capa. RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* es una sola capa en lugar de tres.
  • Ejecuta como usuario no root: añade USER node (o USER nobody) antes del CMD. Si el contenedor es comprometido, el atacante no tiene permisos de root en el host.

Con la imagen construida, el siguiente paso natural es coordinar varios contenedores a la vez: la aplicación, la base de datos, el caché. Levantar y conectar todo manualmente con docker run es tedioso. Eso es exactamente lo que resuelve Docker Compose.