Dónde terminan las variables de entorno de un contenedor
Arranca un contenedor con -e DB_PASSWORD=hunter2 y ejecuta docker inspect sobre él:
| |
| |
La contraseña queda ahí en texto plano, legible por cualquiera con acceso al socket de Docker, por docker exec api env y por cualquier proceso que corra dentro del contenedor. No es un bug: es justo para lo que existen las variables de entorno. El problema es usarlas para valores que nunca deberían ser tan visibles.
Dónde definir una variable de entorno en Docker: en build o en tiempo de ejecución
Cuatro mecanismos, tres ciclos de vida distintos. Dónde defines una variable decide cuánto dura y quién puede volver a leerla:
| Mecanismo | Se define en | ¿Queda en la imagen? | ¿Visible con docker inspect? |
|---|---|---|---|
ARG en el Dockerfile | Solo en build | No (salvo que se copie a ENV) | No, pero queda en el historial de build |
ENV en el Dockerfile | En build | Sí, incrustada en cada capa posterior | Sí |
-e / --env en docker run | Al arrancar el contenedor | No | Sí |
--env-file en docker run | Al arrancar el contenedor | No | Sí |
En tiempo de ejecución las pasas una por una, o desde un archivo:
| |
.env.production se ve así:
| |
--env-file es la mejor opción en cuanto superas dos o tres variables: mantiene el comando de arranque legible y los valores en un solo sitio que puedes comparar con un diff.
Variables de entorno en Docker Compose: environment, env_file y .env
Compose tiene tres sitios donde poner variables, y dos son archivos con nombres casi idénticos. .env y env_file: hacen trabajos completamente distintos.
.enven la raíz del proyecto lo lee el propio Compose, para sustituir los placeholders${VARIABLE}dentro decompose.yaml. Nunca llega al contenedor a menos que también lo referencies bajoenvironment:.env_file:enumera archivos cuyo contenido se inyecta en el entorno del contenedor, igual que--env-fileendocker run.environment:define variables directamente en el archivo Compose, en línea.
| |
Cuando una variable se define en más de un sitio, Compose la resuelve en este orden, de mayor a menor prioridad: un -e pasado a docker compose run, luego environment:, luego env_file:, luego el ENV que ya trae la imagen. Si NODE_ENV aparece tanto en environment: como en .env.api, gana el valor de environment:.
Por qué las variables de entorno no son el lugar correcto para los secrets
Ninguno de los mecanismos anteriores oculta el valor a quien tenga acceso al contenedor o al host:
docker inspectimprime cada variable en tiempo de ejecución, como se vio arriba.docker exec <contenedor> envlas imprime desde dentro.- Un proceso hijo hereda todo el entorno, incluido un debugger, un crash reporter o una dependencia que no auditaste.
- Cualquier cosa que vuelque su entorno al arrancar manda el valor a tu agregador de logs, y una cantidad sorprendente de frameworks hace justo eso cuando el debug logging está activo.
- Las variables de entorno definidas en build con
ENVson permanentes:docker history --no-trunc my-apimuestra el valor exacto, y queda en la imagen en cada registry donde la publiques.
Nada de esto requiere que un atacante comprometa el contenedor. Basta el acceso que ya tiene mucha gente: el socket de Docker, los logs de la CI, el registry de imágenes.
Cómo funcionan los secrets de Docker: el valor se monta como archivo
Deja de pasar credenciales al contenedor como variables de entorno y móntalas como archivos. Compose lo hace por su cuenta, sin necesidad de Swarm:
| |
Compose monta db_password.txt en /run/secrets/db_password dentro del contenedor, en modo solo lectura y en ningún otro sitio: nunca aparece en docker inspect, en docker exec ... env, ni en la imagen. El sufijo _FILE es una convención que ya soportan las imágenes oficiales de Postgres, MySQL y MongoDB: al arrancar, el script de entrypoint lee el archivo en lugar de esperar el valor directamente.
Si tu aplicación no soporta esa convención, lee el archivo tú mismo al arrancar: una línea en la mayoría de los lenguajes, por ejemplo open('/run/secrets/db_password').read().strip() en Python.
En un clúster Swarm, el secret equivalente lo crea y distribuye el orquestador en lugar de un archivo local:
| |
Swarm guarda el secret cifrado en reposo y en tránsito, y lo monta en un filesystem en memoria en cada réplica: nunca se escribe en la capa editable del contenedor.
Mantener los secrets fuera de la imagen en build
ARG y ENV en un Dockerfile tienen el mismo problema un paso antes: un valor pasado como argumento de build queda registrado en el historial de build de la imagen incluso si nunca lo conviertes en ENV.
| |
| |
Ese comando docker history lo encuentra. El token desaparece del filesystem final si no copias ese archivo de configuración a una capa posterior con COPY, pero queda legible para siempre en los metadatos de la imagen, que viajan con ella a cada registry.
El flag --secret de BuildKit evita esto montando el valor en un único paso RUN como archivo en memoria que nunca se convierte en capa:
| |
| |
npm_token existe solo durante esa instrucción RUN y no queda registrado en ningún sitio donde docker history pueda verlo.
Cuándo usar una variable de entorno y cuándo un secret
| Situación | Usa |
|---|---|
| Configuración no sensible (puerto, log level, feature flag) | -e, --env-file, o environment:/env_file: de Compose |
| Contraseña de base de datos, API key, clave TLS en tiempo de ejecución | Un secret de Docker, montado como archivo |
Token de autenticación necesario solo durante docker build | --secret de BuildKit con RUN --mount=type=secret |
| Valor incrustado en la imagen a propósito (versión de la app, commit de build) | ARG copiado a ENV: está bien, no es un secret |
Una sola pregunta lo decide: ¿te importaría que este valor se filtrara? Si la respuesta es sí, no pasa por -e, --env-file, environment: ni por un ARG/ENV en el Dockerfile. Pasa por un secret montado como archivo. La configuración que tu app imprime feliz en sus propios logs se queda como variables de entorno normales, más fáciles de sobrescribir por entorno.
Ejecuta docker inspect --format '{{json .Config.Env}}' sobre los contenedores que tienes corriendo ahora mismo y lee la salida como si fuera el diff de un pull request. Cualquier cosa ahí dentro que no querrías ver revisada en público debería mudarse a un bloque secrets: en tu archivo Compose, con una variable _FILE en lugar de la contraseña. Si el archivo necesita sobrevivir al contenedor en vez de venir del contexto de build, ponlo en un volumen Docker.