La primera vez que Docker me rompió producción
Era 2021. Tenía una app Node.js corriendo en un VPS de DigitalOcean, funcionaba perfecto en mi máquina (sí, esa frase maldita), y decidí 'modernizar' el deploy metiéndole Docker. Resultado: tres horas de downtime, un cliente furioso y yo a las 3 de la mañana leyendo logs que no entendía.
Hoy, con todo ese dolor convertido en experiencia, puedo decirte que Docker con Node.js es una de las mejores decisiones que podés tomar para tu stack — siempre y cuando lo hagas bien. Y 'bien' implica entender qué está pasando, no copiar un Dockerfile de Stack Overflow y rezar.
Vamos de cero. En serio, de cero.
¿Por qué Docker y Node.js se llevan tan bien?
Node.js tiene un problema histórico: el entorno. La versión de Node en tu máquina, la del servidor de staging, la del servidor de producción — si no las controlás, te esperan bugs que aparecen solo en producción y te hacen dudar de tu cordura.
Docker resuelve esto con contenedores. Un contenedor es básicamente un proceso aislado que lleva su propio sistema de archivos, sus propias dependencias, su propia versión de Node. Vos definís todo eso en un Dockerfile, y ese archivo viaja con tu código. Si funciona en tu contenedor, funciona en cualquier lado.
Eso es la promesa. Ahora veamos cómo no arruinarla.
Tu primer Dockerfile para Node.js
Empezamos con lo básico. Supongamos que tenés una app Express simple:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
CMD ["node", "src/index.js"]
Este Dockerfile hace cosas específicas por razones específicas. Te las explico porque cuando entendés el por qué, dejás de copiar a ciegas:
FROM node:20-alpine: Uso Alpine Linux, que pesa unos 50MB contra los 300MB+ de la imagen Debian/Ubuntu. Para producción, menos superficie = menos vulnerabilidades potenciales. Para desarrollo, a veces Alpine te rompe dependencias nativas (te miro a vos, bcrypt). En esos casos usá node:20-slim.
WORKDIR /app: Definís un directorio de trabajo limpio. Sin esto, Docker tira los archivos en la raíz del contenedor y el caos reina.
COPY package*.json ./ antes del COPY . .: Esto es crítico para el sistema de caché de Docker. Las capas de Docker se cachean. Si copiás primero los package.json y corrés npm ci, Docker va a reusar esa capa mientras los package.json no cambien. O sea: en cada rebuild, si solo tocaste código, Docker no reinstala todas las dependencias. Esto te ahorra minutos reales.
npm ci en lugar de npm install: ci usa exactamente el package-lock.json. Reproducible, determinístico, lo que querés en producción.
El .dockerignore que nadie te enseña
Antes de buildear nada, creá un .dockerignore. Esto es lo que más gente olvida y lo que más me quemó al principio:
node_modules
.git
.gitignore
*.log
.env
.env.local
.env.*.local
dist
build
.next
Dockerfile
docker-compose*.yml
README.md
.DS_Store
coverage
Sin .dockerignore, estás copiando node_modules (que puede pesar gigabytes) al contexto de build, y potencialmente metiendo tus variables de entorno secretas en la imagen. Sí, como suena. El .env adentro de una imagen Docker pública es una pesadilla de seguridad que vi pasar en repos reales.
Docker Compose: el compañero inseparable
Ninguna app vive sola. La tuya necesita una base de datos, quizás Redis, quizás un servicio de cola. Docker Compose te permite orquestar todo eso localmente con un solo archivo:
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- DATABASE_URL=postgresql://postgres:password@db:5432/myapp
- REDIS_URL=redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
volumes:
- .:/app
- /app/node_modules
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: password
POSTGRES_DB: myapp
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
cache:
image: redis:7-alpine
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data:
Notá el depends_on con condition: service_healthy. Esto fue otro de mis errores históricos: arrancar la app antes de que Postgres termine de inicializar. Sin el healthcheck, tu app arranca, intenta conectarse a la base de datos que todavía está bootando, y explota. Con el healthcheck, Docker espera a que Postgres realmente esté listo.
El volumen doble en app:
volumes:
- .:/app
- /app/node_modules
Monta tu código local adentro del contenedor (hot reload en desarrollo) pero preserva el node_modules del contenedor. Sin la segunda línea, tu node_modules local pisaría el del contenedor, y si estás en Mac o Windows corriendo Alpine, los binarios compilados son incompatibles. Esta sutileza me hizo perder dos horas una tarde.
Multi-stage builds: el paso de adulto
Cuando empecés a trabajar con TypeScript (y vas a trabajar con TypeScript), necesitás compilar antes de correr. Un Dockerfile naive instalaría todas las devDependencies, compilaría, y dejaría todo ese peso en la imagen final. Multi-stage builds resuelven eso:
# Stage 1: Builder
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json tsconfig.json ./
RUN npm ci
COPY src ./src
RUN npm run build
# Stage 2: Production
FROM node:20-alpine AS production
WORKDIR /app
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodeuser -u 1001
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
COPY --from=builder /app/dist ./dist
USER nodeuser
EXPOSE 3000
CMD ["node", "dist/index.js"]
Esto hace dos cosas importantes:
- La imagen final solo tiene el código compilado y las dependencias de producción. Sin TypeScript, sin ts-node, sin ninguna devDependency. Imágenes más chicas, más seguras, más rápidas de deployar.
USER nodeuser: No corrás tu app como root adentro del contenedor. Es un principio básico de seguridad que mucha gente ignora hasta que tiene un problema.
Variables de entorno: hacelo bien o no lo hagas
Nunca hardcodees secretos en el Dockerfile ni en el docker-compose.yml que commitás. La forma correcta:
Para desarrollo, usá un .env local (que está en tu .dockerignore y .gitignore) y referencialo en compose:
services:
app:
env_file:
- .env
Para producción, usás los secrets de tu plataforma: Railway, Render, Fly.io, o las variables de entorno de tu CI/CD. Docker Swarm y Kubernetes tienen sus propios sistemas de secrets. Lo importante es que el secreto nunca viva en el código ni en la imagen.
El workflow que uso hoy
Después de todos los tropiezos, mi flujo actual es:
# Desarrollo con hot reload
docker compose up
# Rebuild forzado cuando cambio dependencias
docker compose up --build
# Correr en background
docker compose up -d
# Ver logs en tiempo real
docker compose logs -f app
# Entrar al contenedor a debuggear
docker compose exec app sh
# Limpiar todo y empezar de cero
docker compose down -v
El docker compose exec app sh es tu mejor amigo para debuggear. Entrás al contenedor vivo, podés correr comandos, verificar que las variables de entorno están como esperás, ver si los archivos están donde tienen que estar.
Producción: lo que nadie te dice
Para producción real, algunas cosas que aprendí caro:
Health checks en el Dockerfile:
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"
Tu orquestador (sea Compose, Swarm o Kubernetes) necesita saber si tu app está viva. Sin health check, puede estar sirviendo errores 500 y el orquestador sigue creyendo que todo está bien.
NODE_ENV=production: Setéalo siempre. Express, entre otros frameworks, tiene optimizaciones específicas para este modo.
Manejo de señales: Node.js adentro de Docker necesita manejar SIGTERM para hacer graceful shutdown. Si no lo implementás, Docker mata el proceso después del timeout y podés perder requests en vuelo. Es un tema que da para otro post entero.
Conclusión: el dolor vale la pena
Docker con Node.js tiene una curva de aprendizaje real. Te va a romper cosas. Vas a tener imágenes que pesan 2GB cuando deberían pesar 200MB. Vas a tener contenedores que no arrancan por problemas de permisos a las 2 de la mañana.
Pero cuando lo tenés aceitado, la sensación de docker compose up y tener todo tu stack corriendo en 30 segundos, en cualquier máquina, con exactamente las mismas versiones de todo, es difícil de superar.
El día que un compañero clonó mi repo y tuvo el proyecto corriendo en 5 minutos sin instalar nada más que Docker, entendí por qué vale el dolor inicial.
El Dockerfile de producción bien hecho es uno de los activos más valiosos de tu proyecto. Tratálo como código, evoluciónalos, revisalos en code review. No es solo infraestructura — es la receta de cómo vive tu app en el mundo.
Comentarios (0)
Deja un comentario
Artículos Relacionados
pnpm vs npm vs yarn vs bun: la comparativa definitiva que nadie te va a dar en 2025
Usé los cuatro en proyectos reales. Uno me rompió un monorepo a las 3am. Otro me salvó la vida en producción. Te cuento todo sin filtros.
Sandboxes para agentes de código: qué es Freestyle y por qué me importa
Cuando empecé a usar agentes de código en proyectos reales, el mayor miedo no era que escribieran mal — era que ejecutaran cosas en mi máquina sin que yo entendiera qué. Freestyle llegó al HN con 188 puntos tocando exactamente ese nervio.
El stack tecnológico perfecto en 2025: lo que elegiría si arrancara un proyecto hoy
Después de años rompiendo cosas en producción, acá está mi stack ideal para 2025. Sin hype, sin vendor lock-in innecesario, y con las cicatrices suficientes para justificar cada decisión.