Un contenedor que corre localmente no es un contenedor listo para producción. La diferencia no está en que "funcione" — está en el peso de la imagen, la superficie de ataque, el tiempo de build, y si los secretos están donde no deben.
Estos son los problemas más comunes y cómo resolverlos.
Usá imágenes base livianas
La imagen base define el punto de partida de todo lo que construís encima. Una imagen de Ubuntu o Debian completa incluye cientos de paquetes que tu aplicación nunca va a usar — y cada uno es superficie de ataque potencial además de peso innecesario.
Las variantes Alpine resuelven esto. node:20-alpine en lugar de node:20 puede significar la diferencia entre 1GB y 170MB de imagen final.
# Evitar
FROM node:20
# Preferir
FROM node:20-alpine
Si tu aplicación compila binarios estáticos (Go, Rust), podés ir más lejos todavía con FROM scratch — una imagen completamente vacía donde solo existe tu binario.
Menos capas, mejor cache
Cada instrucción RUN, COPY o ADD crea una capa nueva en la imagen. Más capas significa más overhead y peor aprovechamiento del cache de Docker.
# Mal — tres capas para tres comandos relacionados
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# Bien — una capa, mismo resultado
RUN apt-get update \
&& apt-get install -y curl \
&& rm -rf /var/lib/apt/lists/*
El orden también importa para el cache: las instrucciones que cambian poco van arriba, las que cambian frecuentemente van abajo. Docker invalida el cache desde la primera instrucción que cambió hacia adelante — si copiás el código fuente antes de instalar dependencias, cualquier cambio de código invalida el cache de las dependencias.
# Bien — dependencias cacheadas separado del código
COPY package*.json ./
RUN npm ci
COPY . .
Multi-stage builds
El compilador, las herramientas de build, y las dependencias de desarrollo no tienen nada que hacer en la imagen que corre en producción. Los multi-stage builds separan el entorno de compilación del entorno de ejecución.
# Stage 1: compilación
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o servidor .
# Stage 2: imagen final
FROM alpine:3.19
WORKDIR /app
COPY --from=builder /app/servidor .
CMD ["./servidor"]
La imagen final solo contiene el binario compilado y Alpine. Todo el toolchain de Go queda fuera. El resultado puede ser diez veces más liviano que una imagen que compila y corre en el mismo stage.
El mismo patrón aplica a Node.js: un stage instala todas las dependencias y buildea, el stage final solo copia los archivos compilados y las dependencias de producción.
Los secretos no van en el Dockerfile
Un secreto en un ARG o ENV del Dockerfile queda en el historial de la imagen. Aunque lo sobreescribas en un layer posterior, está ahí — cualquiera con acceso a la imagen puede recuperarlo con docker history.
# Nunca hacer esto
ENV DATABASE_PASSWORD=mipassword123
ARG API_KEY=abc123
Los secretos se inyectan en runtime, no en build time. Docker tiene soporte nativo para esto:
# Inyección en runtime
docker run -e DATABASE_URL=$DATABASE_URL mi-imagen
# O con un archivo de variables de entorno
docker run --env-file .env mi-imagen
Para secretos en builds (tokens para registries privados, por ejemplo), Docker BuildKit tiene soporte para --secret que monta el secreto sin dejarlo en el historial de la imagen.
No corrás como root
Por defecto, los procesos dentro de un contenedor corren como root. Si hay una vulnerabilidad que permite escapar del contenedor, el atacante sale con permisos de root en el host.
Crear un usuario sin privilegios es una línea de Dockerfile:
FROM node:20-alpine
WORKDIR /app
COPY --chown=node:node . .
RUN npm ci --only=production
USER node
CMD ["node", "server.js"]
node:alpine ya incluye un usuario node sin privilegios. En otras imágenes base podés crearlo explícitamente:
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
.dockerignore siempre
El contexto de build es todo lo que Docker envía al daemon para construir la imagen. Sin un .dockerignore, eso incluye node_modules, .git, archivos .env, logs — todo.
# .dockerignore
node_modules
.git
.env
*.log
dist
coverage
Un contexto de build innecesariamente grande ralentiza cada build y puede filtrar información sensible a la imagen si hay un COPY . . genérico.
Monitoreá lo que consume
docker stats da una vista en tiempo real del consumo de CPU, memoria y red de cada contenedor corriendo:
docker stats
# CONTAINER ID CPU % MEM USAGE / LIMIT MEM % NET I/O
# a1b2c3d4e5f6 0.5% 128MiB / 2GiB 6.25% 1.2MB / 800kB
Si un contenedor que debería consumir 100MB está usando 1GB, hay un problema — y docker stats es la forma más rápida de verlo. Para ambientes más complejos, Prometheus con cAdvisor da métricas históricas y alertas.
El Dockerfile como documento de arquitectura
Un Dockerfile bien escrito comunica decisiones: qué imagen base y por qué, qué usuario corre el proceso, qué no incluir en la imagen final. Los mismos principios que hacen al código de aplicación mantenible aplican acá.
Un contenedor que alguien puede entender, auditar y modificar sin miedo es tan valioso como uno que simplemente funciona.