Cambiar tema

Seguridad en Docker: guía completa para evitar ejecutar contenedores como root

Easton editorial illustration: environment switchboard

El mes pasado ayudé a un amigo a revisar la configuración de contenedores de su empresa. Un docker inspect rápido: todo corría como root, algunos con --privileged. Dos semanas después, un contenedor fue comprometido y el atacante escapó al host.

No es alarmismo. En enero de 2024 salió a la luz CVE-2024-21626: basta con controlar el directorio de trabajo del contenedor para explotar descriptores de archivo filtrados y manipular el sistema de archivos del host. Datos de NSFOCUS: el 76 % de las imágenes en Docker Hub tienen vulnerabilidades y el 67 % son de alto riesgo.

Antes no le daba importancia: FROM ubuntu, RUN apt-get install, todo dentro del contenedor, ¿no estaba bien aislado? Hasta que en un entorno de pruebas de carga penetraron un contenedor y en los logs vi comandos para montar el disco del host. Se me heló la sangre.

Pasar de root a usuario no root no es tan difícil. Hoy veremos por qué root por defecto es peligroso, cómo crear usuarios no root en el Dockerfile, el parámetro --user, y Capabilities y AppArmor. Al terminar, podrás reducir el riesgo de tus contenedores en producción en torno a un 80 %.

76%
Imágenes con vulnerabilidades
Estadísticas Docker Hub
67%
Vulnerabilidades de alto riesgo
Investigación NSFOCUS
80%
Reducción de riesgo
Configuración no root
Source: Informe de investigación NSFOCUS

¿Por qué no ejecutar contenedores como root?

Escape de contenedor: del sandbox al host en un paso

Muchos piensan que contenedor = sandbox y que nada afecta al host. La realidad: el aislamiento usa namespaces y cgroups de Linux, no hardware como en una VM. Con mala configuración o un bug del kernel, esa capa se rompe.

CVE-2024-21626 es un ejemplo duro. runc (runtime de Docker) filtraba un descriptor hacia el sistema de archivos del host al manejar el directorio de trabajo. En la práctica, un atacante puede leer y escribir cualquier archivo del host, incluso sobrescribir /usr/bin/bash. Si comprometen tu contenedor web, pueden sustituir todos los contenedores del host por miners: no es hipotético, ha ocurrido.

Otra vía habitual es --privileged: le das al contenedor casi todos los permisos del host. El root del contenedor puede montar dispositivos, cargar módulos del kernel, cambiar la red… NSFOCUS documentó un caso: con un contenedor privilegiado, mount /dev/sda1 /mnt y un cron que exfiltra datos en menos de diez minutos.

Riesgo sutil: montar el Docker Socket. Por comodidad montan /var/run/docker.sock para gestionar contenedores desde dentro. Es la llave de todos los contenedores del host. Un atacante puede crear otro contenedor privilegiado y escapar. El equipo de seguridad de Tencent Cloud documentó esa cadena.

¿Por qué root es la mayor brecha?

El núcleo: el UID 0 root del contenedor y el UID 0 root del host son el mismo usuario.

¿Y el namespace? Existe, pero el de UID no va activado por defecto (compatibilidad). Si hay un bug del kernel o falla el namespace, el proceso root en el host sigue siendo root. En pruebas, con SYS_ADMIN y root monté procfs del host y escribí un reverse shell en /proc/sys/kernel/core_pattern: shell root en el host, más fácil de lo que pensaba.

El informe de seguridad de Alibaba cita cinco causas principales de escape: bugs del kernel, configuración incorrecta, imágenes inseguras, abuso de permisos y comunicación insegura entre contenedores. Cuatro se relacionan con root. Usuario no root reduce al menos tres de esos riesgos.

Escenario real: muchas apps Node.js escuchan en el 80, que en Linux requiere root. Si hay path traversal en Express y leen /etc/passwd, pueden intentar SSH al host sin necesidad de escape.

Suena duro, pero la solución es principio de mínimo privilegio: solo lo que la app necesita, no root por defecto.

Configurar usuario no root: empezar por el Dockerfile

