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 OpenSSHysudo 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.
Advertenciano expongas nunca la API de Docker por TCP (
-H tcp://0.0.0.0:2375) sin TLS mutuo. Es una de las vías más comunes para que un servidor acabe minando criptomonedas. Para administrar Docker en remoto usa SSH:docker -H ssh://usuario@your_server_ip ps.
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ón | Efecto |
|---|---|
--read-only | El sistema de archivos del contenedor es de solo lectura: un atacante no puede modificar binarios ni dejar herramientas |
--tmpfs /tmp | Da un directorio escribible en memoria para lo que la aplicación necesite |
--cap-drop ALL | Elimina todas las capacidades del kernel; añade solo las imprescindibles con --cap-add |
--security-opt no-new-privileges:true | Impide ganar privilegios con binarios setuid como su o sudo |
--memory, --cpus | Evitan que un contenedor agote la RAM o la CPU del servidor |
--pids-limit | Frena 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 hosty--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=unconfinedoseccomp=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-privilegesaplica esa restricción a todos los contenedores por defecto.icc: falseimpide que los contenedores de la redbridgepor 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-restoremantiene 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.
Importanteal activar
userns-remap, Docker usa un directorio de datos distinto, así que dejarás de ver las imágenes, contenedores y volúmenes existentes hasta que lo desactives. Los volúmenes montados desde el host también necesitarán ajustar su propietario. Actívalo en servidores nuevos o planifica la migración.
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, nopostgres:latest) para saber qué ejecutas y controlar cuándo cambia. - No guardes secretos en la imagen (
ENV PASSWORD=...oCOPY .env): cualquiera con la imagen puede leerlos condocker historyo extrayendo las capas. Pásalos al arrancar con--env-fileo 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í.
