Contenido
Tutorial de seguridad en Docker: 10 prácticas para proteger tus contenedores
Los contenedores ofrecen cierto aislamiento, pero no garantizan la seguridad. Estas son las protecciones básicas que debes aplicar desde el desarrollo hasta el despliegue, con resultados de ejecuciones reales para cada una.
1. No ejecutes como root
$ docker run --rm alpine:3.23 id
uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
$ docker run --rm -u 10001:10001 alpine:3.23 id
uid=10001 gid=10001 groups=10001
$ docker run --rm node:24-alpine id node
uid=1000(node) gid=1000(node) groups=1000(node),1000(node)
- De forma predeterminada, dentro del contenedor se usa root (uid=0). Si alguien aprovecha una vulnerabilidad en el programa, tendrá privilegios máximos dentro del contenedor y, junto con otras vulnerabilidades, podría afectar al host.
-u 10001:10001especifica el usuario al ejecutar el contenedor, sin modificar la imagen.- Muchas imágenes oficiales ya incluyen un usuario normal, como
node(uid 1000) en la imagennodeynonrooten las imágenes distroless.
La mejor práctica es cambiar de usuario directamente en el Dockerfile:
RUN adduser -D -u 10001 app # Alpine
# RUN useradd --system --uid 10001 app # Debian / Ubuntu
USER app
Consulta los ejemplos prácticos de aplicaciones Python y Node.js.
2. Usa imágenes base de confianza y fija sus versiones
- Da prioridad a Docker Official Images (por ejemplo,
python,nodeynginx) o a imágenes publicadas por organizaciones conocidas. - No uses imágenes de origen dudoso, sobre todo si su nombre se parece al de una imagen popular (por ejemplo,
ngnix). - Fija una versión, por ejemplo,
node:24-alpineo una más específica comonode:24.21-alpine, en lugar denodeolatest. Para fijarla por completo, añade el digest:node:24-alpine@sha256:....
3. Mantén las imágenes lo más pequeñas posible
Cada paquete de una imagen puede tener vulnerabilidades. Usa slim, alpine, distroless o compilaciones multietapa y conserva solo lo necesario para ejecutar la aplicación. Cuantas menos herramientas innecesarias haya (compiladores, curl, ssh), menos oportunidades tendrá un atacante para aprovecharlas y más limpio será el resultado del análisis de vulnerabilidades.
4. No incluyas secretos en la imagen
Prueba práctica: los secretos permanecen en la imagen
El siguiente Dockerfile comete tres errores habituales:
FROM alpine:3.23
ARG NPM_TOKEN
ENV DB_PASSWORD=super-secret
COPY .env /app/.env
RUN echo "use the token: $NPM_TOKEN" > /dev/null && rm /app/.env
CMD ["sh"]
La última línea elimina .env después de usarlo, así que parece que no hay problema. Construye e inspecciona la imagen:
$ docker build -q -f Dockerfile.bad --build-arg NPM_TOKEN=npm_abc123secret -t leaky .
sha256:c9523bb4d0484e3abf049d15dec57df3282ff8203574c03127a8dfc6ee37bf9b
$ docker run --rm leaky ls /app
$ docker inspect -f "{{.Config.Env}}" leaky
[PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin DB_PASSWORD=super-secret]
$ docker history --no-trunc --format "{{.CreatedBy}}" leaky | head -n 4
CMD ["sh"]
RUN |1 NPM_TOKEN=npm_abc123secret /bin/sh -c echo "use the token: $NPM_TOKEN" > /dev/null && rm /app/.env # buildkit
COPY .env /app/.env # buildkit
ENV DB_PASSWORD=super-secret
ls /appno muestra nada: parece que.envya se eliminó.- Pero
docker inspectmuestra directamenteDB_PASSWORD=super-secret, definido conENV. docker historymuestraNPM_TOKEN=npm_abc123secret, pasado durante la compilación: el valor deARGqueda registrado en la capa que lo utiliza.
¿Y qué pasa con .env? Una imagen se compone de varias capas superpuestas. rm solo marca el archivo como «eliminado» en una capa nueva; su contenido sigue en la capa de COPY. Exporta la imagen con docker save y busca .env en las capas:
$ docker save leaky -o leaky.tar && mkdir x && tar -xf leaky.tar -C x && for l in x/blobs/sha256/*; do tar -tzf $l 2>/dev/null | grep -q "app/.env" && tar -xzOf $l app/.env; done; rm -rf x leaky.tar
API_KEY=sk-live-1234567890abcdef
Se ha extraído todo el contenido de .env. Cualquiera que pueda descargar esta imagen puede hacer lo mismo. Supón que cualquiera puede ver el contenido de cada capa de las imágenes que subes a un Registry.
Detecta los problemas antes con docker build --check
docker build --check usa los nombres de las variables para determinar si ARG o ENV parecen contener secretos:
$ docker build --check -f Dockerfile.bad . 2>&1 | sed -n "/Check complete/,\$p"
Check complete, 2 warnings have been found!
WARNING: SecretsUsedInArgOrEnv - https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
Do not use ARG or ENV instructions for sensitive data (ARG "NPM_TOKEN")
Dockerfile.bad:2
--------------------
1 | FROM alpine:3.23
2 | >>> ARG NPM_TOKEN
3 | ENV DB_PASSWORD=super-secret
4 | COPY .env /app/.env
--------------------
WARNING: SecretsUsedInArgOrEnv - https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
Do not use ARG or ENV instructions for sensitive data (ENV "DB_PASSWORD")
Dockerfile.bad:3
--------------------
1 | FROM alpine:3.23
2 | ARG NPM_TOKEN
3 | >>> ENV DB_PASSWORD=super-secret
4 | COPY .env /app/.env
5 | RUN echo "use the token: $NPM_TOKEN" > /dev/null && rm /app/.env
--------------------
Se han detectado NPM_TOKEN y DB_PASSWORD. Pero solo comprueba los nombres de ARG y ENV; no detecta problemas como copiar archivos secretos con COPY .env. Por eso sigue siendo necesario usar .dockerignore.
Buenas prácticas
- No pases contraseñas ni claves de API mediante
ENVoARGen el Dockerfile. - Usa
.dockerignorepara excluir.env, claves privadas y.git, y evitar queCOPY . .los copie a la imagen. - Pasa los secretos que necesites en tiempo de ejecución mediante variables de entorno,
--env-fileo secrets de Compose. Consulta Variables de entorno y archivos .env. - Para los secretos que necesites durante la compilación (por ejemplo, un token para instalar paquetes privados), usa el montaje de secrets de BuildKit:
FROM alpine:3.23
RUN --mount=type=secret,id=npm_token \
test -s /run/secrets/npm_token && echo "token length: $(wc -c < /run/secrets/npm_token)"
CMD ["sh"]
$ NPM_TOKEN=npm_abc123secret docker build --progress plain --no-cache --secret id=npm_token,env=NPM_TOKEN -f Dockerfile.good -t safe . 2>&1 | grep "token length"
#5 [stage-0 2/2] RUN --mount=type=secret,id=npm_token test -s /run/secrets/npm_token && echo "token length: $(wc -c < /run/secrets/npm_token)"
#5 0.261 token length: 16
$ docker history --no-trunc --format "{{.CreatedBy}}" safe | head -n 2
CMD ["sh"]
RUN /bin/sh -c test -s /run/secrets/npm_token && echo "token length: $(wc -c < /run/secrets/npm_token)" # buildkit
$ docker run --rm safe ls /run/secrets
ls: /run/secrets: No such file or directory
- Durante la compilación,
RUNpuede leer el token desde/run/secrets/npm_token(longitud: 16). - En
docker historysolo aparece el comando, no el valor del token. - La imagen tampoco contiene
/run/secrets; el secret solo se monta mientras se ejecuta eseRUN.
5. No expongas puertos innecesarios
# Mal: la base de datos está expuesta a todo el mundo
docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=secret postgres:18
# Bien: solo se puede conectar desde esta máquina
docker run -d -p 127.0.0.1:5432:5432 -e POSTGRES_PASSWORD=secret postgres:18
En Linux, Docker modifica directamente las reglas de iptables. Los puertos abiertos con -p omiten la configuración de firewalls como ufw y firewalld: aunque ufw no permita el puerto 5432, se puede conectar desde el exterior. La opción más segura es no usar -p en absoluto para servicios internos como bases de datos y cachés, y permitir que se conecten solo otros contenedores a través de la red de Docker. Por ejemplo, en Proxy inverso con Nginx, solo Nginx tiene ports.
6. Limita los permisos de los contenedores
Sistema de archivos de solo lectura
$ docker run --rm --read-only alpine:3.23 touch /tmp/x
touch: /tmp/x: Read-only file system
$ docker run --rm --read-only --tmpfs /tmp alpine:3.23 sh -c "touch /tmp/x && echo ok"
ok
--read-only convierte el sistema de archivos del contenedor en solo lectura, de modo que un atacante no puede modificar el programa ni escribir archivos maliciosos. Para las ubicaciones donde el programa necesite escribir (por ejemplo, /tmp), monta aparte espacio temporal en memoria con --tmpfs o monta un Volume.
Elimina las Linux capabilities
En Linux, los permisos de root se dividen en muchas capabilities, como CHOWN (cambiar el propietario de un archivo) y NET_BIND_SERVICE (vincular un puerto inferior a 1024). Docker concede algunas capabilities a los contenedores de forma predeterminada:
$ docker run --rm alpine:3.23 chown nobody /etc/hostname && echo "chown ok"
chown ok
$ docker run --rm --cap-drop ALL alpine:3.23 chown nobody /etc/hostname
chown: /etc/hostname: Operation not permitted
$ docker run --rm --cap-drop ALL --cap-add CHOWN alpine:3.23 chown nobody /etc/hostname && echo "chown ok"
chown ok
- De forma predeterminada, se puede ejecutar
chown. - Después de que
--cap-drop ALLelimina todas las capabilities, incluso root recibeOperation not permitted. - Usa
--cap-addpara volver a añadir solo las que realmente necesites.
La mayoría de las aplicaciones web no necesitan ninguna capability, así que puedes usar directamente --cap-drop ALL.
Impide la elevación de privilegios
$ docker run --rm alpine:3.23 grep NoNewPrivs /proc/self/status
NoNewPrivs: 0
$ docker run --rm --security-opt no-new-privileges alpine:3.23 grep NoNewPrivs /proc/self/status
NoNewPrivs: 1
NoNewPrivs: 1 indica que los procesos del contenedor no pueden obtener más privilegios mediante programas setuid (como sudo o su). Se recomienda añadir esta opción incluso cuando se ejecuta como usuario normal.
En conjunto:
docker run -d \
--user 10001:10001 \
--read-only --tmpfs /tmp \
--cap-drop ALL \
--security-opt no-new-privileges \
myapp
En Compose:
services:
app:
image: myapp
user: "10001:10001"
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
No uses --privileged a la ligera: concede al contenedor todas las capabilities y acceso a todos los dispositivos del host, lo que prácticamente desactiva todo el aislamiento.
7. Limita los recursos
$ docker run --rm --pids-limit 50 alpine:3.23 sh -c "for i in \$(seq 100); do sleep 5 & done; wait" 2>&1 | sort | uniq -c
1 sh: can't fork: Resource temporarily unavailable
$ docker run -d --name limited --memory 512m --cpus 1 --pids-limit 200 nginx:1.30-alpine
WARNING: Your kernel does not support swap limit capabilities or the cgroup is not mounted. Memory limited without swap.
2edbff0c81d73b83f0055b781d8d8ad20661b2b64ef1dfd5a6def334ef02723c
$ docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.PIDs}}" limited
NAME CPU % MEM USAGE / LIMIT PIDS
limited 0.00% 3.406MiB / 512MiB 2
$ docker inspect -f "Memory={{.HostConfig.Memory}} NanoCpus={{.HostConfig.NanoCpus}} PidsLimit={{.HostConfig.PidsLimit}}" limited
Memory=536870912 NanoCpus=1000000000 PidsLimit=200
$ docker rm -f limited
limited
- Con
--pids-limit 50, si el contenedor intenta crear 100 procesos, al superar el límite aparececan't fork: Resource temporarily unavailable. Esto evita que una bomba fork colapse todo el host. --memory 512m --cpus 1limita la memoria y la CPU. Endocker stats,LIMITmuestra 512MiB. El sistema termina los procesos que superan el límite de memoria (OOM), en lugar de dejar que agoten la memoria de todo el host.WARNING: Your kernel does not support swap limit capabilitiesindica que el kernel del host no tiene habilitado el límite de swap. El límite de memoria sigue funcionando, pero no se puede limitar también el swap; puedes ignorar el aviso.
En Compose:
services:
app:
image: myapp
mem_limit: 512m
cpus: 1
pids_limit: 200
8. Protege el socket de Docker
/var/run/docker.sock es la interfaz de control de Docker. Quien pueda acceder a él puede crear cualquier contenedor, incluso uno que monte el directorio raíz del host. En una prueba, montamos / del host en un contenedor:
$ docker run --rm -v /:/host alpine:3.23 sh -c "head -n 2 /host/etc/os-release; ls /host/var/lib/docker | head -n 5"
NAME="Alpine Linux"
ID=alpine
buildkit
containerd
containers
engine-id
image
El contenedor leyó directamente /etc/os-release y /var/lib/docker del host Docker (el entorno de prueba de este tutorial es Alpine Linux). Del mismo modo, también podría leer y escribir cualquier archivo, como /etc/shadow y las claves SSH. Por eso:
- Poder ejecutar el comando
dockerequivale a tener permisos de root en el host. Añadir a un usuario al grupodockerequivale a darle permisos de root. - No montes
-v /var/run/docker.sock:/var/run/docker.socken un contenedor sin confiar plenamente en esa imagen (por ejemplo, herramientas que lo necesitan, como Traefik, Portainer y Trivy). - No expongas en la red el puerto TCP del daemon de Docker (2375): equivaldría a hacer públicos los permisos de root del host.
- Si necesitas un aislamiento más estricto, puedes investigar el modo rootless de Docker.
9. Analiza las vulnerabilidades de las imágenes
La herramienta de código abierto Trivy puede analizar las vulnerabilidades conocidas en los paquetes del sistema operativo y los paquetes de lenguajes de programación de una imagen. Ejecútala en un contenedor; no hace falta instalarla:
$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v trivy-cache:/root/.cache aquasec/trivy:latest image --quiet --scanners vuln --severity HIGH,CRITICAL nginx:1.30-alpine
Report Summary
┌───────────────────────────────────┬────────┬─────────────────┐
│ Target │ Type │ Vulnerabilities │
├───────────────────────────────────┼────────┼─────────────────┤
│ nginx:1.30-alpine (alpine 3.24.2) │ alpine │ 3 │
└───────────────────────────────────┴────────┴─────────────────┘
Legend:
- '-': Not scanned
- '0': Clean (no security findings detected)
nginx:1.30-alpine (alpine 3.24.2)
=================================
Total: 3 (HIGH: 3, CRITICAL: 0)
┌──────────┬─────────────────┬──────────┬────────┬───────────────────┬───────────────┬─────────────────────────────────────────────────────────┐
│ Library │ Vulnerability │ Severity │ Status │ Installed Version │ Fixed Version │ Title │
├──────────┼─────────────────┼──────────┼────────┼───────────────────┼───────────────┼─────────────────────────────────────────────────────────┤
│ libexpat │ CVE-2026-93990 │ HIGH │ fixed │ 2.8.4-r0 │ 2.8.5-r0 │ expat: Expat: XML Injection via Malformed UTF-16 Input │
│ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2026-93990 │
├──────────┼─────────────────┤ │ ├───────────────────┼───────────────┼─────────────────────────────────────────────────────────┤
│ pcre2 │ CVE-2026-103111 │ │ │ 10.48-r0 │ 10.49-r0 │ pcre2: pcre2: Out-of-bounds write via crafted regular │
│ │ │ │ │ │ │ expression │
│ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2026-103111 │
├──────────┼─────────────────┤ │ ├───────────────────┼───────────────┼─────────────────────────────────────────────────────────┤
│ tiff │ CVE-2026-4775 │ │ │ 4.7.1-r0 │ 4.7.2-r0 │ libtiff: libtiff: Arbitrary code execution or denial of │
│ │ │ │ │ │ │ service via signed integer... │
│ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2026-4775 │
└──────────┴─────────────────┴──────────┴────────┴───────────────────┴───────────────┴─────────────────────────────────────────────────────────┘
--severity HIGH,CRITICALmuestra solo las vulnerabilidades de nivel alto o crítico, y--quietoculta el progreso de la descarga de la base de datos de vulnerabilidades.- Montar
docker.sockpermite que Trivy lea las imágenes locales; el Volumetrivy-cacheguarda la base de datos de vulnerabilidades para no tener que volver a descargarla en el siguiente análisis. - El resultado muestra 3 vulnerabilidades de nivel HIGH en Alpine, la imagen base de
nginx:1.30-alpine. Todas tienenStatusfixedy se indicaFixed Version: Alpine ya publicó las correcciones, pero esta imagen todavía no las incluía cuando se construyó.
Los resultados del análisis cambian con el tiempo: cada día se descubren nuevas vulnerabilidades y las imágenes se actualizan continuamente. Es muy probable que los resultados que veas al ejecutarlo sean distintos.
Docker Desktop y las versiones recientes de la CLI también incluyen Docker Scout (docker scout quickview, docker scout cves), que puedes usar después de iniciar sesión en tu cuenta de Docker. Se recomienda automatizar el análisis en el flujo de CI y bloquear el despliegue si se detectan vulnerabilidades graves.
10. Actualizaciones periódicas
Las imágenes base reciben parches de forma continua. A partir de los resultados del análisis anterior, actualiza los paquetes en el Dockerfile y vuelve a analizar:
$ cat Dockerfile
FROM nginx:1.30-alpine
RUN apk upgrade --no-cache
$ docker build -q -t nginx-patched .
sha256:c88ea56e7062e7703dccc5eb2fedea74c333f33982d9735753056a50858646f2
$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v trivy-cache:/root/.cache aquasec/trivy:latest image --quiet --scanners vuln --severity HIGH,CRITICAL nginx-patched
Report Summary
┌───────────────────────────────┬────────┬─────────────────┐
│ Target │ Type │ Vulnerabilities │
├───────────────────────────────┼────────┼─────────────────┤
│ nginx-patched (alpine 3.24.2) │ alpine │ 0 │
└───────────────────────────────┴────────┴─────────────────┘
Legend:
- '-': Not scanned
- '0': Clean (no security findings detected)
Después de que apk upgrade actualiza los paquetes vulnerables a versiones con parches, el número de vulnerabilidades HIGH y CRITICAL pasa a ser 0.
En la práctica, es mejor reconstruir periódicamente con docker build --pull (--pull descarga primero la versión más reciente de la imagen base) y volver a desplegar. Puedes usar herramientas como Dependabot y Renovate para recibir avisos automáticos sobre nuevas versiones de las imágenes base. Las vulnerabilidades de una imagen que se construye una sola vez y no se actualiza durante años solo aumentarán.
Lista de comprobación
- El Dockerfile incluye
USERy no se ejecuta como root - El origen de la imagen base es confiable y la versión está fijada
- Usa una imagen ligera o una compilación en varias etapas
- La imagen no contiene contraseñas, claves ni
.env(compruébalo condocker historyydocker inspect) - Usa
RUN --mount=type=secretpara los secretos durante la compilación - Los servicios internos, como las bases de datos, no exponen puertos al exterior
- Según corresponda, usa
--read-only,--cap-drop ALLyno-new-privileges - No uses
--privilegedni montes docker.sock sin restricciones - Establece límites de memoria, CPU y PID
- Analiza las vulnerabilidades de las imágenes en CI
- Reconstruye periódicamente con
--pullpara obtener actualizaciones de seguridad
Resumen
- Añade
USERal Dockerfile para ejecutar como un usuario distinto de root. ARG,ENVy los archivos eliminados permanecen en la imagen; usa--secretpara los secretos durante la compilación.- No uses
-pen servicios que no deban exponerse al exterior;-pomite ufw. - Tener acceso a docker.sock equivale a tener acceso root en el host.
- Analiza periódicamente y vuelve a construir con
--pull.
Ejercicios
Ejercicio 1: Usa docker history --no-trunc para revisar una imagen que hayas construido y comprobar si aparecen contraseñas o tokens.
Pista
Revisa el contenido de las instrucciones ARG, ENV y RUN en la columna CREATED BY, así como docker inspect -f "{{json .Config.Env}}" imagen. Si aparecen, usa --secret o variables de entorno en tiempo de ejecución, y revoca las claves que ya se hayan expuesto.
Preguntas frecuentes
¿El usuario root de un contenedor es el mismo root del host?
En el modo habitual, tiene el mismo uid, pero está restringido por namespaces, capabilities y seccomp. Para que no sea root del host, usa el [modo Rootless](/es/docker/rootless-podman/).
¿Qué hago si el análisis de la imagen encuentra muchas vulnerabilidades?
Empieza por las vulnerabilidades de alto riesgo que tengan Fixed Version: vuelve a construir (docker build --pull) o actualiza los paquetes. Si no hay una versión corregida, evalúa si realmente necesitas ese paquete.
¿En qué se diferencian Docker Scout y Trivy?
Ambos son herramientas de análisis de vulnerabilidades. Scout está integrado en Docker Desktop y requiere iniciar sesión; Trivy es una herramienta de código abierto que puedes ejecutar en un contenedor.
Actualizado 14 de octubre de 2026