Configuración segura de contenedores Docker con usuario no root

Guía completa desde crear usuario no root en Dockerfile hasta parámetros de runtime, para reducir el riesgo de contenedores en un 80 %

Estimated time: PT30M

  1. 1

    Step 1: Crear usuario no root en Dockerfile

    Crear usuario y grupo dedicados:
  2. 2

    Step 2: Resolver enlace de puertos

    Resolver enlace de puertos:
  3. 3

    Step 3: Parámetros de seguridad en runtime

    Parámetros de seguridad en runtime:
  4. 4

    Step 4: Control fino con Capabilities

    Control fino con Capabilities:
  5. 5

    Step 5: AppArmor/SELinux

    Debian/Ubuntu: AppArmor (perfil docker-default). RHEL/CentOS: SELinux. El perfil por defecto ya es estricto.
  6. 6

    Step 6: Escaneo y monitorización

    Trivy o docker scan periódico; corregir vulnerabilidades críticas antes de desplegar. Monitorizar reinicios anómalos y uso de recursos.

Crear correctamente un usuario no root

Patrón estándar:

FROM node:18-alpine

# Crear usuario y grupo dedicados (UID/GID fijos)
RUN addgroup -g 5000 appgroup \
    && adduser -D -u 5000 -G appgroup appuser

# Directorio de trabajo
WORKDIR /app

# Copiar con propietario correcto (¡clave!)
COPY --chown=appuser:appgroup package*.json ./
RUN npm install
COPY --chown=appuser:appgroup . .

# Cambiar a no root (todo lo siguiente corre como appuser)
USER appuser

# Arranque
CMD ["node", "server.js"]

Cada línea importa.

¿Por qué UID/GID fijos? Sin números, el sistema asigna IDs distintos entre contenedores y los volúmenes compartidos fallan en permisos. UID 5000 fijo en todos reduce esos problemas.

¿Qué aporta COPY --chown? COPY + RUN chown crea dos capas: copia como root y luego cambio de dueño. --chown fija el dueño al copiar: menos capas y más seguro. Olvidé chown una vez y la app falló con «Permission denied» media hora hasta localizarlo.

Posición de USER. Antes de USER, root puede instalar (npm install en /app); después, appuser. USER demasiado pronto rompe instalaciones. Regla: todo lo que necesite root va antes de USER.

Trampas habituales

Trampa 1: puertos

Cambias a no root y falla: Error: listen EACCES: permission denied 0.0.0.0:80. Puertos < 1024 requieren privilegio.

Soluciones:

  1. Puerto alto (recomendado): 3000 u 8080 + Nginx o balanceador
  2. NET_BIND_SERVICE: permite enlazar puertos bajos sin root
# La app escucha en 3000
EXPOSE 3000
USER appuser
CMD ["node", "server.js"]  # escucha 3000 internamente

En docker-compose o K8s:

ports:
  - "80:3000"  # host 80 → contenedor 3000

Trampa 2: logs y temporales

Una app Python en no root falló porque escribía en /var/log sin permiso.

# Directorio de logs para el usuario de la app
RUN mkdir -p /var/log/myapp && \
    chown -R appuser:appgroup /var/log/myapp

USER appuser

Mejor: stdout/stderr y recolección por Docker/K8s:

# Sin logs en archivo
logging.basicConfig(stream=sys.stdout, level=logging.INFO)

Trampa 3: permisos del volumen

/data en el host es de root; montado en el contenedor, appuser (UID 5000) no puede leer.

# Mal
docker run -v /data:/app/data myapp
# appuser no puede leer/escribir /app/data

Soluciones:

# 1: permisos en el host
sudo chown -R 5000:5000 /data

# 2: volumen con nombre
docker volume create appdata
docker run -v appdata:/app/data myapp

Parámetros de runtime: —user y más

—user para sobrescribir la imagen

Imagen de terceros sin USER, todo root. Reconstruir es pesado; --user en runtime:

# 1: UID:GID directo
docker run --user=1001:1001 nginx:latest

