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

  1. Correr como no-root
  2. Pinear las imágenes base
  3. Filesystems read-only
  4. Eliminar capabilities
  5. Manejo de señales con tini
  6. Volúmenes named para node_modules
  7. Aislamiento de red
  8. gVisor para workloads no confiables
  9. Límites de recursos
  10. .dockerignore
  11. Health checks
  12. 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 solo alpine)
  • 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 todo
  • cap_add: NET_BIND_SERVICE — solo si necesitas puertos bajo 1024
  • no-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 tiniCon tini
Tiempo de parada~10 segundos (kill forzado)<1 segundo (cierre limpio)
Procesos zombieSe acumulanCorrectamente recolectados
Forwarding de señalesRotoFunciona 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:

  1. Lento en macOS/WSL — overhead de traducción de filesystem en cada acceso
  2. 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, no 0.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 stats es 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 .envsecrets en las capas de la imagen (incluso si los borras después, están en el cache de capas)

Mi proyecto CPA25 tiene el .dockerignore má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

  1. Guarda este post — o ponlo donde tu asistente de IA pueda leerlo
  2. La próxima vez que dockerices un proyecto, pásale el link y dile: “Lee esto y aplica estas prácticas a mi Dockerfile”
  3. 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.