Un Dockerfile es un archivo pequeño con un radio de impacto desproporcionado. Esas pocas docenas de líneas deciden qué código, qué paquetes del sistema operativo y qué privilegios terminan ejecutándose en producción, a menudo en miles de instancias de contenedor. Por eso la seguridad en Dockerfile merece el mismo cuidado que le das al código de la aplicación: una imagen base descuidada, un COPY . . extraviado o un secreto pasado por ARG pueden entregar en silencio a un atacante un punto de apoyo que ninguna cantidad de herramientas de runtime deshará por completo. Acertar con el Dockerfile es el lugar más barato y más temprano para fortalecer un contenedor.
Lo alentador es que un Dockerfile seguro es, en gran medida, una cuestión de hábitos bien comprendidos y no de herramientas exóticas. Este análisis a fondo recorre las prácticas que más importan para el endurecimiento de contenedores: elegir imágenes base mínimas y confiables, ejecutar como un usuario sin privilegios, usar builds multi-stage para reducir la superficie de ataque, mantener los secretos fuera de las layers, minimizar lo que instalas, copiar solo lo necesario, fijar versiones, agregar un healthcheck, restringir el runtime y escanear cada image antes de distribuirla. Forma parte de nuestra guía más amplia de Docker container security, que conecta estas mejores prácticas de Dockerfile a nivel de la image con las preocupaciones de registro y de runtime.
Parte de una imagen base mínima y confiable
Cada línea de tu Dockerfile se apoya en la imagen base, así que la imagen base es tu mayor riesgo heredado. Una image completa de ubuntu o node trae un shell, un gestor de paquetes y decenas de bibliotecas que nunca vas a invocar, pero que un atacante sí podría. Prefiere una variante mínima, una tag -slim o, mejor aún, una image distroless que contenga únicamente el runtime de tu lenguaje y sus dependencias, sin shell y sin gestor de paquetes del que abusar.
Igual de importante: sé exacto sobre lo que estás descargando. Usa imágenes oficiales o de publishers verificados y fíjalas por digest en lugar de una tag móvil. Una tag como :latest o incluso :20 puede apuntar mañana a un contenido distinto del de hoy, lo que rompe en silencio la reproducibilidad y permite que un cambio upstream se cuele sin revisión.
# Fragile: the tag can move under you
FROM node:latest
# Better: a slim, digest-pinned base you can reproduce and audit
FROM node:20.11.1-slim@sha256:2b3f1c...
Fijar por digest significa que el build es reproducible byte a byte y que cualquier cambio en la imagen base se convierte en un commit deliberado y revisable, en lugar de una sorpresa silenciosa.
Ejecuta como un usuario non-root
De forma predeterminada, los procesos dentro de un contenedor se ejecutan como root. Si un atacante escapa de la aplicación, root en el contenedor sumado a un bug del kernel o a una mala configuración puede convertirse en root en el host. La mayoría de las cargas de trabajo nunca lo necesitan. Crea un usuario sin privilegios y cambia a él antes de que arranque el proceso principal del contenedor.
# Create a dedicated, unprivileged user and own the app directory
RUN groupadd --system app && useradd --system --gid app --no-create-home app
WORKDIR /app
COPY --chown=app:app . .
USER app
USER app garantiza que el entrypoint se ejecute sin privilegios. Combínalo con la eliminación de las capabilities de Linux que no necesitas, para que incluso los permisos que root normalmente tendría queden despojados. Las capabilities se aplican en runtime, pero el Dockerfile es donde señalas y documentas la intención, y donde un USER non-root hace que el valor predeterminado de mínimo privilegio se mantenga.
Dos detalles son fáciles de pasar por alto. Primero, coloca la instrucción USER después de cualquier paso que genuinamente necesite derechos elevados, como instalar paquetes, para que esos pasos sigan funcionando mientras el proceso en ejecución permanece sin privilegios. Segundo, asegúrate de que el usuario sin privilegios sea realmente el dueño de los archivos y directorios en los que debe escribir en runtime; el COPY --chown se encarga de los archivos que copias, y un chown explícito cubre los directorios en los que la aplicación crea datos. Si te saltas eso, el contenedor arranca con el usuario correcto, pero falla en el momento en que intenta escribir, lo que suele empujar a los equipos de vuelta a root por frustración.
Usa builds multi-stage
Construir software requiere compiladores, headers, paquetes de desarrollo y, a veces, credenciales. Nada de eso pertenece a la image que despliegas. Un build multi-stage te permite hacer el trabajo pesado en una etapa y copiar solo el artefacto final a una etapa final limpia y mínima.
# Stage 1: build with the full toolchain
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /bin/app ./cmd/app
# Stage 2: ship only the binary on a distroless runtime
FROM gcr.io/distroless/static-debian12@sha256:...
COPY --from=build /bin/app /bin/app
USER nonroot:nonroot
ENTRYPOINT ["/bin/app"]
La image de runtime no tiene compilador, ni shell, ni caché de build, así que la superficie de ataque se reduce drásticamente. Los builds multi-stage también son la manera más limpia de asegurarte de que las herramientas de build y los archivos intermedios nunca se filtren a lo que se ejecuta en producción.
Mantén los secretos totalmente fuera de la image
Este es el error que más duele, porque es invisible en el contenedor en ejecución pero permanente en la image. Los secretos pasados por ARG o ENV, o copiados como archivos, se escriben en las layers de la image, y esas layers viven para siempre en el historial de la image. Borrar un archivo en una layer posterior no lo elimina: cualquiera que pueda descargar la image puede ejecutar docker history o desempacar las layers y leerlo.
# Wrong: the token is now baked into a layer for good
ARG NPM_TOKEN
RUN npm ci
En su lugar, usa los secret mounts de BuildKit. El secreto está disponible únicamente durante esa única instrucción RUN y nunca se escribe en una layer.
# syntax=docker/dockerfile:1
FROM node:20.11.1-slim@sha256:...
WORKDIR /app
COPY package*.json ./
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN="$(cat /run/secrets/npm_token)" npm ci
Luego lo suministras en el momento del build con docker build --secret id=npm_token,src=./npm_token.txt. La credencial cumple su función y no deja rastro en la image final. Para los secretos de runtime, inyéctalos a través de tu orquestador o de un gestor de secretos, en lugar del Dockerfile, para que nada sensible forme parte jamás del artefacto.
Minimiza layers, paquetes y cachés
Cada paquete que instalas es más código que puede acarrear una vulnerabilidad, y cada caché sobrante es peso muerto y superficie de ataque adicional. Instala solo lo que necesitas, evita los extras recomendados pero innecesarios y haz la limpieza en el mismo RUN, para que la limpieza caiga en la misma layer.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
--no-install-recommends evita que apt arrastre paquetes opcionales, y eliminar las listas de paquetes en la misma layer impide que inflen la image. Menos paquetes significan menos CVE que clasificar y una superficie menor que defender.
Copia solo lo necesario y usa un .dockerignore
COPY . . es cómodo y peligroso. Barre alegremente todo tu directorio de trabajo hacia la image, incluyendo el historial .git, archivos .env locales, credenciales y artefactos de CI. Copia rutas específicas y respáldalo con un .dockerignore, para que los archivos sensibles nunca entren en el contexto de build en primer lugar.
# Copy dependency manifests first, then only the source you need
COPY package*.json ./
RUN npm ci
COPY src ./src
# .dockerignore
.git
.env
node_modules
*.log
**/secrets/*
Un buen .dockerignore también acelera los builds al mantener el contexto pequeño, así que se paga a sí mismo dos veces.
Fija las versiones de dependencias y paquetes
La reproducibilidad es una propiedad de seguridad. Si un build puede descargar una versión distinta de una dependencia o de un paquete del SO en cada ejecución, no puedes razonar sobre lo que hay realmente en la image, y una release upstream comprometida puede colarse sin ser notada. Fija las dependencias de la aplicación mediante un lockfile versionado e instala a partir de él, y fija los paquetes del SO a versiones conocidas donde tu distribución base lo permita.
# Install from the committed lockfile, not "whatever is latest"
COPY package.json package-lock.json ./
RUN npm ci
npm ci (y sus equivalentes en otros ecosistemas) instala exactamente lo que el lockfile especifica y falla si ambos difieren, que es precisamente el comportamiento que quieres en un pipeline de build.
Agrega un healthcheck y restringe el runtime
Un HEALTHCHECK le da a tu orquestador una señal confiable de cuándo un contenedor está genuinamente atendiendo, y no solo iniciado, lo que lo ayuda a reemplazar con prontitud las instancias comprometidas o atascadas.
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD ["/bin/app", "healthcheck"]
El Dockerfile también prepara el terreno para un runtime restringido. Diseña la image de modo que pueda ejecutarse con un sistema de archivos raíz de solo lectura, escribiendo únicamente en volúmenes montados explícitamente o en tmpfs, y de modo que no necesite binarios setuid ni setgid. Los binarios setuid y setgid permiten que un programa se ejecute con los privilegios del dueño del archivo sin importar quién lo invoque, que es exactamente el tipo de vía de escalada latente que no quieres tener alojada en un contenedor. Puedes encontrar y eliminar esos bits durante el build y, después, imponer el sistema de archivos raíz de solo lectura y la eliminación de capabilities cuando ejecutes el contenedor.
# Remove setuid/setgid bits from binaries you do not need them on
RUN find / -xdev -perm /6000 -type f -exec chmod a-s {} + || true
Una image construida para ejecutarse en solo lectura y sin privilegios, sin vías de escalada por setuid, le da a un atacante mucho menos espacio para persistir, manipular el sistema de archivos o escalar privilegios. El Dockerfile no puede activar por sí mismo la bandera de solo lectura, eso es una configuración de runtime, pero construir la image para tolerarla es lo que hace indoloro activarla después.
Verifica, escanea y etiqueta en CI
La última salvaguarda es la automatización. Un Dockerfile que se veía bien el trimestre pasado puede estar plagado de CVE recién divulgadas hoy, porque se descubren vulnerabilidades en dependencias que ya distribuiste. Escanea cada image en cada build en CI, haz que el pipeline falle ante hallazgos de alta severidad y agrega labels de procedencia para que puedas rastrear cualquier image hasta su origen.
LABEL org.opencontainers.image.source="https://example.com/org/repo" \
org.opencontainers.image.revision="$GIT_SHA"
El escaneo automatizado es lo que convierte todas las prácticas anteriores de buenas intenciones en un estándar obligatorio. Detecta la imagen base vulnerable que nadie notó, el secreto que se coló en una layer y el paquete que adquirió una CVE crítica desde la última release. Los valores predeterminados inseguros y las fallas de tipo inyección siguen siendo un tema recurrente en el OWASP Top 10 (2025), y las imágenes de contenedor son uno de los lugares donde esas debilidades se acumulan en silencio.
Cómo ayuda Rainforest
Rainforest lleva tus imágenes de contenedor al mismo flujo de seguridad que el resto de tu código. Nuestro container security scanning analiza las imágenes en busca de imágenes base vulnerables y desactualizadas, secretos expuestos incrustados en layers, configuración riesgosa y valores predeterminados inseguros, y luego expone los hallazgos con la severidad y el contexto que tu equipo necesita para actuar. Como se ejecuta como parte de la más amplia plataforma de application security testing, el mismo escaneo se dispara en cada build, así que una imagen riesgosa se señala antes de llegar a un registro, y no después de un incidente.
La recompensa son imágenes en las que realmente puedes confiar: bases mínimas y fijadas, non-root de forma predeterminada, sin secretos en las layers y una compuerta de CI que mantiene honesto cada build. ¿Quieres verlo contra tus propias imágenes? Agenda una demo y lo recorreremos con tu stack.