# 2: usuario del host (desarrollo)
docker run --user="$(id -u):$(id -g)" -v "$PWD:/app" node:18 npm test

# 3: nombre de usuario (si existe en la imagen)
docker run --user=nobody redis:alpine

La opción 2 en local es muy práctica: tests generan archivos con tu UID en el host, sin sudo para borrar.

--user sobrescribe USER del Dockerfile. Si la imagen ya es no root y pones --user=0:0, vuelves a root. Revisa la imagen antes.

Sistema de archivos de solo lectura

Si un atacante entra y el FS es solo lectura, no puede plantar malware ni cambiar config fácilmente.

# Solo lectura simple
docker run -d --read-only nginx:alpine

# ¿Temporales?
docker run -d \
  --read-only \
  --tmpfs /tmp \
  --tmpfs /var/run \
  nginx:alpine

--tmpfs monta RAM; al reiniciar se vacía. Un API con logs a stdout, sesiones en Redis y sin escritura persistente: con solo lectura, un shell comprometido sirve de poco.

Prohibir escalada: no-new-privileges

Evita que procesos escalen vía setuid/setgid. Aunque exista /bin/su con SUID, no suben a root.

docker run --security-opt=no-new-privileges myapp

En pruebas, sudo en un contenedor con esta opción devuelve «effective uid is not 0». Muy útil contra escalada.

Producción: combinación

docker run -d \
  --name secure-webapp \
  --user=5000:5000 \         # no root
  --read-only \               # solo lectura
  --tmpfs /tmp:size=64M \     # 64 MB temporales
  --security-opt=no-new-privileges \  # sin escalada
  --cap-drop=ALL \            # quitar todas las Capabilities
  --cap-add=NET_BIND_SERVICE \ # solo puerto si hace falta
  -p 443:8443 \               # mapeo
  -v appdata:/app/data \      # volumen escribible
  --memory=512m \             # límite RAM
  --cpus=1.0 \                # límite CPU
  myapp:1.0.0

Cada flag tiene un propósito. Varios servicios core llevan esta plantilla más de dos años sin incidentes. El coste: depurar sin exec y editar archivos dentro del contenedor; vale la pena.

En K8s, SecurityContext equivalente:

securityContext:
  runAsNonRoot: true
  runAsUser: 5000
  readOnlyRootFilesystem: true
  allowPrivilegeEscalation: false
  capabilities:
    drop: ["ALL"]
    add: ["NET_BIND_SERVICE"]

Control fino: Capabilities

¿Qué son y por qué son más seguras que root?

En Linux clásico: todo o nada. Las Capabilities dividen root en más de 40 «capacidades» independientes; solo das las necesarias.

Root es una tarjeta maestra de todas las puertas; Capabilities son llaves por habitación. Un atacante con una llave solo entra donde tengas permiso.

Docker deja 14 Capabilities por defecto, entre ellas:

  • CHOWN: cambiar dueño de archivos
  • NET_BIND_SERVICE: puertos < 1024
  • SETUID/SETGID: cambiar UID/GID
  • KILL: señales a otros procesos
  • DAC_OVERRIDE: saltar permisos de archivos

Para muchas apps basta. Otras son peligrosas y hay que quitarlas.

Capabilities peligrosas (¡no las des!)

SYS_ADMIN — casi medio root

Montar FS, cambiar namespaces, cargar módulos del kernel… Caso real: contenedor con SYS_ADMIN, el atacante usa unshare, monta el disco del host, fin del juego.

# ¡Nunca así!
docker run --cap-add=SYS_ADMIN myapp  # ❌ Peligroso

NET_ADMIN — red

Rutas, firewall, sniffing. Solo para herramientas de red (VPN, router software).

SYS_MODULE — módulos del kernel

Insertar código en el kernel. El riesgo habla por sí solo.

Mínimo privilegio en la práctica

Estrategia 1: quitar todo y añadir (recomendado)

docker run -d \
  --cap-drop=ALL \
  --cap-add=NET_BIND_SERVICE \
  --cap-add=CHOWN \
  myapp

Si falta algo, el error lo indica. setuid() sin permiso → añade --cap-add=SETUID.

