Dónde terminan las variables de entorno de un contenedor

Arranca un contenedor con -e DB_PASSWORD=hunter2 y ejecuta docker inspect sobre él:

1
2
docker run -d --name api -e DB_PASSWORD=hunter2 nginx:alpine
docker inspect --format '{{json .Config.Env}}' api
1
["DB_PASSWORD=hunter2","PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]

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:

MecanismoSe define en¿Queda en la imagen?¿Visible con docker inspect?
ARG en el DockerfileSolo en buildNo (salvo que se copie a ENV)No, pero queda en el historial de build
ENV en el DockerfileEn buildSí, incrustada en cada capa posterior
-e / --env en docker runAl arrancar el contenedorNo
--env-file en docker runAl arrancar el contenedorNo

En tiempo de ejecución las pasas una por una, o desde un archivo:

1
2
3
4
5
# Una por una, repetible
docker run -e NODE_ENV=production -e PORT=3000 my-api

# Desde un archivo, un KEY=VALUE por línea, sin comillas
docker run --env-file .env.production my-api

.env.production se ve así:

1
2
3
NODE_ENV=production
PORT=3000
LOG_LEVEL=info

--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.

  • .env en la raíz del proyecto lo lee el propio Compose, para sustituir los placeholders ${VARIABLE} dentro de compose.yaml. Nunca llega al contenedor a menos que también lo referencies bajo environment:.
  • env_file: enumera archivos cuyo contenido se inyecta en el entorno del contenedor, igual que --env-file en docker run.
  • environment: define variables directamente en el archivo Compose, en línea.
1
2
3
4
5
6
7
services:
  api:
    image: my-api
    environment:
      - NODE_ENV=production
    env_file:
      - .env.api

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 inspect imprime cada variable en tiempo de ejecución, como se vio arriba.
  • docker exec <contenedor> env las 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 ENV son permanentes: docker history --no-trunc my-api muestra 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt

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:

1
2
echo "supersecret" | docker secret create db_password -
docker service create --name db --secret db_password postgres:16

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.

1
2
3
FROM node:20-slim
ARG NPM_TOKEN
RUN npm config set //registry.npmjs.org/:_authToken=${NPM_TOKEN} && npm install
1
2
docker build --build-arg NPM_TOKEN=npm_abc123 -t my-api .
docker history --no-trunc my-api | grep npm_abc123

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:

1
2
3
4
# syntax=docker/dockerfile:1
FROM node:20-slim
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) npm config set //registry.npmjs.org/:_authToken=${NPM_TOKEN} && npm install
1
docker buildx build --secret id=npm_token,src="$HOME/.npm_token" -t my-api .

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ónUsa
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ónUn 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.

Artículos relacionados