Un contenedor no es una máquina virtual: comparte el kernel del host, y una configuración descuidada puede dar a un atacante acceso root al servidor o exponer servicios que creías privados. En este tutorial aplicarás, en orden de impacto, las medidas que más reducen el riesgo en un servidor con Docker: controlar el acceso al daemon, publicar puertos sin saltarte el firewall, ejecutar contenedores con los mínimos privilegios, endurecer la configuración del daemon y escanear las imágenes. Los comandos son para Ubuntu 24.04 con Docker Engine.

Requisitos previos

  • Un servidor con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath.
  • Un usuario no root con privilegios sudo.
  • Docker Engine instalado desde el repositorio oficial de Docker.
  • UFW activo permitiendo SSH (sudo ufw allow OpenSSH y sudo ufw enable).

Paso 1: Controlar quién puede usar Docker

Quien puede hablar con el daemon de Docker es, en la práctica, root en el servidor: le basta con montar / del host en un contenedor. Esto afecta a dos cosas.

El grupo docker. Añadir un usuario al grupo docker equivale a darle sudo sin contraseña. Revisa quién está en él:

getent group docker
docker:x:988:deploy

Deja solo a los usuarios que realmente administran contenedores. Para quitar a uno:

sudo gpasswd -d usuario docker

El socket /var/run/docker.sock. No lo montes en contenedores salvo que sea imprescindible (un proxy inverso que descubre contenedores, un agente de CI) y la imagen sea de total confianza. Localiza los que lo tienen montado:

docker ps -q | xargs -r docker inspect --format '{{.Name}} {{range .Mounts}}{{.Source}} {{end}}' | grep docker.sock

Si no aparece nada, ningún contenedor en ejecución tiene acceso al daemon.

Paso 2: Publicar puertos sin saltarte UFW

Docker gestiona sus propias reglas de iptables, y los puertos publicados con -p 8080:80 se abren en todas las interfaces antes de que el tráfico llegue a las reglas de UFW. Es decir: aunque UFW no permita el 8080, el puerto es accesible desde Internet.

Compruébalo con un contenedor de prueba:

docker run -d --name prueba -p 8080:80 nginx:stable
sudo ufw status

UFW no menciona el puerto 8080, pero desde otra máquina curl http://your_server_ip:8080 responde con la página de Nginx. Bórralo:

docker rm -f prueba

La solución más sencilla es publicar en 127.0.0.1 todo lo que no deba ser público y dejar que un proxy inverso (Nginx, Caddy, Traefik) en los puertos 80 y 443 sea lo único expuesto:

docker run -d --name app -p 127.0.0.1:8080:80 nginx:stable

Verifica en qué dirección escucha:

sudo ss -ltnp 'sport = :8080'
State  Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0      4096       127.0.0.1:8080      0.0.0.0:*     users:(("docker-proxy",pid=4121,fd=7))

En Docker Compose se hace igual:

services:
  app:
    image: nginx:stable
    ports:
      - "127.0.0.1:8080:80"

Las bases de datos, cachés y paneles internos no necesitan publicar puertos en absoluto: conecta los contenedores a la misma red de Docker y se comunicarán por nombre de servicio. Si tu servidor tiene una red privada, puedes publicar en su IP (-p 10.0.0.5:5432:5432) en lugar de en todas las interfaces.

Borra el contenedor de prueba:

docker rm -f app

Paso 3: Ejecutar contenedores con los mínimos privilegios

Por defecto el proceso principal de muchas imágenes se ejecuta como root dentro del contenedor, con un conjunto de capacidades del kernel y un sistema de archivos escribible. Cada una de esas cosas se puede recortar.

Usar un usuario sin privilegios

Lo ideal es que la imagen lo defina con la instrucción USER en el Dockerfile:

FROM python:3.12-slim
RUN useradd --system --uid 10001 --no-create-home app
WORKDIR /app
COPY --chown=app:app . .
USER app
CMD ["python", "app.py"]

Si la imagen no lo hace, puedes forzar el usuario al arrancar con --user 10001:10001, siempre que la aplicación no necesite escribir en rutas propiedad de root.