Estrategia 2: quitar solo las peligrosas

docker run -d \
  --cap-drop=SYS_ADMIN \
  --cap-drop=NET_ADMIN \
  --cap-drop=SYS_MODULE \
  --cap-drop=SYS_RAWIO \
  myapp

Cuando no sabes el mínimo exacto pero quieres endurecer rápido.

¿Qué Capabilities necesita la app?

Método 1: prueba y error

docker run --cap-drop=ALL myapp
# bind: permission denied

docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE myapp
# arranca

Método 2: capsh

$ docker run --rm -it --cap-drop=ALL ubuntu capsh --print
Current: =
# vacío

$ docker run --rm -it ubuntu capsh --print
Current: = cap_chown,cap_dac_override,cap_fowner,...
# las 14 por defecto

Método 3: tabla de referencia

Tipo de appCapabilities necesariasNotas
Web (puerto alto)Ninguna3000+ no requiere extra
Web (puerto bajo)NET_BIND_SERVICE80/443
Base de datos (MySQL/Postgres)NingunaPuertos altos por defecto
Nginx/CaddyNET_BIND_SERVICESi escuchan 80/443 directo
VPN/herramientas de redNET_ADMINRutas e interfaces

La mayoría de apps de negocio van con drop all; a lo sumo NET_BIND_SERVICE.

Control de acceso obligatorio: AppArmor y SELinux

¿Para qué sirven?

Las Capabilities limitan qué operaciones puede hacer un proceso; AppArmor/SELinux limitan a qué archivos y recursos puede acceder. Son MAC a nivel de SO: incluso con root, el perfil manda.

Eres el jefe (root) pero la sala de servidores exige tarjeta. AppArmor/SELinux es el control de acceso.

Elección de sistema:

  • Debian/Ubuntu: AppArmor por defecto
  • RHEL/CentOS: SELinux por defecto
  • No actives ambos a la vez (conflictos)

AppArmor: simple y suficiente

Docker aplica el perfil docker-default, ya bastante estricto. En muchos casos no tocas nada.

docker inspect mycontainer | grep -i apparmor
# "AppArmorProfile": "docker-default"

¿Qué hace docker-default?

Impide al contenedor:

  • montar sistemas de archivos (mount)
  • cambiar parámetros del kernel (bajo /proc/sys/)
  • acceder a dispositivos sensibles del host (la mayoría de /dev/)
  • modificar la configuración de AppArmor

En pruebas, con AppArmor activo, incluso root dentro del contenedor: mount /dev/sda1 /mnt → «Permission denied». Capabilities + AppArmor multiplican la dificultad del escape.

SELinux: más potente, más complejo

Etiquetas (labels) en archivos y procesos; políticas definen qué etiqueta accede a cuál.

docker inspect mycontainer | grep -i selinux
# "ProcessLabel": "system_u:system_r:container_t:s0:c123,c456"

c123,c456 son categorías únicas por contenedor para aislar contenedor A de B.

SELinux tiene curva de aprendizaje y mensajes crípticos. En Ubuntu, AppArmor suele bastar; en RHEL, SELinux ya protege por defecto.

¿Cuál usar y cuándo?

Desarrollo

  • Puedes desactivar temporalmente (apparmor=unconfined o label=disable) para depurar
  • Pregúntate: ¿lo reactivarás al desplegar?

Pruebas

  • Actívalo con perfil por defecto
  • Detecta conflictos entre seguridad y funcionalidad pronto

Producción

  • Siempre activo, sin excepciones
  • Perfil por defecto salvo motivo fuerte para personalizar
  • Audita logs de intentos denegados

En mi experiencia, el 99 % de DENIED son legítimos: ataque o diseño de app deficiente. Rara vez hace falta relajar.

Lista de verificación de seguridad completa

Checklist ejecutable; si la sigues, vas por delante del 80 % en seguridad de contenedores.

Fase de construcción de imagen

