--- title: "Cómo endurecer imágenes de contenedor Docker para producción" description: "Reduce la superficie de ataque de imágenes Docker con builds multi-etapa, bases distroless, usuario no root, secretos de BuildKit y ejecución de solo lectura." category: "Seguridad de Contenedores" language: es source: https://cubepath.com/es/docs/seguridad-de-contenedores/mejores-practicas-de-hardening-de-imagenes updated: 2026-09-25 --- # Cómo endurecer imágenes de contenedor Docker para producción Endurecer una imagen de contenedor significa dejar dentro solo lo que la aplicación necesita para ejecutarse y lanzarla con los mínimos privilegios. Cada paquete que sobra es una posible vulnerabilidad y cada herramienta presente (una shell, un gestor de paquetes, `curl`) es algo que un atacante puede aprovechar si compromete el contenedor. En este tutorial partirás de un Dockerfile típico, lo convertirás en una imagen mínima con build multi-etapa, base distroless y usuario no root, y la ejecutarás con sistema de archivos de solo lectura y sin capacidades, comprobando la mejora con Trivy. ## Requisitos previos Para seguir esta guía necesitas: - Un servidor con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath, con un usuario no root con privilegios `sudo` que pertenezca al grupo `docker`. - Docker Engine 24 o superior con BuildKit (activado por defecto en Docker 23 y posteriores). - Trivy instalado, para medir las vulnerabilidades antes y después. ## Paso 1: Crear la aplicación de ejemplo Usarás un pequeño servidor HTTP en Go, porque produce un binario estático que permite llegar a la imagen más pequeña posible. Los mismos principios se aplican a cualquier lenguaje; al final verás un ejemplo en Python. Crea el directorio del proyecto: ```bash mkdir -p ~/hola && cd ~/hola ``` Crea el archivo `go.mod`: ```bash nano go.mod ``` ```text module example.com/hola go 1.25 ``` Crea el código de la aplicación: ```bash nano main.go ``` ```go package main import ( "fmt" "log" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hola desde un contenedor endurecido") }) log.Println("Escuchando en :8080") log.Fatal(http.ListenAndServe(":8080", nil)) } ``` ## Paso 2: Medir el punto de partida Este es el Dockerfile que se suele escribir primero: una sola etapa con la imagen completa de Go, que incluye compilador, shell, `git` y cientos de paquetes de Debian. ```bash nano Dockerfile.inicial ``` ```dockerfile FROM golang:1.25 WORKDIR /app COPY . . RUN go build -o hola . EXPOSE 8080 CMD ["./hola"] ``` Constrúyela y anota tamaño, usuario y vulnerabilidades: ```bash docker build -f Dockerfile.inicial -t hola:inicial . docker images hola:inicial --format '{{.Repository}}:{{.Tag}} {{.Size}}' docker inspect --format 'Usuario: "{{.Config.User}}"' hola:inicial trivy image --severity HIGH,CRITICAL --quiet hola:inicial | grep Total ``` ```output hola:inicial 850MB Usuario: "" Total: 25 (HIGH: 22, CRITICAL: 3) ``` Las cifras exactas varían según la fecha, pero el patrón se repite: una imagen de cientos de megas, que se ejecuta como root (usuario vacío) y arrastra vulnerabilidades de paquetes que la aplicación nunca usa. ## Paso 3: Excluir archivos del contexto de build `COPY . .` copia todo el directorio, incluidos `.git`, archivos `.env` con credenciales o artefactos locales. Un archivo `.dockerignore` lo evita y además acelera el build: ```bash nano .dockerignore ``` ```text .git .env *.env *.pem *.key Dockerfile* .dockerignore ``` ## Paso 4: Build multi-etapa con base distroless y usuario no root Un build multi-etapa compila en una imagen con todas las herramientas y copia solo el resultado a una imagen final mínima. Para un binario Go estático, la base `gcr.io/distroless/static-debian12` contiene únicamente certificados raíz, datos de zona horaria y los archivos `/etc/passwd` y `/etc/group`: no tiene shell ni gestor de paquetes. Su variante `:nonroot` define el usuario `nonroot` con UID 65532. ```bash nano Dockerfile ``` ```dockerfile # syntax=docker/dockerfile:1 # Etapa de compilación FROM golang:1.25 AS build WORKDIR /src COPY go.mod ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/hola . # Imagen final FROM gcr.io/distroless/static-debian12:nonroot COPY --from=build /out/hola /hola USER 65532:65532 EXPOSE 8080 ENTRYPOINT ["/hola"] ``` Qué aporta cada decisión: - `CGO_ENABLED=0` genera un binario estático que no depende de la libc de la imagen. - `-trimpath -ldflags="-s -w"` elimina rutas locales y símbolos de depuración del binario. - Copiar primero `go.mod` y descargar dependencias aprovecha la caché: si solo cambia el código, esa capa no se repite. - `USER 65532:65532` con UID numérico permite que Kubernetes verifique `runAsNonRoot` sin tener que resolver el nombre. - La forma exec de `ENTRYPOINT` (con corchetes) hace que la aplicación sea el PID 1 y reciba las señales de parada. Construye la imagen: ```bash docker build -t hola:hardened . ``` ## Paso 5: Comparar el resultado Repite las mismas mediciones del paso 2: ```bash docker images hola:hardened --format '{{.Repository}}:{{.Tag}} {{.Size}}' docker inspect --format 'Usuario: "{{.Config.User}}"' hola:hardened trivy image --severity HIGH,CRITICAL --quiet hola:hardened | grep Total ``` ```output hola:hardened 9MB Usuario: "65532:65532" Total: 0 (HIGH: 0, CRITICAL: 0) ``` La imagen ha pasado de cientos de megas a unos pocos, ya no se ejecuta como root y Trivy no encuentra paquetes del sistema vulnerables. Revisa también el Dockerfile con el analizador de configuración de Trivy: ```bash trivy config . ``` Para el `Dockerfile` endurecido no debe reportar fallos; para `Dockerfile.inicial` marcará la ausencia de `USER`. ## Paso 6: Fijar la imagen base por digest Una etiqueta como `:nonroot` puede apuntar a otra imagen mañana. Para builds reproducibles y auditables, fija la base por su digest SHA-256. Obténlo así: ```bash docker buildx imagetools inspect gcr.io/distroless/static-debian12:nonroot | grep Digest ``` ```output Digest: sha256:... ``` Y úsalo en el `FROM`, conservando la etiqueta para que sea legible: ```dockerfile FROM gcr.io/distroless/static-debian12:nonroot@sha256:el_digest_obtenido ``` > Nota: fijar por digest implica que tú decides cuándo actualizar. Automatízalo con una herramienta como Renovate o Dependabot para no quedarte con una base desactualizada. ## Paso 7: Usar secretos de build sin dejarlos en la imagen Cualquier valor pasado con `ARG` o `ENV`, o copiado con `COPY`, queda guardado en las capas de la imagen y se puede leer con `docker history` o extrayendo la capa, aunque lo borres en una instrucción posterior. Si el build necesita una credencial (por ejemplo, un token para descargar dependencias privadas), usa un secreto de BuildKit, que se monta solo durante esa instrucción `RUN`. Si tu proyecto descarga módulos de repositorios Git privados, en la etapa de compilación del Dockerfile sustituye la línea `RUN go mod download` por esta, que monta un archivo `.netrc` con credenciales de Git solo mientras se descargan las dependencias: ```dockerfile RUN --mount=type=secret,id=netrc,target=/root/.netrc go mod download ``` Y pasa el secreto al construir: ```bash docker build --secret id=netrc,src="$HOME/.netrc" -t hola:hardened . ``` Comprueba que no queda rastro en el historial de la imagen: ```bash docker history --no-trunc hola:hardened | grep -i netrc ``` No debe devolver nada: la etapa de compilación no forma parte de la imagen final y, aunque lo fuera, BuildKit no guarda el contenido del secreto en ninguna capa. Los secretos que la aplicación necesita en ejecución (contraseñas de base de datos, claves de API) no deben estar en la imagen: inyéctalos al arrancar con variables de entorno, Docker secrets o Kubernetes Secrets. ## Paso 8: Ejecutar el contenedor con privilegios mínimos Una imagen endurecida se complementa con opciones de ejecución restrictivas: ```bash docker run -d --name hola \ --read-only \ --cap-drop ALL \ --security-opt no-new-privileges:true \ --memory 128m --pids-limit 100 \ -p 8080:8080 \ hola:hardened ``` - `--read-only` monta el sistema de archivos raíz del contenedor en solo lectura; un atacante no puede modificar binarios ni dejar herramientas. - `--cap-drop ALL` elimina todas las capacidades de Linux. Esta aplicación escucha en el puerto 8080, así que no necesita ninguna; solo añadirías `--cap-add NET_BIND_SERVICE` si tuviera que escuchar en un puerto inferior a 1024. - `--security-opt no-new-privileges:true` impide ganar privilegios mediante binarios setuid. - `--memory` y `--pids-limit` limitan el daño de una fuga de memoria o de una bomba de procesos. Comprueba que la aplicación responde: ```bash curl http://localhost:8080 ``` ```output Hola desde un contenedor endurecido ``` Comprueba que no hay shell a la que entrar: ```bash docker exec -it hola sh ``` ```output OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH: unknown ``` Si tu aplicación necesita escribir archivos temporales, no quites `--read-only`: monta un `tmpfs` solo en esa ruta, por ejemplo `--tmpfs /tmp:rw,noexec,nosuid,size=64m`. En Kubernetes, el equivalente es este `securityContext` del contenedor: ```yaml securityContext: runAsNonRoot: true runAsUser: 65532 readOnlyRootFilesystem: true allowPrivilegeEscalation: false capabilities: drop: ["ALL"] ``` Docker aplica además su perfil seccomp por defecto, que bloquea decenas de llamadas al sistema peligrosas. No lo desactives con `--security-opt seccomp=unconfined`. ## Ejemplo con Python Los lenguajes interpretados necesitan el intérprete en la imagen final, pero se aplican los mismos principios: una etapa para instalar dependencias en un entorno virtual y una imagen `slim` final con usuario no root, sin compiladores ni cachés de `pip`: ```dockerfile # syntax=docker/dockerfile:1 FROM python:3.12-slim AS build RUN python -m venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt FROM python:3.12-slim RUN useradd --system --uid 10001 --no-create-home app COPY --from=build /opt/venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" \ PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 WORKDIR /app COPY app/ ./app/ USER 10001 EXPOSE 8000 CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app.main:app"] ``` Este ejemplo supone que `requirements.txt` incluye `gunicorn` y que la aplicación WSGI está en `app/main.py`. El código queda propiedad de root y el usuario `app` solo puede leerlo, no modificarlo. ## Solución de problemas **`permission denied` al arrancar como usuario no root**: la aplicación intenta escribir en un directorio que pertenece a root. Identifica la ruta en los logs (`docker logs hola`) y monta un `tmpfs` o un volumen en ella, en lugar de volver a root. **`Read-only file system`**: igual que el caso anterior con `--read-only`. Las rutas habituales son `/tmp`, directorios de caché y archivos PID. **No puedo depurar una imagen distroless porque no tiene shell**: usa la variante `:debug-nonroot`, que incluye una shell de BusyBox, solo en entornos de pruebas; o adjunta un contenedor auxiliar que comparta el espacio de procesos: `docker run -it --rm --pid container:hola --network container:hola busybox`. **Errores de TLS o de zona horaria en `scratch`**: si usas `FROM scratch` en lugar de distroless, faltan los certificados raíz y los datos de zona horaria. Por eso esta guía usa `distroless/static`, que ya los incluye. ## Conclusión Has pasado de una imagen de cientos de megas que se ejecutaba como root a una imagen mínima, sin shell, con usuario no root, base fijada por digest, sin secretos en sus capas y ejecutada en solo lectura y sin capacidades. Como siguientes pasos, puedes añadir `trivy image --exit-code 1` a tu pipeline para bloquear imágenes vulnerables, firmar las imágenes con Cosign, y vigilar su comportamiento en ejecución con Falco.