Hardening de seguridad en Docker: modo rootless, seccomp y AppArmor en un VPS

16 min de lectura·Matthieu·vpscontainersapparmorseccomprootless-dockersecuritydocker|

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
Puertos privilegiados Necesita solución alternativa Funciona Funciona
--net=host No
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:

  1. Copia el perfil seccomp por defecto de Docker como punto de partida.
  2. Ejecuta tu contenedor con este perfil copiado. Se comporta de forma idéntica al por defecto.
  3. Elimina syscalls por grupos (por ejemplo, todas las syscalls key*, todas las swap*).
  4. Prueba el contenedor después de cada eliminación. Si falla, añade de vuelta la última syscall eliminada.
  5. 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