El problema: "en mi máquina funciona"
Una aplicación depende de un entorno: una versión concreta de Node, ciertas variables, librerías del sistema... Si ese entorno difiere entre tu portátil y el servidor, aparecen errores difíciles de reproducir. Los contenedores resuelven esto empaquetando la aplicación junto con su entorno para que se ejecute igual en cualquier sitio.
Imagen vs contenedor
Es la distinción más importante del tema:
- Una imagen es una plantilla inmutable y de solo lectura: el sistema de ficheros, las dependencias y el comando de arranque. Es como la clase.
- Un contenedor es una instancia en ejecución de una imagen, con su propia capa de escritura y procesos vivos. Es como el objeto.
De una misma imagen puedes lanzar muchos contenedores. La imagen se construye una vez; los contenedores se crean, paran y destruyen continuamente.
El Dockerfile
Una imagen se define con un Dockerfile: una receta de instrucciones que se ejecutan de arriba abajo.
# Imagen base de la que partimos
FROM node:20-alpine
# Carpeta de trabajo dentro del contenedor
WORKDIR /app
# Copiamos primero los manifiestos (para aprovechar la caché, ver abajo)
COPY package.json package-lock.json ./
RUN npm ci
# Copiamos el resto del código fuente
COPY . .
# Comando que se ejecuta al arrancar el contenedor
CMD ["node", "server.js"]
Las instrucciones clave:
FROM: imagen base de partida.WORKDIR: directorio de trabajo dentro de la imagen.COPY: copia ficheros del host a la imagen.RUN: ejecuta un comando al construir la imagen (instalar, compilar).CMD: define el comando que corre al arrancar el contenedor.
Capas y caché de build
Cada instrucción genera una capa. Docker cachea cada capa: si una
instrucción y todo lo anterior no han cambiado, reutiliza la capa en lugar de
re-ejecutarla. Por eso copiamos package.json y hacemos npm ci antes de
copiar el resto del código: mientras no cambien las dependencias, esa capa
(la lenta) se reutiliza aunque edites tu código fuente.
Regla práctica: pon lo que cambia poco arriba y lo que cambia mucho abajo.
Builds multi-stage
Para construir necesitas herramientas (compiladores, dev-dependencies) que no quieres en la imagen final. Un build multi-stage usa una etapa para construir y otra, limpia y pequeña, para ejecutar:
# Etapa 1: construir
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Etapa 2: imagen final, solo con lo necesario para ejecutar
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]
La imagen final no contiene el código fuente ni las dev-dependencies: solo el
resultado del build (dist) y las dependencias de producción. Resultado:
imágenes más pequeñas, rápidas de desplegar y con menor superficie de
ataque.
Varios servicios: docker-compose
Una app real combina varios contenedores (API, base de datos, caché...). docker-compose los describe y orquesta en un único fichero:
services:
api:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://user:pass@db:5432/app
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: pass
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:
Con docker compose up levantas toda la pila a la vez. Los servicios se ven
entre sí por su nombre (db), y los volúmenes persisten datos aunque
el contenedor se destruya.