Buenas prácticas de Docker que tu asistente de IA no aplica por defecto
Mantengo 6 proyectos con Docker — un monorepo Next.js, un pipeline de análisis de emails en Python, una red social con FastAPI, un blog estático en Astro, y un par de entornos de desarrollo. La semana pasada los audité todos.
El patrón fue claro: los Dockerfiles que escribí con asistencia de IA (Claude Code, en mi caso) empezaron débiles. ¿Usuarios no-root? Ausentes. ¿Filesystems read-only? Nunca sugerido. ¿Manejo de señales con tini? Ni una vez.
La IA acertó en lo básico — FROM, COPY, RUN, CMD. Pero el hardening de seguridad y operacional? Tuve que aprenderlo, pedirlo, y exigirlo.
Esto es lo que encontré y lo que deberías revisar en tus propios Dockerfiles.
Tabla de contenidos
- Correr como no-root
- Pinear las imágenes base
- Filesystems read-only
- Eliminar capabilities
- Manejo de señales con tini
- Volúmenes named para node_modules
- Aislamiento de red
- gVisor para workloads no confiables
- Límites de recursos
- .dockerignore
- Health checks
- El problema con la IA
1. Correr como no-root — siempre
La práctica de seguridad más básica, y los asistentes de IA casi nunca la aplican sin que la pidas.
# ❌ Mal — corre como root por defecto
FROM node:22-alpine
WORKDIR /app
COPY . .
CMD ["node", "server.js"]
# ✅ Bien — usa el usuario built-in node (UID 1000)
FROM node:22-alpine
WORKDIR /app
COPY --chown=node:node . .
USER node
CMD ["node", "server.js"]
Para Python, crea tu propio usuario:
FROM python:3.12-slim
RUN groupadd -g 1000 app && useradd -u 1000 -g app app
WORKDIR /app
COPY --chown=app:app . .
USER app
Por qué importa: si alguien explota tu app, obtiene root en el container. Con un usuario no-root, el radio de daño baja dramáticamente.
Por qué la IA no lo hace: funciona sin él. El Dockerfile compila, la app corre, los tests pasan. No-root es invisible hasta que algo sale mal.
2. Pinear las imágenes base
# ❌ "latest" hoy, roto mañana
FROM node:22-alpine
# ✅ Builds reproducibles
FROM node:22.22.0-alpine3.23
Aprendí esto cuando una actualización menor de Alpine rompió un build un lunes a las 8am. Pinea la versión completa.
Checklist rápido:
- Usar tags exactos, no solo major (
node:22-alpine) - Incluir la versión del OS (
alpine3.23, no soloalpine) - Actualizar intencionalmente, no accidentalmente
3. Filesystems read-only donde sea posible
# docker-compose.yml
services:
api:
image: my-api
read_only: true
tmpfs:
- /tmp:noexec,nosuid,nodev
- /app/.next/cache:noexec,nosuid
Si tu app no necesita escribir a disco (la mayoría de las APIs no), haz el filesystem read-only. Combinado con tmpfs para directorios temporales, bloquea toda una clase de ataques.
En mi pipeline de análisis de emails, los servicios que solo leen datos montados corren con read_only: true. Si algo se compromete, no puede escribir un backdoor.
Dónde usarlo:
- ✅ APIs, proxies (nginx, Caddy), servidores estáticos
- ✅ Workers que procesan datos desde volúmenes
- ❌ Apps que escriben logs a disco, compilan assets, o manejan estado
4. Eliminar todas las capabilities, agregar solo las necesarias
services:
api:
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE # solo si bindeas a puertos < 1024
security_opt:
- no-new-privileges:true
Las capabilities de Linux son un sistema de permisos granular. Por defecto, los containers Docker reciben varias. Elimínalas todas y agrega solo lo que necesitas.
cap_drop: ALL— elimina todocap_add: NET_BIND_SERVICE— solo si necesitas puertos bajo 1024no-new-privileges: true— previene escalación de privilegios vía setuid
La mayoría de las apps necesitan cero capabilities. Si la tuya necesita, pregúntate por qué.
5. Manejar señales correctamente con tini
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]
Sin tini, npm o node corren como PID 1 y no manejan SIGTERM correctamente:
| Sin tini | Con tini | |
|---|---|---|
| Tiempo de parada | ~10 segundos (kill forzado) | <1 segundo (cierre limpio) |
| Procesos zombie | Se acumulan | Correctamente recolectados |
| Forwarding de señales | Roto | Funciona correctamente |
Esto casi nunca lo sugiere la IA. Lo descubrí preguntándome por qué mis containers eran tan lentos al reiniciar.
6. Volúmenes named para node_modules
services:
dev:
volumes:
- ./src:/app/src # tu código (hot reload)
- app_modules:/app/node_modules # aislado, rápido
volumes:
app_modules:
Montar tu node_modules del host en el container causa dos problemas:
- Lento en macOS/WSL — overhead de traducción de filesystem en cada acceso
- Incompatibilidades de plataforma — binarios nativos compilados para tu host (macOS/Windows) no funcionan en Alpine Linux
Los volúmenes named mantienen node_modules dentro del filesystem del container — más rápido y correcto.
7. Aislamiento de red
services:
# Este servicio NO tiene acceso a internet
processor:
networks:
- nonet
# Este servicio solo escucha localmente
api:
ports:
- "127.0.0.1:8080:8080" # NO 0.0.0.0
networks:
- localonly
networks:
nonet:
internal: true # sin gateway a internet
localonly:
driver: bridge
Mi pipeline de análisis de emails procesa contenido potencialmente malicioso. Los servicios que parsean adjuntos tienen cero acceso a internet — si un adjunto explota una vulnerabilidad, no puede llamar a casa.
Reglas generales:
- Servidores de desarrollo → siempre bindear a
127.0.0.1, no0.0.0.0 - Servicios procesando datos no confiables → red
internal: true - Bases de datos → nunca exponer puertos al host a menos que necesites un GUI client
8. gVisor para workloads no confiables
services:
parser:
runtime: runsc # sandbox gVisor
read_only: true
cap_drop:
- ALL
networks:
- nonet
gVisor es un kernel de aplicación que intercepta system calls y los ejecuta en un sandbox. Los containers Docker normales comparten el kernel del host — si un container escapa, tiene acceso al kernel. gVisor agrega una capa entre el container y el kernel real.
Lo uso en mi pipeline de análisis de emails para los servicios que parsean adjuntos potencialmente maliciosos. Combinado con cero acceso a internet y filesystem read-only, incluso si un adjunto explota una vulnerabilidad:
- ❌ No puede acceder a la red
- ❌ No puede escribir a disco
- ❌ No puede alcanzar el kernel real
- ❌ No puede escalar privilegios
Cuándo usarlo:
- ✅ Procesando input no confiable — uploads, adjuntos de email, código de usuarios
- ❌ Entornos de desarrollo, servicios internos de confianza — el overhead no vale la pena
La IA nunca va a sugerir esto. Es demasiado nicho. Pero para workloads sensibles a seguridad, cambia el juego.
9. Límites de recursos
services:
api:
mem_limit: 512m
cpus: 1.0
Sin límites, un memory leak o un crypto miner en un container comprometido se come toda tu máquina. Pon límites razonables.
Guía rápida:
- Empezar con 256m–512m para APIs Node.js/Python
- 1.0 CPU suele ser suficiente para un servicio individual
- Monitorear uso real y ajustar —
docker statses tu amigo
10. El .dockerignore importa
node_modules
.git
.env*
!.env.example
*.log
docs/
*.md
coverage/
.next
dist
Todo archivo que no esté en .dockerignore se envía al daemon de Docker como contexto de build:
- Una carpeta
node_modules→ +500MB a cada build - Una carpeta
.git→ +100MB de historial que no necesitas - Archivos
.env→ secrets en las capas de la imagen (incluso si los borras después, están en el cache de capas)
Mi proyecto CPA25 tiene el
.dockerignoremás estricto — excluye archivos de test, documentación, logs, y todo lo que no se necesita en runtime.
11. Health checks
services:
db:
image: postgres:17-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
api:
depends_on:
db:
condition: service_healthy
Sin health checks, depends_on solo espera a que el container inicie, no a que el servicio esté listo.
Patrones comunes de health check:
- PostgreSQL:
pg_isready -U postgres - Redis:
redis-cli ping - HTTP API:
curl -f http://localhost:8080/health - Python:
python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000')"
El problema con la IA
Uso Claude Code para la mayoría de mi desarrollo. Es excelente escribiendo Dockerfiles que funcionan. Pero “funciona” y “listo para producción” son cosas distintas.
Cuando pido “dockeriza este proyecto”, obtengo:
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
Funciona. Es correcto. También le falta cada práctica de este post.
No es un bug — es el default. La IA optimiza para “¿funciona?” no para “¿está hardeneado?”. Y está bien, mientras sepas qué pedir.
Qué hacer
- Guarda este post — o ponlo donde tu asistente de IA pueda leerlo
- La próxima vez que dockerices un proyecto, pásale el link y dile: “Lee esto y aplica estas prácticas a mi Dockerfile”
- Audita tus Dockerfiles existentes — la mayoría probablemente corren como root ahora mismo
La IA es una gran herramienta. Pero necesita contexto sobre tus estándares. No va a aplicar buenas prácticas de seguridad a menos que se lo digas — o a menos que tu CLAUDE.md (o archivo de configuración equivalente) las incluya.
Pro tip: agrega una línea al archivo de configuración de IA de tu proyecto: “Todos los Dockerfiles deben seguir las prácticas de [este post]. Usuario no-root, imágenes pineadas, read-only donde sea posible, cap_drop ALL, tini, health checks.”
Ahora tu IA las aplica por defecto, cada vez.
Estas prácticas vienen de auditar 10 Dockerfiles en 6 proyectos durante 2 años. Algunas las aprendí de incidentes en producción, otras de auditorías de seguridad ejecutadas por agentes de IA. El hilo común: ninguna fue sugerida por defecto.