Quitar capacidades, bloquear escaladas y limitar recursos

Este docker run combina las restricciones más útiles. Se prueba con la imagen nginxinc/nginx-unprivileged, una variante oficial de Nginx que funciona sin root en el puerto 8080:

docker run -d --name web \
  --read-only \
  --tmpfs /tmp \
  --cap-drop ALL \
  --security-opt no-new-privileges:true \
  --memory 256m \
  --cpus 1 \
  --pids-limit 200 \
  -p 127.0.0.1:8080:8080 \
  nginxinc/nginx-unprivileged:stable

Qué hace cada opción:

OpciónEfecto
--read-onlyEl sistema de archivos del contenedor es de solo lectura: un atacante no puede modificar binarios ni dejar herramientas
--tmpfs /tmpDa un directorio escribible en memoria para lo que la aplicación necesite
--cap-drop ALLElimina todas las capacidades del kernel; añade solo las imprescindibles con --cap-add
--security-opt no-new-privileges:trueImpide ganar privilegios con binarios setuid como su o sudo
--memory, --cpusEvitan que un contenedor agote la RAM o la CPU del servidor
--pids-limitFrena ataques de tipo fork bomb

Comprueba que responde y que el proceso no es root:

curl -sI http://127.0.0.1:8080 | head -1
docker exec web id
HTTP/1.1 200 OK
uid=101(nginx) gid=101(nginx) groups=101(nginx)

Si una aplicación falla con --read-only, sus logs dirán en qué ruta intenta escribir: añade otro --tmpfs o un volumen para esa ruta. Si falla con --cap-drop ALL, añade solo la capacidad que pida; por ejemplo, --cap-add NET_BIND_SERVICE para escuchar en un puerto menor que 1024 sin ser root.

El equivalente en Compose:

services:
  web:
    image: nginxinc/nginx-unprivileged:stable
    read_only: true
    tmpfs:
      - /tmp
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    mem_limit: 256m
    cpus: 1
    pids_limit: 200
    ports:
      - "127.0.0.1:8080:8080"

Borra el contenedor:

docker rm -f web

Opciones que debes evitar

  • --privileged: desactiva casi todo el aislamiento y da acceso a los dispositivos del host.
  • --network host y --pid host: el contenedor comparte la red o los procesos del host.
  • Montar directorios sensibles del host (/, /etc, /root, /var/run/docker.sock).
  • --security-opt apparmor=unconfined o seccomp=unconfined: desactivan los perfiles que Docker aplica por defecto.

Para encontrar contenedores en marcha con --privileged:

docker ps -q | xargs -r docker inspect --format '{{.Name}} privileged={{.HostConfig.Privileged}}' | grep 'privileged=true'

Paso 4: Endurecer la configuración del daemon

Algunos ajustes se pueden fijar para todos los contenedores en /etc/docker/daemon.json:

sudo nano /etc/docker/daemon.json

Añade estas claves (combínalas con las que ya tengas, como las de rotación de logs):

{
  "no-new-privileges": true,
  "icc": false,
  "live-restore": true,
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}
  • no-new-privileges aplica esa restricción a todos los contenedores por defecto.
  • icc: false impide que los contenedores de la red bridge por defecto se comuniquen entre sí. Las redes que crees tú (y las de Compose) no se ven afectadas, así que úsalas para agrupar solo los servicios que deben hablarse.
  • live-restore mantiene los contenedores en marcha si reinicias o actualizas el daemon.
  • Los límites de logs evitan que un contenedor llene el disco.

Valida la configuración y reinicia Docker:

sudo dockerd --validate --config-file /etc/docker/daemon.json
sudo systemctl restart docker
configuration OK

Comprueba que se ha aplicado. live-restore aparece en docker info, y no-new-privileges se ve en el estado de cualquier proceso de un contenedor nuevo:

docker info --format '{{.LiveRestoreEnabled}}'
docker run --rm alpine:3 grep NoNewPrivs /proc/self/status
true
NoNewPrivs:	1

Remapeo de usuarios (opcional)

Con "userns-remap": "default" en daemon.json, el root de dentro de los contenedores se asigna a un usuario sin privilegios del host (dockremap). Si alguien escapa del contenedor, no será root en el servidor.