Dockerfile:

  • ✅ Imagen base oficial o de confianza
  • ✅ Versión fija (node:18.17-alpine, no node:latest)
  • ✅ Usuario no root con UID/GID fijos
  • COPY --chown para propietarios
  • USER después de instalación, antes de arranque
  • ✅ Puerto alto (3000+) o Capabilities para bajos
  • ✅ Multi-stage para menos superficie de ataque
  • ✅ Sin secretos en la imagen (variables o secrets en runtime)

Fase de escaneo

# docker scan (Snyk)
docker scan myapp:latest

# Trivy (recomendado)
trivy image myapp:latest

# Clair en CI/CD
# Harbor con escaneo automático

El 76 % de imágenes en Hub tienen fallos: el escaneo periódico es obligatorio. En nuestro equipo:

  • Críticas: corregir antes de producción
  • Medias: evaluación de riesgo y monitorización
  • Bajas: registro y revisión periódica

Runtime

Plantilla docker-compose de producción:

services:
  myapp:
    image: myapp:1.0.0
    user: "5000:5000"           # no root
    read_only: true             # solo lectura
    tmpfs:
      - /tmp:size=64M           # temporales en RAM
    security_opt:
      - no-new-privileges:true  # sin escalada
      - apparmor=docker-default # AppArmor
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE
    volumes:
      - appdata:/app/data:rw    # lectura/escritura explícita
    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 512M
    ports:
      - "8080:8080"

Operaciones en producción

Monitorización diaria:

  • ✅ Reinicios anómalos del contenedor
  • ✅ Uso de CPU/RAM inusual (miners)
  • ✅ Auditoría de operaciones sobre contenedores

Auditoría periódica:

  • ✅ Escanear imágenes en ejecución cada mes
  • ✅ Detectar --privileged o Capabilities peligrosas
  • ✅ Revisar red y puertos expuestos

Preguntas frecuentes

Q1: Tras pasar a no root, la app falla por permisos

Diagnóstico:

  1. Lee el error (archivo o puerto)
  2. Archivo: revisa --chown y permisos de directorios en Dockerfile
  3. Puerto: puerto alto o NET_BIND_SERVICE

Errores típicos:

# Error: EACCES: permission denied, open '/app/logs/app.log'
# Causa: /app/logs sin permiso para appuser
# Fix:
RUN mkdir -p /app/logs && chown appuser:appgroup /app/logs

# Error: listen EACCES: permission denied 0.0.0.0:80
# Causa: no root en puerto bajo
# Fix1: escuchar 3000 y mapear
EXPOSE 3000
# Fix2: Capability
docker run --cap-add=NET_BIND_SERVICE myapp

Q2: Permisos del volumen no coinciden

Problema muy frecuente. Tres vías:

1: UID/GID en el host (recomendado)

sudo chown -R 5000:5000 /data
docker run -v /data:/app/data myapp

2: volumen con nombre

docker volume create --opt o=uid=5000,gid=5000 appdata
docker run -v appdata:/app/data myapp

Q3: ¿Cuándo hace falta root de verdad? Casi nunca

EscenarioAlternativa sin root
Puertos 80/443NET_BIND_SERVICE o puerto alto + balanceador
Paquetes del sistemaInstalar en Dockerfile antes de USER
Config del sistemaVariables de entorno o archivos de config
Docker SocketMuy peligroso; API de Docker o K8s en su lugar

Único caso razonable que vi: herramienta legacy de migración de BD que exige root (código del proveedor) y no se puede cambiar. Solución: contenedor desechable aislado, destruir tras la migración.

Q4: Imagen de terceros como root

Prioridad:

  1. Versión oficial no root (-rootless, -nonroot)
  2. --user en runtime
docker run --user=65534:65534 third-party-image  # nobody
  1. Dockerfile propio sobre la imagen
FROM third-party-image:latest
RUN adduser -D -u 5000 appuser
USER appuser
  1. Pedir al mantenedor una variante no root

Conclusión

En una frase: contenedor como root = puerta trasera para atacantes.

