--- title: "Seguridad en Docker: cómo proteger contenedores en Ubuntu" description: "Endurece Docker en Ubuntu 24.04: controla el acceso al daemon, evita que los puertos esquiven UFW, ejecuta contenedores sin root y escanea tus imágenes." category: "Docker Kubernetes" language: es source: https://cubepath.com/es/docs/docker-kubernetes/mejores-practicas-de-seguridad-docker updated: 2026-09-25 --- # Seguridad en Docker: cómo proteger contenedores en Ubuntu 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: ```bash getent group docker ``` ```output docker:x:988:deploy ``` Deja solo a los usuarios que realmente administran contenedores. Para quitar a uno: ```bash 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: ```bash 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. > Advertencia: no 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: ```bash 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: ```bash 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: ```bash docker run -d --name app -p 127.0.0.1:8080:80 nginx:stable ``` Verifica en qué dirección escucha: ```bash sudo ss -ltnp 'sport = :8080' ``` ```output 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: ```yaml 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: ```bash 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: ```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: ```bash 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: ```bash curl -sI http://127.0.0.1:8080 | head -1 docker exec web id ``` ```output 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: ```yaml 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: ```bash 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`: ```bash 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`: ```bash sudo nano /etc/docker/daemon.json ``` Añade estas claves (combínalas con las que ya tengas, como las de rotación de logs): ```json { "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: ```bash sudo dockerd --validate --config-file /etc/docker/daemon.json sudo systemctl restart docker ``` ```output 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: ```bash docker info --format '{{.LiveRestoreEnabled}}' docker run --rm alpine:3 grep NoNewPrivs /proc/self/status ``` ```output 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. > Importante: al 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`, 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: ```bash 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: ```bash 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: ```bash 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: ```bash 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: ```bash 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í.