Curso Docker #8: Seguridad en Docker — Mejores Practicas
Bienvenido al Curso de Docker - Parte 8 de 10. Docker simplifica enormemente el despliegue de aplicaciones, pero si no aplicas buenas practicas de seguridad, puedes estar exponiendo tu infraestructura a ataques. Un contenedor mal configurado puede ser la puerta de entrada para comprometer todo tu sistema.

Fuente: Wikimedia Commons
En este articulo aprenderemos las mejores practicas de seguridad para Docker: desde ejecutar contenedores como usuario non-root hasta escanear vulnerabilidades con Trivy.
Por que la seguridad en Docker es critica
Los contenedores Docker comparten el kernel del sistema operativo host. A diferencia de las maquinas virtuales, no hay un hipervisor que aisle completamente el contenedor del host. Esto significa que:
- Un contenedor comprometido puede potencialmente afectar al host
- Ejecutar como root dentro del contenedor puede ser equivalente a root en el host si hay una vulnerabilidad de escape
- Imagenes con vulnerabilidades conocidas son un vector de ataque comun
- Secretos expuestos en capas de la imagen son visibles para cualquiera que tenga acceso a la imagen
Ejecutar como usuario non-root
Por defecto, los procesos dentro de un contenedor Docker se ejecutan como root. Esta es la regla numero uno de seguridad: nunca ejecutes tu aplicacion como root.
1# MAL: ejecuta como root (por defecto)
2FROM node:20-alpine
3WORKDIR /app
4COPY . .
5CMD ["node", "index.js"]
6
7# BIEN: crea y usa un usuario non-root
8FROM node:20-alpine
9WORKDIR /app
10
11# Crear usuario y grupo
12RUN addgroup -S appgroup && adduser -S appuser -G appgroup
13
14# Copiar archivos y cambiar propietario
15COPY --chown=appuser:appgroup . .
16
17# Instalar dependencias como root (necesario para escribir en node_modules)
18RUN npm ci --omit=dev
19
20# Cambiar a usuario non-root ANTES del CMD
21USER appuser
22
23EXPOSE 3000
24CMD ["node", "index.js"]
Algunas imagenes oficiales ya incluyen usuarios non-root:
1# Node.js tiene el usuario 'node'
2FROM node:20-alpine
3USER node
4
5# PostgreSQL tiene el usuario 'postgres'
6FROM postgres:16
7# Ya ejecuta como 'postgres' por defecto
8
9# Nginx tiene www-data pero necesita root para el puerto 80
10# Usa un puerto alto como alternativa
11FROM nginx:alpine
12# Configurar nginx para usar puerto 8080 y usuario nginx
docker exec mi-contenedor whoami. Si dice "root", necesitas corregirlo.
Usar imagenes base minimas
Cada paquete instalado en tu imagen es una potencial vulnerabilidad. Usa siempre la imagen mas pequena posible:
| En lugar de... | Usa... | Razon |
|---|---|---|
node:20 |
node:20-alpine |
De 1.1 GB a 140 MB, sin herramientas innecesarias |
python:3.12 |
python:3.12-slim |
De 1 GB a 150 MB |
ubuntu:22.04 |
alpine:3.19 |
De 77 MB a 7 MB |
eclipse-temurin:21-jdk |
eclipse-temurin:21-jre-alpine |
Solo el runtime, no el compilador |
1# Comparar vulnerabilidades entre imagenes base
2docker run --rm aquasec/trivy image node:20 | tail -5
3docker run --rm aquasec/trivy image node:20-alpine | tail -5
4
5# Resultado tipico:
6# node:20 -> 245 vulnerabilidades (15 criticas)
7# node:20-alpine -> 12 vulnerabilidades (0 criticas)
Escaneo de vulnerabilidades con Trivy
Trivy es una herramienta open-source de Aqua Security que escanea imagenes Docker, repositorios y archivos de configuracion en busca de vulnerabilidades conocidas (CVEs).
1# Instalar Trivy (en macOS)
2brew install trivy
3
4# Instalar con Docker (sin instalacion local)
5docker run --rm aquasec/trivy --version
6
7# Escanear una imagen local
8trivy image mi-app:latest
9
10# Escanear y fallar si hay vulnerabilidades criticas
11trivy image --severity CRITICAL --exit-code 1 mi-app:latest
12
13# Escanear un Dockerfile
14trivy config ./Dockerfile
15
16# Escanear el sistema de archivos del proyecto
17trivy fs .
18
19# Generar reporte en formato JSON
20trivy image --format json --output report.json mi-app:latest
21
22# Escanear ignorando vulnerabilidades especificas
23trivy image --ignorefile .trivyignore mi-app:latest
Ejemplo de salida de Trivy:
1mi-app:latest (alpine 3.19.1)
2Total: 3 (UNKNOWN: 0, LOW: 1, MEDIUM: 1, HIGH: 1, CRITICAL: 0)
3
4+-----------+------------------+----------+-------------------+
5| LIBRARY | VULNERABILITY ID | SEVERITY | INSTALLED VERSION |
6+-----------+------------------+----------+-------------------+
7| libcrypto | CVE-2024-1234 | HIGH | 3.1.4-r1 |
8| libssl | CVE-2024-5678 | MEDIUM | 3.1.4-r1 |
9| zlib | CVE-2024-9012 | LOW | 1.3.1-r0 |
10+-----------+------------------+----------+-------------------+
No guardar secretos en las capas de la imagen
Cada instruccion en un Dockerfile crea una capa. Aunque borres un archivo en una capa posterior, sigue existiendo en las capas anteriores y cualquiera puede extraerlo.
1# MAL: el secreto queda en las capas
2FROM node:20-alpine
3WORKDIR /app
4COPY . .
5# Aunque borremos .env despues, queda en la capa anterior
6COPY .env .env
7RUN npm ci
8RUN rm .env # ESTO NO SIRVE - .env sigue en la capa de COPY
9
10# MAL: credenciales en variables de build
11FROM node:20-alpine
12ARG DB_PASSWORD=supersecret
13ENV DB_PASSWORD=${DB_PASSWORD}
14# El ARG y ENV quedan visibles con docker inspect
15
16# BIEN: usar Docker secrets en build (BuildKit)
17FROM node:20-alpine
18WORKDIR /app
19COPY . .
20RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
21
22# BIEN: inyectar secretos en runtime, no en build
23FROM node:20-alpine
24WORKDIR /app
25COPY . .
26RUN npm ci --omit=dev
27# Los secretos se pasan al ejecutar el contenedor:
28# docker run -e DB_PASSWORD=xxx mi-app
29CMD ["node", "index.js"]
Puedes verificar que no hay secretos en tu imagen:
1# Ver todas las capas de una imagen
2docker history mi-app:latest
3
4# Inspeccionar variables de entorno
5docker inspect mi-app:latest | jq '.[0].Config.Env'
6
7# Analizar capas con dive
8docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock wagoodman/dive mi-app:latest
COPY para archivos .env, claves SSH, tokens de API o certificados privados. Siempre inyecta secretos en runtime con -e, --env-file o Docker Secrets.
Filesystem de solo lectura y no-new-privileges
Puedes ejecutar contenedores con el sistema de archivos en modo solo lectura para prevenir que un atacante modifique archivos dentro del contenedor:
1# Ejecutar con filesystem de solo lectura
2docker run --read-only --tmpfs /tmp mi-app:latest
3
4# Agregar no-new-privileges para prevenir escalacion
5docker run --read-only --tmpfs /tmp --security-opt no-new-privileges mi-app:latest
En Docker Compose:
1services:
2 api:
3 image: mi-app:latest
4 read_only: true
5 tmpfs:
6 - /tmp
7 - /var/run
8 security_opt:
9 - no-new-privileges:true
10 environment:
11 - NODE_ENV=production
La opcion no-new-privileges previene que procesos dentro del contenedor obtengan privilegios adicionales a traves de setuid, setgid u otros mecanismos de escalacion.
Capabilities: cap_drop y cap_add
Linux capabilities permiten otorgar permisos granulares en lugar de dar acceso root completo. Docker por defecto da al contenedor un subconjunto de capabilities, pero puedes restringirlas aun mas:
1# Eliminar TODAS las capabilities y agregar solo las necesarias
2docker run --cap-drop ALL --cap-add NET_BIND_SERVICE mi-app:latest
3
4# Ver las capabilities de un contenedor en ejecucion
5docker inspect mi-contenedor | jq '.[0].HostConfig.CapAdd'
6docker inspect mi-contenedor | jq '.[0].HostConfig.CapDrop'
En Docker Compose:
1services:
2 api:
3 image: mi-app:latest
4 cap_drop:
5 - ALL
6 cap_add:
7 - NET_BIND_SERVICE # solo si necesita puertos < 1024
8 security_opt:
9 - no-new-privileges:true
| Capability | Descripcion | Necesaria? |
|---|---|---|
NET_BIND_SERVICE |
Bind a puertos menores a 1024 | Solo si usas puerto 80/443 |
CHOWN |
Cambiar propietario de archivos | Raramente |
SYS_ADMIN |
Operaciones de administracion del sistema | Casi nunca (peligrosa) |
NET_RAW |
Sockets raw (ping, etc.) | No para apps web |
Aislamiento de red y Docker Content Trust
La seguridad de red es fundamental. Nunca expongas servicios internos al exterior:
1# docker-compose.yml seguro
2services:
3 frontend:
4 image: nginx:alpine
5 ports:
6 - "443:443" # solo el frontend expone puertos
7 networks:
8 - public
9 - internal
10
11 api:
12 image: mi-api:latest
13 # NO exponer puertos al host
14 networks:
15 - internal
16
17 db:
18 image: postgres:16
19 # NO exponer puertos al host
20 networks:
21 - internal
22 volumes:
23 - db_data:/var/lib/postgresql/data
24
25networks:
26 public:
27 driver: bridge
28 internal:
29 driver: bridge
30 internal: true # sin acceso a internet
Docker Content Trust (DCT) permite verificar la integridad y el publicador de las imagenes:
1# Activar Docker Content Trust
2export DOCKER_CONTENT_TRUST=1
3
4# Ahora solo podras hacer pull de imagenes firmadas
5docker pull nginx:alpine # OK si esta firmada
6docker pull imagen-random:latest # FALLA si no esta firmada
7
8# Firmar tu propia imagen al hacer push
9docker trust sign mi-usuario/mi-app:latest
Dockerfile hardening completo
Aqui un ejemplo que aplica todas las practicas de seguridad que hemos visto:
1# ---- Etapa 1: Build ----
2FROM node:20-alpine AS builder
3WORKDIR /app
4
5# Instalar dependencias primero (cache)
6COPY package.json package-lock.json ./
7RUN npm ci
8
9# Copiar y compilar
10COPY tsconfig.json ./
11COPY src/ ./src/
12RUN npm run build
13
14# Instalar solo dependencias de produccion
15RUN npm ci --omit=dev
16
17# ---- Etapa 2: Produccion (hardened) ----
18FROM node:20-alpine AS production
19
20# Actualizar paquetes del SO para parchar CVEs
21RUN apk update && apk upgrade --no-cache
22
23# Eliminar herramientas innecesarias
24RUN apk del --purge apk-tools && \
25 rm -rf /var/cache/apk/* /tmp/* /root/.npm
26
27WORKDIR /app
28
29# Crear usuario non-root
30RUN addgroup -S appgroup && adduser -S appuser -G appgroup
31
32# Copiar solo artefactos necesarios
33COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
34COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
35COPY --from=builder --chown=appuser:appgroup /app/package.json ./
36
37# Cambiar a usuario non-root
38USER appuser
39
40# Puerto no privilegiado
41EXPOSE 3000
42
43# Healthcheck
44HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
45 CMD wget --quiet --tries=1 --spider http://localhost:3000/health || exit 1
46
47# Metadata
48LABEL maintainer="[email protected]"
49LABEL version="1.0.0"
50LABEL description="API segura con Node.js"
51
52CMD ["node", "dist/index.js"]
Y el correspondiente docker-compose.yml con hardening:
1services:
2 api:
3 build:
4 context: .
5 dockerfile: Dockerfile
6 read_only: true
7 tmpfs:
8 - /tmp
9 security_opt:
10 - no-new-privileges:true
11 cap_drop:
12 - ALL
13 deploy:
14 resources:
15 limits:
16 memory: 256M
17 cpus: "0.5"
18 healthcheck:
19 test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"]
20 interval: 30s
21 timeout: 5s
22 retries: 3
23 networks:
24 - internal
Resumen y siguiente articulo
En este articulo aprendimos las mejores practicas de seguridad para Docker:
- Usuario non-root: nunca ejecutes tu aplicacion como root
- Imagenes minimas: Alpine, slim o distroless para reducir la superficie de ataque
- Escaneo con Trivy: detecta vulnerabilidades antes de desplegar
- Secretos seguros: nunca en las capas de la imagen, siempre en runtime
- Filesystem read-only: previene modificaciones maliciosas
- Capabilities: cap_drop ALL y agrega solo las necesarias
- Aislamiento de red: redes internas para servicios que no necesitan acceso externo
- Docker Content Trust: verifica la integridad de las imagenes
Comments
Sign in to leave a comment
No comments yet. Be the first!