Repaso:

  • El escape no es teoría: CVE-2024-21626, contenedores privilegiados, montajes reales
  • USER en Dockerfile y --user en runtime: bajo coste, alto beneficio
  • Capabilities: drop all + añadir solo lo necesario
  • Solo lectura, no-new-privileges y AppArmor: defensa en capas
  • 76 % de imágenes con vulnerabilidades: escaneo periódico obligatorio

Tres acciones hoy:

  1. Revisa tus Dockerfiles y añade USER donde falte
  2. Audita producción: contenedores --privileged o root; cámbialos
  3. Integra escaneo de imágenes en CI/CD

La seguridad es continua. El primer paso —de root a no root— ya te pone por encima de la mayoría. No esperes al incidente; la lección del amigo sigue ahí.

FAQ

¿Por qué no ejecutar contenedores Docker como root?
El root del contenedor (UID 0) y el root del host (UID 0) son el mismo usuario; el namespace de UID no está activado por defecto. Si hay una vulnerabilidad del kernel o un error de configuración que invalida el namespace, un proceso root dentro del contenedor también tiene privilegios root en el host.

Vulnerabilidades de escape como CVE-2024-21626 demuestran que el aislamiento no es absoluto. El 76 % de las imágenes en Docker Hub tienen vulnerabilidades y el 67 % son de alto riesgo. Ejecutar con usuario no root puede reducir el riesgo en un 80 %.
¿Cómo configurar un usuario no root en el Dockerfile?
Crea un usuario y grupo dedicados (con UID/GID fijos, por ejemplo 5000), usa COPY --chown=appuser:appgroup para fijar el propietario de archivos y coloca USER después de la instalación y antes del arranque.

Las operaciones que requieren root van antes de USER. UID/GID fijos evitan problemas de permisos al montar volúmenes.
¿Qué hacer si un usuario no root no puede enlazar los puertos 80/443?
Dos opciones:

1) Usar puertos altos (recomendado):
• La app escucha en 3000 u 8080
• Nginx o un balanceador hace de proxy inverso
• Mapea el puerto 80 del host al 3000 del contenedor

2) Usar la Capability NET_BIND_SERVICE:
• Permite a usuarios no root enlazar puertos bajos
• Evita escuchar 80/443 directamente como root
¿Qué son las Capabilities y cómo configurar permisos mínimos?
Las Capabilities dividen los privilegios de root en más de 40 capacidades independientes; solo concedes las que el proceso necesita.

Buenas prácticas:
• Primero --cap-drop=ALL para vaciar todas las Capabilities
• Añade solo las necesarias (p. ej. NET_BIND_SERVICE para puertos)
• Nunca concedas SYS_ADMIN, NET_ADMIN, SYS_MODULE u otras peligrosas

La mayoría de apps de negocio funcionan con drop all; a lo sumo NET_BIND_SERVICE.
¿Qué parámetros de seguridad usar en producción?
Combinación de producción:
• --user=5000:5000 usuario no root
• --read-only con --tmpfs para directorios temporales
• --security-opt=no-new-privileges para prohibir escalada
• --cap-drop=ALL y añadir solo lo necesario
• AppArmor/SELinux para control de acceso obligatorio

En K8s, la misma política va en SecurityContext del Pod.
¿Qué hacer si los permisos no coinciden tras montar un volumen?
Tres soluciones:

1) Ajustar UID/GID en el host (recomendado):
• sudo chown -R 5000:5000 /data
• Debe coincidir con el UID del usuario del contenedor

2) Volumen con nombre gestionado por Docker:
• docker volume create --opt o=uid=5000,gid=5000 appdata

3) Crear y autorizar directorios para el usuario de la app en el Dockerfile
¿Cómo saber qué Capabilities necesita la aplicación?
Tres métodos:

1) Prueba y error:
• --cap-drop=ALL y revisar el error
• Añadir según el mensaje

2) Herramienta capsh:
• docker run --rm -it ubuntu capsh --print

3) Referencia por tipo de app:
• Web (puerto alto): sin permisos especiales
• Web (puerto bajo): NET_BIND_SERVICE
• VPN/herramientas de red: NET_ADMIN

13 min de lectura · Publicado el: 18 dic 2025 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog