Hardening de seguridad en Docker: modo rootless, seccomp y AppArmor en un VPS
Siete capas de hardening para Docker en un VPS. Cada sección explica la amenaza, muestra la solución con sintaxis CLI y Compose, y verifica que funciona.
La configuración por defecto de Docker sacrifica seguridad por conveniencia. Los contenedores se ejecutan como root en el host. Las 14 capabilities de Linux permanecen activas. Seccomp bloquea solo 44 de más de 300 syscalls. El tráfico entre contenedores fluye libremente.
En un VPS, esto importa más que en una máquina de desarrollo local. Compartes un host físico con otros inquilinos. Una fuga de contenedor significa que un atacante aterriza como root en el kernel expuesto al hipervisor. Cada capa de hardening que añades reduce el radio de explosión.
Este tutorial cubre siete medidas de hardening. Cada sección explica la amenaza que previene, muestra la implementación (tanto flags de docker run como sintaxis Compose) e incluye un paso de verificación. Probamos cada comando en Ubuntu 24.04 con Docker Engine 29.x.
Prerrequisitos: Un VPS con Debian 12 o Ubuntu 24.04 y Docker Engine instalado. Acceso SSH como usuario no root con sudo. Si aún no has asegurado el host, empieza con nuestra guía de seguridad Linux VPS. Para problemas de firewall con Docker, consulta Docker UFW Firewall Fix.
Este artículo es parte de la serie .
¿Cómo configuro rootless Docker en Ubuntu 24.04 o Debian 12?
Rootless Docker ejecuta el daemon y todos los contenedores bajo una cuenta de usuario normal en lugar de root. Si un atacante escapa de un contenedor, aterriza como usuario sin privilegios en el host. Sin acceso root. De todas las medidas de esta guía, esta tiene el mayor impacto.
Instalar rootless Docker
Instala los paquetes necesarios. El paquete uidmap proporciona newuidmap y newgidmap, que gestionan el mapeo de UIDs/GIDs subordinados:
sudo apt-get update && sudo apt-get install -y uidmap docker-ce-rootless-extras
Verifica que tu usuario tiene al menos 65 536 UIDs y GIDs subordinados:
grep "^$(whoami):" /etc/subuid
grep "^$(whoami):" /etc/subgid
Deberías ver algo como deploy:100000:65536. Si las entradas no existen, añádelas:
sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $(whoami)
Detén el daemon de Docker del sistema. No lo necesitas para el modo rootless:
sudo systemctl disable --now docker.service docker.socket
Ejecuta la herramienta de configuración rootless como tu usuario normal (no root):
dockerd-rootless-setuptool.sh install
El script imprime variables de entorno que necesitas configurar. Añádelas a tu perfil de shell:
echo 'export PATH=/usr/bin:$PATH' >> ~/.bashrc
echo 'export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock' >> ~/.bashrc
source ~/.bashrc
Habilita linger para que el daemon rootless arranque al inicio del sistema, no solo cuando inicias sesión:
sudo loginctl enable-linger $(whoami)
Verificar el modo rootless
docker context use rootless
docker run --rm hello-world
Comprueba que el proceso del daemon se ejecuta como tu usuario, no como root:
ps aux | grep dockerd
La salida debería mostrar tu nombre de usuario, no root. También confirma que Docker info reporta rootless:
docker info --format '{{.SecurityOptions}}'
Deberías ver rootless en la lista.
Cuando rootless Docker rompe cosas
El modo rootless tiene limitaciones reales. Conocerlas evita horas de depuración.
| Limitación | Causa | Solución alternativa |
|---|---|---|
| No puede enlazar puertos por debajo de 1024 | Los usuarios no root no pueden enlazar puertos privilegiados | Configura sysctl net.ipv4.ip_unprivileged_port_start=0 o usa un reverse proxy en el host |
| Errores de permisos en bind mount | Los archivos del host propiedad de root son inaccesibles para el UID remapeado | Cambia la propiedad a tu usuario, o usa named volumes |
| Sistema de archivos overlay más lento | Rootless usa fuse-overlayfs en lugar de overlay2 nativo |
Acepta la sobrecarga (5-15% para cargas intensivas de I/O) o usa overlay2 nativo con --privileged (anula el propósito) |
Sin --net=host |
La red rootless usa slirp4netns o pasta, no la pila de red del host | Usa port mapping (-p) en su lugar. Para mejor rendimiento, instala pasta como driver de red |
ping falla dentro de los contenedores |
CAP_NET_RAW está restringido |
Instala slirp4netns >= 0.4.0 o usa pasta |
Nota sobre rendimiento de red: Por defecto, rootless Docker usa slirp4netns para la red, lo que añade sobrecarga de NAT. El driver pasta copia la configuración de red del host en el namespace del contenedor sin NAT y ofrece mejor throughput. En Debian 12 y Ubuntu 24.04, instálalo con:
sudo apt-get install -y passt
Docker detecta pasta automáticamente si está instalado.
¿Cuándo debo usar user namespace remapping en lugar de rootless Docker?
User namespace remapping (userns-remap) mapea el UID 0 dentro de los contenedores a un UID sin privilegios en el host. A diferencia de rootless Docker, el daemon sigue ejecutándose como root. Esto significa que conservas toda la funcionalidad de Docker (puertos privilegiados, red del host, overlay2 nativo) mientras previenes que root-en-contenedor sea root-en-host.
Elige userns-remap cuando el modo rootless rompe tu carga de trabajo pero aún quieres aislamiento de UID. Elige rootless cuando puedas vivir con sus limitaciones.
| Característica | Rootless Docker | userns-remap | Docker estándar |
|---|---|---|---|
| El daemon se ejecuta como | Usuario | Root | Root |
| Root del contenedor = root del host | No | No | Sí |
| Puertos privilegiados | Necesita solución alternativa | Funciona | Funciona |
--net=host |
No | Sí | Sí |
| Driver de almacenamiento | fuse-overlayfs | overlay2 | overlay2 |
| Complejidad de configuración | Media | Baja | Ninguna |
Configurar userns-remap
Crea el usuario dockremap o usa el atajo default que lo crea automáticamente:
sudo tee /etc/docker/daemon.json > /dev/null <<'EOF'
{
"userns-remap": "default"
}
EOF
sudo systemctl restart docker
Docker crea el usuario dockremap y añade rangos de UID/GID subordinados a /etc/subuid y /etc/subgid.
Verifica que funciona:
sudo ls -ld /var/lib/docker/
Deberías ver un nuevo subdirectorio nombrado según el rango de UID remapeado, como /var/lib/docker/100000.100000/. El número exacto depende del rango de UID subordinado asignado al usuario dockremap en /etc/subuid.
Ejecuta un contenedor y comprueba el UID del proceso en el host:
docker run -d --name test-userns nginx:alpine
ps aux | grep nginx
El proceso nginx debería mostrar un UID alto (correspondiente al primer número de /etc/subuid para dockremap), no 0.
Limpieza:
docker rm -f test-userns
Problema con propiedad de volúmenes: Los archivos montados con bind que pertenecen a root del host (UID 0) aparecen como nobody dentro del contenedor porque el UID 0 se mapea a un rango diferente. Usa named volumes o haz chown de los archivos al UID remapeado.
¿Cómo elimino capabilities de Linux de un contenedor Docker?
Docker otorga 14 capabilities de Linux a los contenedores por defecto. Cada capability es un permiso del kernel que un atacante puede explotar tras una fuga de contenedor o dentro de un contenedor comprometido. Eliminar todas las capabilities y añadir solo las que tu aplicación necesita reduce la superficie de ataque.
Capabilities por defecto de Docker
| Capability | Qué permite | ¿Mantener o eliminar? |
|---|---|---|
CAP_CHOWN |
Cambiar propiedad de archivos | Eliminar salvo que sea necesario |
CAP_DAC_OVERRIDE |
Omitir comprobaciones de permisos de lectura/escritura | Eliminar salvo que sea necesario |
CAP_FOWNER |
Omitir comprobaciones de permisos sobre propietario | Eliminar salvo que sea necesario |
CAP_FSETID |
Establecer bits setuid/setgid | Eliminar |
CAP_KILL |
Enviar señales a cualquier proceso | Eliminar salvo que sea necesario |
CAP_SETGID |
Cambiar GID del proceso | Mantener para la mayoría de apps |
CAP_SETUID |
Cambiar UID del proceso | Mantener para la mayoría de apps |
CAP_SETPCAP |
Modificar capabilities del proceso | Eliminar |
CAP_NET_BIND_SERVICE |
Enlazar puertos por debajo de 1024 | Mantener si se enlaza el puerto 80/443 |
CAP_NET_RAW |
Usar raw sockets (crear paquetes) | Eliminar salvo que necesites ping/traceroute |
CAP_SYS_CHROOT |
Usar chroot | Eliminar |
CAP_MKNOD |
Crear archivos de dispositivo | Eliminar |
CAP_AUDIT_WRITE |
Escribir en el log de auditoría del kernel | Eliminar salvo que sea necesario |
CAP_SETFCAP |
Establecer capabilities de archivos | Eliminar |
Eliminar todas, añadir lo necesario
Sintaxis CLI:
docker run -d \
--cap-drop ALL \
--cap-add CHOWN \
--cap-add NET_BIND_SERVICE \
--cap-add SETUID \
--cap-add SETGID \
--name hardened-nginx \
nginx:alpine
Nginx necesita CHOWN porque su script de entrada cambia la propiedad de los directorios de caché al arrancar. Sin él, el contenedor se detiene inmediatamente con un error chown: Operation not permitted.
Sintaxis Compose:
services:
web:
image: nginx:alpine
cap_drop:
- ALL
cap_add:
- CHOWN
- NET_BIND_SERVICE
- SETUID
- SETGID
Verificar que las capabilities se eliminaron
docker exec hardened-nginx sh -c 'cat /proc/1/status | grep Cap'
Compara la máscara de bits CapEff (capabilities efectivas). Con todas eliminadas y cuatro añadidas, el valor es mucho menor que el 00000000a80425fb por defecto.
Para una salida legible, instala capsh en el host y decodifica el hexadecimal:
docker exec hardened-nginx sh -c 'cat /proc/1/status | grep CapEff' | awk '{print $2}' | xargs -I{} capsh --decode=0x{}
Deberías ver solo cap_chown,cap_setgid,cap_setuid,cap_net_bind_service en la salida.
Limpieza:
docker rm -f hardened-nginx
¿Qué previene el flag no-new-privileges?
El flag no-new-privileges impide que los procesos dentro de un contenedor obtengan privilegios adicionales mediante binarios setuid o setgid. Sin este flag, un proceso comprometido puede ejecutar un binario setuid (como su o sudo) y escalar a root. Con el flag activado, el kernel rechaza la escalada de privilegios.
Aplicar por contenedor
CLI:
docker run -d --security-opt no-new-privileges:true --name no-priv-test nginx:alpine
Compose:
services:
web:
image: nginx:alpine
security_opt:
- no-new-privileges:true
Aplicar como valor por defecto del daemon
Añádelo a daemon.json para que cada contenedor obtenga este flag automáticamente:
{
"no-new-privileges": true
}
Reinicia Docker después de editar:
sudo systemctl restart docker
Verificar que funciona
docker exec no-priv-test grep NoNewPrivs /proc/1/status
Salida esperada:
NoNewPrivs: 1
Un valor de 1 significa que no se pueden obtener nuevos privilegios. Un valor de 0 significa que el flag no está activado.
Limpieza:
docker rm -f no-priv-test
¿Cómo creo un perfil seccomp personalizado para Docker?
El perfil seccomp por defecto de Docker bloquea unas 44 syscalls de más de 300. Un perfil personalizado te permite restringir los contenedores a solo las syscalls que tu aplicación realmente usa. Si un atacante compromete el contenedor, no puede explotar vulnerabilidades del kernel a través de syscalls bloqueadas.
Descubrir qué syscalls necesita tu aplicación
Usa strace en un contenedor en ejecución para capturar las syscalls que hace durante la operación normal:
docker run -d --security-opt seccomp=unconfined --name trace-target nginx:alpine
# Install strace on the host
sudo apt-get install -y strace
# Get the container's PID
PID=$(docker inspect --format '{{.State.Pid}}' trace-target)
# Trace syscalls for 30 seconds during normal operation
sudo strace -f -p "$PID" -o /tmp/nginx-syscalls.log -e trace=all &
STRACE_PID=$!
sleep 30
# Send some test traffic to exercise the application
curl -s http://localhost:80 > /dev/null 2>&1 || true
kill "$STRACE_PID" 2>/dev/null
wait "$STRACE_PID" 2>/dev/null
Extrae los nombres de syscalls únicos:
grep -oP '^\[pid \d+\] \K\w+|^\w+' /tmp/nginx-syscalls.log | sort -u
Esto te da el conjunto mínimo de syscalls que tu aplicación necesita.
Construir el perfil personalizado
Crea un archivo JSON. El defaultAction es SCMP_ACT_ERRNO (denegar todo lo no permitido explícitamente). La lista de syscalls debe incluir tanto lo que tu aplicación necesita COMO lo que el runtime del contenedor (runc) necesita durante la inicialización. El siguiente perfil fue probado con nginx:alpine en Docker Engine 29.x:
{
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": [
"SCMP_ARCH_X86_64",
"SCMP_ARCH_X86",
"SCMP_ARCH_AARCH64"
],
"syscalls": [
{
"names": [
"accept4",
"access",
"arch_prctl",
"bind",
"brk",
"capget",
"capset",
"chdir",
"chown",
"clone",
"clone3",
"close",
"close_range",
"connect",
"copy_file_range",
"dup",
"dup2",
"dup3",
"epoll_create1",
"epoll_ctl",
"epoll_pwait",
"epoll_pwait2",
"epoll_wait",
"eventfd2",
"execve",
"exit",
"exit_group",
"faccessat",
"faccessat2",
"fchmod",
"fchmodat",
"fchown",
"fchownat",
"fcntl",
"fork",
"fstat",
"fstatfs",
"futex",
"getcwd",
"getdents",
"getdents64",
"getegid",
"geteuid",
"getgid",
"getpgrp",
"getpid",
"getppid",
"getrandom",
"getrlimit",
"getsockname",
"getsockopt",
"gettid",
"getuid",
"io_destroy",
"io_getevents",
"io_setup",
"io_submit",
"ioctl",
"kill",
"listen",
"lseek",
"lstat",
"madvise",
"memfd_create",
"mkdir",
"mkdirat",
"mmap",
"mount",
"mprotect",
"mremap",
"munmap",
"nanosleep",
"newfstatat",
"open",
"openat",
"pipe",
"pipe2",
"pivot_root",
"poll",
"ppoll",
"prctl",
"pread64",
"prlimit64",
"pwrite64",
"read",
"readlink",
"readlinkat",
"recvfrom",
"recvmsg",
"rename",
"renameat",
"rseq",
"rt_sigaction",
"rt_sigprocmask",
"rt_sigreturn",
"sched_getaffinity",
"sched_yield",
"seccomp",
"sendfile",
"sendmsg",
"sendto",
"set_robust_list",
"set_tid_address",
"setgid",
"setgroups",
"sethostname",
"setitimer",
"setsockopt",
"setuid",
"sigaltstack",
"socket",
"socketpair",
"stat",
"statfs",
"statx",
"symlink",
"symlinkat",
"sysinfo",
"tgkill",
"umask",
"umount2",
"uname",
"unlink",
"unlinkat",
"unshare",
"utimensat",
"wait4",
"write",
"writev"
],
"action": "SCMP_ACT_ALLOW"
}
]
}
¿Por qué tantas syscalls? La lista incluye syscalls necesarias para tres capas: el runtime del contenedor (runc) para la configuración de namespaces (clone3, mount, pivot_root, unshare, seccomp, statx), el shell Alpine y los scripts de entrada (fork, open, pipe, wait4), y nginx en sí (accept4, bind, listen, io_setup). Una imagen basada en glibc necesitaría menos syscalls de shell pero más internas de libc.
Guarda esto como /etc/docker/seccomp-nginx.json.
Aplicar el perfil personalizado
CLI:
docker run -d \
--security-opt seccomp=/etc/docker/seccomp-nginx.json \
--name seccomp-test \
nginx:alpine
Compose:
services:
web:
image: nginx:alpine
security_opt:
- seccomp=/etc/docker/seccomp-nginx.json
Verificar que el perfil está activo
docker inspect --format '{{.HostConfig.SecurityOpt}}' seccomp-test
La salida muestra el JSON completo del perfil seccomp aplicado al contenedor. Docker lee el archivo en el momento de creación del contenedor e incrusta el contenido del perfil.
Prueba que una operación restringida falla:
docker exec seccomp-test unshare --mount /bin/sh -c 'echo escaped'
Esto debería fallar con "Operation not permitted". El contenedor no tiene CAP_SYS_ADMIN (no otorgado por defecto), y el perfil seccomp proporciona una segunda capa de defensa al permitir solo las syscalls necesarias para la operación normal.
Limpieza:
docker rm -f seccomp-test
rm -f /tmp/nginx-syscalls.log
Consejo de producción: Empieza con el perfil por defecto de Docker y elimina syscalls en lugar de construir desde cero. El enfoque con strace te da el perfil más ajustado posible pero necesita pruebas exhaustivas. Ejercita cada ruta de código que tu aplicación usa durante la captura con strace: arranque, peticiones normales, manejo de errores, apagado graceful (docker stop) y rotación de logs.
Enfoque iterativo: Si construir desde strace te parece arriesgado, usa este flujo más seguro:
- Copia el perfil seccomp por defecto de Docker como punto de partida.
- Ejecuta tu contenedor con este perfil copiado. Se comporta de forma idéntica al por defecto.
- Elimina syscalls por grupos (por ejemplo, todas las syscalls
key*, todas lasswap*). - Prueba el contenedor después de cada eliminación. Si falla, añade de vuelta la última syscall eliminada.
- Repite hasta que hayas recortado todo lo que tu aplicación no necesita.
Esto es más lento que el método con strace pero más seguro para contenedores de producción donde omitir una syscall podría causar fallos intermitentes.
¿Cómo escribo un perfil AppArmor para un contenedor Docker?
AppArmor restringe qué archivos, recursos de red y capabilities puede acceder un proceso de contenedor. Docker aplica un perfil docker-default automáticamente. Un perfil personalizado te permite restringir aún más los contenedores a solo las rutas del sistema de archivos y operaciones de red que necesitan.
Comprobar que AppArmor está activo
sudo aa-status
Deberías ver docker-default en la lista de perfiles cargados. Si AppArmor no está instalado:
sudo apt-get install -y apparmor apparmor-utils
Escribir un perfil personalizado
Crea /etc/apparmor.d/containers/docker-nginx:
#include <tunables/global>
profile docker-nginx flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
#include <abstractions/nameservice>
# Capabilities needed by nginx
capability chown,
capability setuid,
capability setgid,
capability net_bind_service,
capability dac_override,
# Network access
network inet tcp,
network inet udp,
network inet6 tcp,
network inet6 udp,
# Shell and entrypoint scripts
/bin/** rix,
/usr/bin/** rix,
/usr/sbin/** rix,
/lib/** mr,
/usr/lib/** mr,
/docker-entrypoint.sh rix,
/docker-entrypoint.d/ r,
/docker-entrypoint.d/** rix,
/dev/null rw,
/dev/stdout rw,
/dev/stderr rw,
# Nginx binary
/usr/sbin/nginx ix,
# Config files (read only)
/etc/ r,
/etc/nginx/ r,
/etc/nginx/** r,
# Web root (read only)
/usr/share/nginx/html/** r,
# Temp and cache directories
/var/cache/nginx/ rw,
/var/cache/nginx/** rw,
/var/log/nginx/ rw,
/var/log/nginx/** rw,
/run/ rw,
/run/** rw,
/tmp/ rw,
/tmp/** rw,
# Proc filesystem (needed for nginx worker management)
/proc/** r,
# Deny sensitive files
deny /etc/shadow r,
deny /etc/passwd w,
deny /proc/*/mem r,
deny /sys/** w,
}
El perfil necesita declaraciones capability porque AppArmor controla el uso de capabilities independientemente de los flags --cap-add de Docker. Las rutas de los scripts de entrada (/docker-entrypoint.sh, /docker-entrypoint.d/) y los binarios del shell (/bin/**) deben permitirse explícitamente, o el contenedor no arranca. El permiso rix significa lectura, heredar contexto de ejecución y permitir ejecución.
Cargar y aplicar el perfil
sudo apparmor_parser -r -W /etc/apparmor.d/containers/docker-nginx
Verifica que se cargó:
sudo aa-status | grep docker-nginx
Ejecuta un contenedor con el perfil personalizado:
CLI:
docker run -d \
--security-opt apparmor=docker-nginx \
--name apparmor-test \
nginx:alpine
Compose:
services:
web:
image: nginx:alpine
security_opt:
- apparmor=docker-nginx
Verificar la aplicación de AppArmor
docker exec apparmor-test cat /etc/shadow
Esto debería devolver "Permission denied" porque el perfil deniega explícitamente la lectura de /etc/shadow.
Comprueba el estado de AppArmor del contenedor:
docker inspect --format '{{.AppArmorProfile}}' apparmor-test
Salida esperada: docker-nginx.
Limpieza:
docker rm -f apparmor-test
¿Cómo ejecuto un contenedor Docker con sistema de archivos de solo lectura?
Un sistema de archivos raíz de solo lectura impide que los atacantes escriban malware, backdoors o binarios modificados dentro de un contenedor comprometido. El contenedor aún puede escribir en volúmenes tmpfs montados explícitamente para archivos temporales y datos de ejecución.
CLI:
docker run -d \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--tmpfs /run:rw,noexec,nosuid,size=16m \
--tmpfs /var/cache/nginx:rw,noexec,nosuid,size=128m \
-p 8080:80 \
--name readonly-nginx \
nginx:alpine
Compose:
services:
web:
image: nginx:alpine
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m
- /run:rw,noexec,nosuid,size=16m
- /var/cache/nginx:rw,noexec,nosuid,size=128m
Observa el flag noexec en los montajes tmpfs. Esto impide ejecutar binarios desde directorios temporales, una técnica habitual que usan los atacantes tras obtener acceso de escritura.
Verificar que el sistema de archivos es de solo lectura
docker exec readonly-nginx touch /testfile
Salida esperada:
touch: /testfile: Read-only file system
Confirma que los montajes tmpfs funcionan:
docker exec readonly-nginx touch /tmp/testfile && echo "tmpfs works"
Verifica que el contenedor sirve tráfico:
curl -s -o /dev/null -w '%{http_code}' http://localhost:8080
Esperado: 200.
Limpieza:
docker rm -f readonly-nginx
¿Cómo debería ser un daemon.json de Docker reforzado?
Un daemon.json reforzado aplica valores de seguridad por defecto a cada contenedor del host. Los contenedores individuales pueden sobreescribir algunos ajustes, pero la configuración del daemon establece la línea base.
Crea o edita /etc/docker/daemon.json:
{
"no-new-privileges": true,
"icc": false,
"live-restore": true,
"userland-proxy": false,
"log-driver": "journald",
"log-opts": {
"tag": "{{.Name}}"
},
"default-ulimits": {
"nproc": {
"Name": "nproc",
"Hard": 512,
"Soft": 256
},
"nofile": {
"Name": "nofile",
"Hard": 65536,
"Soft": 32768
}
},
"storage-driver": "overlay2"
}
Qué hace cada ajuste:
no-new-privileges: Bloquea la escalada setuid/setgid en todos los contenedores por defecto.icc: false: Desactiva la comunicación entre contenedores en la red bridge por defecto. Los contenedores solo pueden comunicarse a través de puertos publicados explícitamente o redes definidas por el usuario. Esto limita el movimiento lateral si un contenedor se compromete.live-restore: Mantiene los contenedores en ejecución durante reinicios del daemon. Evita tiempo de inactividad durante actualizaciones de Docker.userland-proxy: false: Usa iptables para el mapeo de puertos en lugar de un proceso proxy de espacio de usuario. Mejor rendimiento, menos descriptores de archivo abiertos.log-driver: journald: Envía los logs de los contenedores al journal del sistema donde se gestionan y rotan centralmente.default-ulimits: Limita procesos y archivos abiertos por contenedor. Previene fork bombs y agotamiento de descriptores de archivo.
Aplica los cambios:
sudo systemctl restart docker
Verifica que el daemon tomó la configuración:
docker info --format '{{.SecurityOptions}}'
Deberías ver no-new-privileges en la lista de opciones de seguridad.
Comprueba que ICC está desactivado:
docker network inspect bridge --format '{{index .Options "com.docker.network.bridge.enable_icc"}}'
Salida esperada: false.
Nota sobre userns-remap: Si elegiste user namespace remapping en lugar del modo rootless, añade "userns-remap": "default" a esta configuración. No combines userns-remap con rootless Docker.
Ocultación de versión: Mientras editas daemon.json, considera también ocultar la información de versión de la API de Docker. Docker no expone headers de versión por defecto como Nginx, pero si expones la API de Docker en un socket TCP (no lo hagas, salvo que sea absolutamente necesario), protégela con certificados TLS de cliente. Una API de Docker expuesta es equivalente a acceso root por shell.
Auditoría de tu configuración: Ejecuta Docker Bench for Security para auditar tu configuración contra el CIS Docker Benchmark. Clona el repositorio y ejecuta el script directamente, ya que la imagen de contenedor incluye un cliente Docker obsoleto incompatible con Docker Engine 29.x:
git clone https://github.com/docker/docker-bench-security.git /tmp/docker-bench
cd /tmp/docker-bench && sudo bash docker-bench-security.sh
Revisa la salida en busca de entradas WARN. El daemon.json reforzado anterior aborda la mayoría. Los elementos marcados como INFO son recomendaciones, no fallos.
Combinando múltiples capas de hardening en Docker Compose
Un archivo Compose de producción debería apilar varias medidas de hardening juntas. Aquí tienes un ejemplo para un contenedor Nginx con las siete capas aplicadas:
services:
web:
image: nginx:alpine
read_only: true
cap_drop:
- ALL
cap_add:
- CHOWN
- NET_BIND_SERVICE
- SETUID
- SETGID
security_opt:
- no-new-privileges:true
- seccomp=/etc/docker/seccomp-nginx.json
- apparmor=docker-nginx
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m
- /run:rw,noexec,nosuid,size=16m
- /var/cache/nginx:rw,noexec,nosuid,size=128m
ports:
- "80:80"
pids_limit: 100
deploy:
resources:
limits:
memory: 256M
cpus: '1.0'
Observa que pids_limit previene fork bombs y deploy.resources.limits limita memoria y CPU. Estos no son flags de security-opt pero previenen denegación de servicio desde dentro del contenedor. Para más información sobre límites de recursos, consulta .
Verifica que todas las opciones de seguridad están activas en el contenedor en ejecución:
docker compose up -d
docker inspect web --format '{{json .HostConfig.SecurityOpt}}' | python3 -m json.tool
Deberías ver no-new-privileges, la ruta de tu perfil seccomp y el nombre del perfil AppArmor listados.
Checklist de hardening para producción
| Medida | Flag CLI | Clave Compose | daemon.json | Amenaza prevenida |
|---|---|---|---|---|
| Rootless Docker | N/A (nivel daemon) | N/A | N/A (daemon separado) | Fuga de contenedor da root en host |
| userns-remap | N/A | N/A | "userns-remap": "default" |
Root del contenedor = root del host |
| Eliminar capabilities | --cap-drop ALL --cap-add X |
cap_drop: [ALL] |
N/A | Superficie de ataque del kernel |
| no-new-privileges | --security-opt no-new-privileges:true |
security_opt: [no-new-privileges:true] |
"no-new-privileges": true |
Escalada setuid/setgid |
| Seccomp personalizado | --security-opt seccomp=profile.json |
security_opt: [seccomp:path] |
N/A | Exploit del kernel vía syscalls bloqueadas |
| Perfil AppArmor | --security-opt apparmor=name |
security_opt: [apparmor:name] |
N/A | Acceso a archivos/red más allá de lo necesario |
| Rootfs de solo lectura | --read-only |
read_only: true |
N/A | Malware persistente, manipulación de binarios |
| Desactivar ICC | N/A | N/A | "icc": false |
Movimiento lateral entre contenedores |
| Limitar procesos | --pids-limit 100 |
pids_limit: 100 |
"default-pids-limit": 100 |
Fork bombs |
| Desactivar userland proxy | N/A | N/A | "userland-proxy": false |
Desperdicio de recursos, reduce superficie de ataque |
Solución de problemas
El contenedor no arranca tras añadir el perfil seccomp:
Tu perfil no incluye una syscall que la aplicación necesita. Ejecuta con --security-opt seccomp=unconfined temporalmente, haz strace del proceso y añade las syscalls faltantes a tu perfil.
Consulta los logs de auditoría del kernel para syscalls bloqueadas:
sudo journalctl -k | grep -i seccomp
AppArmor bloquea operaciones legítimas: Configura el perfil en modo complain para registrar denegaciones sin aplicarlas:
sudo aa-complain /etc/apparmor.d/containers/docker-nginx
Observa los logs:
sudo journalctl -k | grep apparmor | tail -20
Tras añadir los permisos necesarios, vuelve al modo enforce:
sudo aa-enforce /etc/apparmor.d/containers/docker-nginx
Rootless Docker no puede descargar imágenes:
Comprueba que DOCKER_HOST está configurado correctamente:
echo $DOCKER_HOST
Debería apuntar a /run/user/<UID>/docker.sock. También verifica que el daemon rootless está en ejecución:
systemctl --user status docker
Permiso denegado en bind mount con userns-remap: Los archivos en el host propiedad de root (UID 0) son inaccesibles porque el UID 0 del contenedor se mapea a un UID alto del host. Arréglalo cambiando la propiedad:
# Find the remapped UID
grep dockremap /etc/subuid
# Then chown to that UID
sudo chown -R <remapped-uid>:<remapped-uid> /path/to/bind/mount
Reemplaza <remapped-uid> con el primer número de /etc/subuid para el usuario dockremap (habitualmente 100000).
Logs del contenedor: Para cualquier problema relacionado con Docker, empieza con los logs del contenedor y el journal del daemon Docker:
docker logs <container-name>
sudo journalctl -u docker -f
Para rootless Docker, consulta el journal a nivel de usuario:
journalctl --user -u docker -f
Copyright 2026 Virtua.Cloud. Todos los derechos reservados. Este contenido es una obra original del equipo de Virtua.Cloud. La reproducción, republicación o redistribución sin permiso escrito está prohibida.
¿Listo para probarlo?
Despliega tu propio servidor en segundos. Linux, Windows o FreeBSD.
Ver planes VPS