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

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 %.
¿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
Step 1: Crear usuario no root en Dockerfile
Crear usuario y grupo dedicados: -
2
Step 2: Resolver enlace de puertos
Resolver enlace de puertos: -
3
Step 3: Parámetros de seguridad en runtime
Parámetros de seguridad en runtime: -
4
Step 4: Control fino con Capabilities
Control fino con Capabilities: -
5
Step 5: AppArmor/SELinux
Debian/Ubuntu: AppArmor (perfil docker-default). RHEL/CentOS: SELinux. El perfil por defecto ya es estricto. -
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:
- Puerto alto (recomendado): 3000 u 8080 + Nginx o balanceador
- 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 app | Capabilities necesarias | Notas |
|---|---|---|
| Web (puerto alto) | Ninguna | 3000+ no requiere extra |
| Web (puerto bajo) | NET_BIND_SERVICE | 80/443 |
| Base de datos (MySQL/Postgres) | Ninguna | Puertos altos por defecto |
| Nginx/Caddy | NET_BIND_SERVICE | Si escuchan 80/443 directo |
| VPN/herramientas de red | NET_ADMIN | Rutas 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=unconfinedolabel=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, nonode:latest) - ✅ Usuario no root con UID/GID fijos
- ✅
COPY --chownpara propietarios - ✅
USERdespué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
--privilegedo Capabilities peligrosas - ✅ Revisar red y puertos expuestos
Preguntas frecuentes
Q1: Tras pasar a no root, la app falla por permisos
Diagnóstico:
- Lee el error (archivo o puerto)
- Archivo: revisa
--chowny permisos de directorios en Dockerfile - 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
| Escenario | Alternativa sin root |
|---|---|
| Puertos 80/443 | NET_BIND_SERVICE o puerto alto + balanceador |
| Paquetes del sistema | Instalar en Dockerfile antes de USER |
| Config del sistema | Variables de entorno o archivos de config |
| Docker Socket | Muy 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:
- Versión oficial no root (
-rootless,-nonroot) --useren runtime
docker run --user=65534:65534 third-party-image # nobody
- Dockerfile propio sobre la imagen
FROM third-party-image:latest
RUN adduser -D -u 5000 appuser
USER appuser
- 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
--useren 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:
- Revisa tus Dockerfiles y añade USER donde falte
- Audita producción: contenedores
--privilegedo root; cámbialos - 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?
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?
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?
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?
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?
• --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?
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?
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
Guía práctica de Docker
Si llegaste desde búsqueda, lo más rápido es ir al artículo anterior o siguiente de esta misma serie.
Anterior
Escaneo y corrección de seguridad de imágenes Docker: tutorial práctico con Trivy e integración CI/CD
El 76% de las imágenes en Docker Hub tienen vulnerabilidades de seguridad. Esta guía explica el uso práctico de Trivy, métodos sistemáticos de corrección y la integración automatizada en CI/CD, con ejemplos de comandos completos para construir aplicaciones contenedorizadas seguras.
Parte 29 de 38
Siguiente
Guía completa de Docker Secrets: mejores prácticas para gestionar contraseñas y claves API en contenedores
Deja de escribir la contraseña de la base de datos en el Dockerfile. Esta guía explica cómo gestionar información sensible con Docker Secrets, compara Docker/K8s/Vault y ofrece un checklist completo para producción.
Parte 31 de 38



Comentarios
Inicia sesión con GitHub para dejar un comentario