Una alternativa más completa es ejecutar Docker en modo rootless, en el que el propio daemon corre con un usuario normal. Consulta la documentación oficial de Docker sobre el modo rootless si quieres ese nivel de aislamiento.

Paso 5: Usar imágenes de confianza y escanearlas

Una imagen puede traer vulnerabilidades conocidas o, si procede de una fuente desconocida, código malicioso. Reglas básicas:

  • Usa imágenes oficiales (nginx, postgres) o de editores verificados en Docker Hub, o constrúyelas tú.
  • Fija la versión (postgres:17, no postgres:latest) para saber qué ejecutas y controlar cuándo cambia.
  • No guardes secretos en la imagen (ENV PASSWORD=... o COPY .env): cualquiera con la imagen puede leerlos con docker history o extrayendo las capas. Pásalos al arrancar con --env-file o como secretos de Compose.

Para detectar vulnerabilidades, instala Trivy desde su repositorio oficial:

sudo apt install -y wget gnupg
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | sudo tee /usr/share/keyrings/trivy.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | sudo tee /etc/apt/sources.list.d/trivy.list
sudo apt update
sudo apt install -y trivy

Escanea una imagen mostrando solo las vulnerabilidades graves:

trivy image --severity HIGH,CRITICAL nginx:stable

La primera ejecución descarga la base de datos de vulnerabilidades. El resultado es una tabla por paquete con el identificador CVE, la versión instalada y la versión que lo corrige. Lo que tenga versión corregida se soluciona casi siempre reconstruyendo con una imagen base actualizada:

docker build --pull -t mi-app:1.0.1 .

En un pipeline de CI puedes hacer que el build falle si hay vulnerabilidades críticas con solución disponible:

trivy image --severity CRITICAL --ignore-unfixed --exit-code 1 mi-app:1.0.1

Paso 6: Auditar el servidor con Docker Bench for Security

Docker Bench for Security es un script de Docker que comprueba el host, el daemon y los contenedores contra las recomendaciones del CIS Docker Benchmark. Descárgalo y revísalo antes de ejecutarlo:

git clone https://github.com/docker/docker-bench-security.git
cd docker-bench-security
less docker-bench-security.sh
sudo sh docker-bench-security.sh

Cada comprobación sale marcada como [PASS], [WARN], [INFO] o [NOTE]. No todos los [WARN] aplican a tu caso (por ejemplo, el que recomienda una partición separada para /var/lib/docker), pero úsalos como lista de revisión: te dirán qué contenedores se ejecutan como root, cuáles no tienen límite de memoria o cuáles montan rutas sensibles.

Solución de problemas

Un contenedor deja de arrancar tras añadir --read-only. Mira docker logs nombre: aparecerá un error Read-only file system con la ruta. Móntala como --tmpfs si son datos temporales o como volumen si deben persistir.

Tras activar icc: false, dos contenedores ya no se comunican. Estaban en la red bridge por defecto. Crea una red propia (docker network create app_net) y conecta ambos con --network app_net; allí además se resolverán por nombre.

Un puerto sigue siendo accesible desde fuera aunque lo publicaste en 127.0.0.1. Probablemente el contenedor antiguo sigue en marcha o Compose no lo ha recreado. Revisa docker ps (columna PORTS) y recrea con docker compose up -d --force-recreate.

Conclusión

Has limitado quién controla Docker, has evitado que los puertos publicados esquiven el firewall, has ejecutado contenedores sin root, sin capacidades y con límites de recursos, y has añadido el escaneo de imágenes y una auditoría con Docker Bench. Estas medidas cubren la mayoría de incidentes reales con Docker en servidores expuestos a Internet.

Como siguientes pasos puedes:

  • Automatizar el escaneo con Trivy en tu pipeline de CI para que ninguna imagen vulnerable llegue a producción.
  • Poner un proxy inverso con HTTPS delante de tus aplicaciones y dejar los contenedores solo en localhost.
  • Mantener Docker Engine y el kernel actualizados con sudo apt update && sudo apt upgrade, ya que muchas fugas de contenedores se corrigen ahí.