El compose.yaml de Aurora Libros funciona, pero tiene la contraseña de PostgreSQL escrita en claro, la versión de la imagen fijada a mano y el puerto 8080 incrustado. Así no sirve para más de un entorno ni puede subirse a un repositorio compartido.
Este es el tema donde más gente se pierde, y por una razón concreta: hay dos sistemas distintos que se parecen y no tienen nada que ver. Uno actúa sobre el fichero YAML antes de arrancar nada; el otro define lo que ve el proceso dentro del contenedor. Sepáralos desde la primera línea y todo lo demás encaja.
Contenido
- Los dos sistemas, separados desde el principio
- Sistema A: sustitución de variables en el YAML
- De dónde saca Compose los valores para sustituir
- Sistema B: variables dentro del contenedor
- La tabla de precedencia completa
- El fichero
.envdel proyecto - Aurora Libros parametrizada:
.envy.env.example - Gestión de secretos:
environmentno es un sitio para contraseñas - Depuración con
docker compose config
- Los dos sistemas, separados desde el principio
| Sistema A: sustitución | Sistema B: entorno del contenedor | |
|---|---|---|
| Sintaxis | ${VARIABLE} en cualquier parte del YAML |
Claves environment: y env_file: |
| Cuándo actúa | Antes de crear nada, al leer el fichero | Al crear el contenedor |
| Quién lo procesa | El proceso docker compose en tu máquina |
El demonio Docker, dentro del contenedor |
| Para qué sirve | Parametrizar el propio fichero: versiones, puertos, rutas | Configurar la aplicación: DB_HOST, PORT... |
| Fuente principal | El fichero .env del proyecto y tu shell |
Lo que escribas en environment / env_file |
| ¿Lo ve el proceso? | No, salvo que además lo pases por el sistema B | Sí, es su entorno |
graph LR
E[".env del proyecto"] --> S
SH["Entorno del shell"] --> S
CLI["--env-file"] --> S
S["SUSTITUCIÓN<br/>Compose resuelve los ${VAR}"] --> Y["compose.yaml resuelto<br/>(docker compose config)"]
Y --> D["Docker crea el contenedor"]
EN["environment:"] --> D
EF["env_file:"] --> D
IMG["ENV del Dockerfile"] --> D
D --> P["Entorno del proceso<br/>dentro del contenedor"]
La confusión clásica: alguien escribe DB_PASSWORD=secreta en el .env y cree que la aplicación la verá. No la verá. El .env alimenta la sustitución; para que llegue al contenedor hace falta, además, un environment: { DB_PASSWORD: ${DB_PASSWORD} } o un env_file.
- Sistema A: sustitución de variables en el YAML
Cualquier ${VARIABLE} o $VARIABLE en el fichero se sustituye antes de que Docker vea nada. Sirve en cualquier posición: nombres de imagen, puertos, rutas, límites.
services:
aurora-api:
image: auroralibros/aurora-api:${AURORA_API_VERSION}
ports:
- "${API_PUERTO}:3000"Usa siempre las llaves, ${VAR}: sin ellas, $VARIABLE_larga puede recortarse de forma inesperada al tocar un carácter no alfanumérico.
Los modificadores son la parte que de verdad marca la diferencia entre un fichero frágil y uno robusto:
| Sintaxis | Si la variable no está definida | Si está definida pero vacía |
|---|---|---|
${VAR} |
Cadena vacía (silencioso) | Cadena vacía |
${VAR:-defecto} |
defecto |
defecto |
${VAR-defecto} |
defecto |
Cadena vacía |
${VAR:?mensaje} |
Error y aborta | Error y aborta |
${VAR?mensaje} |
Error y aborta | Cadena vacía |
Los dos puntos significan "trata el valor vacío igual que el ausente". En la práctica: :- para lo que tenga un valor por defecto sensato y :? para lo que no puede faltar.
image: auroralibros/aurora-api:${AURORA_API_VERSION:-1.2.0}
ports:
- "${WEB_PUERTO:-8080}:80"
environment:
DB_PASSWORD: ${DB_PASSWORD:?falta DB_PASSWORD; copia .env.example a .env}error while interpolating services.aurora-db.environment.POSTGRES_PASSWORD:
required variable DB_PASSWORD is missing a value: falta DB_PASSWORD; copia .env.example a .envUn error inmediato y con instrucciones, en lugar de una base de datos que arranca con contraseña vacía. Esa es la diferencia entre ${VAR} y ${VAR:?...}, y merece la pena aplicarla a todas las credenciales.
Cuando necesites un $ literal —muy habitual en command de shell o en patrones de nginx— duplícalo:
command: sh -c 'echo "PID actual: $$$$"; exec node server.js'
environment:
PLANTILLA: "Hola $${NOMBRE}" # llega al contenedor como: Hola ${NOMBRE}Un solo $ lo consume Compose; $$ produce un $ literal en la salida. Si ves errores del tipo Invalid interpolation format, casi siempre es un $ sin escapar.
- De dónde saca Compose los valores para sustituir
Para resolver un ${VAR}, Compose busca en este orden, y el primero que encuentre gana:
| Orden | Fuente |
|---|---|
| 1 | Variable pasada en la misma línea: AURORA_API_VERSION=1.3.0 docker compose up -d |
| 2 | Variable exportada en tu shell (export AURORA_API_VERSION=1.3.0) |
| 3 | Fichero indicado con --env-file |
| 4 | Fichero .env del directorio del proyecto |
| 5 | Valor por defecto del propio ${VAR:-...} |
Que el shell gane al .env es deliberado y muy práctico: permite a un pipeline de CI sobrescribir la versión de la imagen sin tocar ningún fichero.
- Sistema B: variables dentro del contenedor
Forma de mapa (recomendada: se lee mejor y se fusiona bien entre ficheros de override):
Forma de lista, con una capacidad que la de mapa no tiene:
environment:
- PORT=3000
- DB_HOST=aurora-db
- HTTP_PROXY # SIN valor: toma el del shell del host (paso a través)Esa última línea es el pass-through: si HTTP_PROXY existe en tu shell, se pasa al contenedor con su valor; si no existe, la variable no se define. Útil para proxies corporativos y credenciales temporales que no quieres escribir en ningún fichero.
env_file carga variables desde ficheros, en el orden dado y ganando el último:
env_file:
- ./config/comunes.env
- path: ./config/local.env
required: false # si no existe, no falla (forma larga)| Aspecto | environment |
env_file |
|---|---|---|
| Dónde está el valor | En el compose.yaml |
En un fichero aparte |
¿Se interpola ${...}? |
Sí | También, desde Compose v2.24 |
| ¿Va a Git? | Sí, con el fichero | Normalmente no |
| Precedencia | Mayor | Menor |
| Buen uso | Valores no sensibles y estructurales | Muchas variables o valores por entorno |
Sintaxis de un fichero .env/env_file: una CLAVE=valor por línea, # para comentarios, sin espacios alrededor del =, sin export, y las comillas se conservan como parte del valor salvo que envuelvan toda la cadena. DB_PASSWORD="secreta" y DB_PASSWORD=secreta dan el mismo resultado; DB_PASSWORD=se creta también funciona, porque no hace falta entrecomillar los espacios.
- La tabla de precedencia completa
Cuando una misma variable aparece en varios sitios, este es el orden que decide qué ve el proceso, de mayor a menor prioridad:
| # | Origen | Ejemplo |
|---|---|---|
| 1 | docker compose run -e / exec -e |
docker compose run -e NODE_ENV=test aurora-api npm test |
| 2 | environment sin valor (paso a través del shell) |
- NODE_ENV con export NODE_ENV=debug en el shell |
| 3 | environment con valor |
environment: { NODE_ENV: production } |
| 4 | --env-file indicado en la CLI |
--env-file .env.prod |
| 5 | env_file declarado en el servicio |
env_file: [./config/api.env] |
| 6 | ENV del Dockerfile de la imagen |
ENV NODE_ENV=production |
Y una precisión que evita horas perdidas: el .env del proyecto no aparece en esta tabla. No inyecta nada en los contenedores; solo alimenta la sustitución del sistema A. Si quieres que una variable del .env llegue al proceso, tienes que pasarla explícitamente.
# Comprobación empírica de la precedencia
docker compose exec aurora-api env | sort | grep -E "^(NODE_ENV|DB_HOST|PORT)="
docker compose run --rm -e NODE_ENV=test aurora-api env | grep NODE_ENV
- El fichero
.env del proyecto
.env del proyectoEs un fichero llamado exactamente .env, situado en el directorio del proyecto (el del compose.yaml, o el que indiques con --project-directory). Compose lo carga automáticamente y lo usa solo para la interpolación.
# .env — valores locales de Aurora Libros. NO se sube a Git.
AURORA_API_VERSION=1.2.0
WEB_PUERTO=8080
DB_USUARIO=aurora
DB_PASSWORD=aurora_secreta
DB_NOMBRE=aurora_libros
COMPOSE_PROJECT_NAME=aurora-librosFíjate en la última línea: en el .env también puedes fijar las variables de configuración de la propia CLI —COMPOSE_PROJECT_NAME, COMPOSE_FILE, COMPOSE_PROFILES—, que afectan al comportamiento de Compose y no a los contenedores.
Con --env-file puedes usar otro fichero, o varios:
docker compose --env-file .env.produccion config
docker compose --env-file .env --env-file .env.local up -d # el último gana
- Aurora Libros parametrizada:
.env y .env.example
.env y .env.exampleAplica el sistema A a las tres cosas que cambian entre entornos: versión de la imagen, puertos publicados y credenciales.
services:
aurora-db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${DB_USUARIO:-aurora}
POSTGRES_PASSWORD: ${DB_PASSWORD:?define DB_PASSWORD en tu fichero .env}
POSTGRES_DB: ${DB_NOMBRE:-aurora_libros}
aurora-api:
image: auroralibros/aurora-api:${AURORA_API_VERSION:-1.2.0}
environment:
PORT: "3000"
DB_HOST: aurora-db
DB_USER: ${DB_USUARIO:-aurora}
DB_PASSWORD: ${DB_PASSWORD:?define DB_PASSWORD en tu fichero .env}
DB_NAME: ${DB_NOMBRE:-aurora_libros}
REDIS_HOST: aurora-cache
NODE_ENV: ${NODE_ENV:-production}
LOG_NIVEL: ${LOG_NIVEL:-info}
aurora-web:
image: nginx:alpine
ports:
- "${WEB_PUERTO:-8080}:80"Ahora, la pieza cultural que convierte esto en algo usable por un equipo: el .env no se versiona, pero su plantilla sí.
# .env.example — plantilla versionada en Git.
# Cópiala a .env y rellena los valores locales: cp .env.example .env
AURORA_API_VERSION=1.2.0
WEB_PUERTO=8080
DB_USUARIO=aurora
DB_NOMBRE=aurora_libros
NODE_ENV=production
LOG_NIVEL=info
# Obligatoria y sin valor por defecto: cada persona pone la suya en local.
DB_PASSWORD=El .env real no aparece: está ignorado. Con esto, quien clona el repositorio ejecuta cp .env.example .env, escribe su contraseña y levanta la plataforma. Y si se olvida, no obtiene un fallo críptico, sino el mensaje de ${DB_PASSWORD:?...} diciéndole exactamente qué hacer.
- Gestión de secretos:
environment no es un sitio para contraseñas
environment no es un sitio para contraseñasTodo lo anterior parametriza bien, pero no protege nada. Una contraseña en environment es visible para cualquiera que tenga acceso al demonio Docker:
docker inspect aurora-libros-aurora-db-1 --format '{{range .Config.Env}}{{println .}}{{end}}' | grep PASS
docker compose exec aurora-api env | grep PASSWORD
docker compose exec aurora-api cat /proc/1/environ | tr '\0' '\n' | grep PASSTres vías distintas, la misma contraseña en claro. Y hay más: las variables de entorno acaban en los volcados de estado del contenedor, se heredan a todos los procesos hijos (incluida cualquier dependencia de terceros que decida enviarlas en un informe de errores) y se cuelan en los registros de auditoría.
La alternativa de Compose es el bloque secrets:, que monta cada secreto como un fichero dentro de /run/secrets/:
secrets:
db_password:
file: ./secretos/db_password.txt # el fichero vive fuera de Git
services:
aurora-db:
image: postgres:16-alpine
environment:
POSTGRES_USER: aurora
POSTGRES_PASSWORD_FILE: /run/secrets/db_password # la RUTA, no el valor
POSTGRES_DB: aurora_libros
secrets:
- db_password
aurora-api:
environment:
DB_PASSWORD_FILE: /run/secrets/db_password
secrets:
- source: db_password
target: db_password # ruta final: /run/secrets/db_password
mode: 0400 # solo lectura para el propietarioLas imágenes oficiales de PostgreSQL, MySQL y muchas otras soportan el sufijo _FILE en sus variables: si defines POSTGRES_PASSWORD_FILE, el entrypoint lee la contraseña del fichero y nunca la expone como variable de entorno. Para tu propio código, el patrón es de tres líneas:
import { readFileSync } from 'node:fs';
// Prefiere el fichero de secreto; cae a la variable solo si no existe.
const dbPassword = process.env.DB_PASSWORD_FILE
? readFileSync(process.env.DB_PASSWORD_FILE, 'utf8').trim()
: process.env.DB_PASSWORD;mkdir -p secretos && chmod 700 secretos
printf 'aurora_secreta' > secretos/db_password.txt # printf, no echo: sin salto de línea
chmod 600 secretos/db_password.txt
docker compose up -d
docker compose exec aurora-db env | grep -c "PASSWORD=aurora_secreta" || echo "ya no está en el entorno"
docker compose exec aurora-db cat /run/secrets/db_passwordEl secreto ha desaparecido del entorno y vive en un fichero con permisos restringidos, montado en un tmpfs que no toca el disco del contenedor y no queda en ninguna capa de la imagen.
| Método | Visible en inspect |
En el entorno de los hijos | En capas de la imagen | Recomendación |
|---|---|---|---|---|
ENV en el Dockerfile |
Sí | Sí | Sí, para siempre | Nunca |
environment en Compose |
Sí | Sí | No | Solo valores no sensibles |
env_file |
Sí (acaba en el entorno) | Sí | No | Solo valores no sensibles |
secrets: con fichero |
No | No | No | Correcto para desarrollo y un solo host |
| Gestor externo (Vault, KMS...) | No | No | No | Producción |
Aviso importante. Lo anterior es el mecanismo básico de Compose y es adecuado para desarrollo local y despliegues pequeños en un solo host, con datos ficticios como los de este curso. El manejo de credenciales reales —rotación, control de acceso, auditoría, cifrado en reposo— debe validarse siempre con el responsable de seguridad de tu organización, que decidirá qué gestor de secretos corresponde. Nada de lo que hagas con ficheros locales sustituye a esa conversación. El endurecimiento general de contenedores se trata en la lección 05-03.
- Depuración con
docker compose config
docker compose configCuando una variable no llega donde esperas, no adivines:
docker compose config # todo resuelto
docker compose config --no-interpolate # con los ${...} sin resolver
docker compose config --format json | jq '.services["aurora-api"].environment'{
"DB_HOST": "aurora-db",
"DB_NAME": "aurora_libros",
"DB_PASSWORD": "aurora_secreta",
"NODE_ENV": "production",
"PORT": "3000"
}Comparar config con config --no-interpolate te dice al instante si el problema está en la sustitución (sistema A) o en el paso al contenedor (sistema B). Y docker compose exec <servicio> env te da la verdad definitiva: lo que el proceso ve de verdad.
Cuidado: la salida de config contiene los secretos resueltos en claro. Nunca la vuelques en un log de CI ni la pegues en un ticket.
Errores Comunes y Consejos
Creer que el .env llega a los contenedores. No llega. Solo alimenta la interpolación. Necesitas environment o env_file además.
Usar ${VAR} sin :? para valores obligatorios. Una contraseña ausente se convierte en cadena vacía y arrancas una base de datos sin contraseña, en silencio.
Subir el .env a Git. Es el escape de credenciales más frecuente que existe. Ignóralo desde el primer commit y versiona .env.example.
Olvidar el $$ para un $ literal. Provoca Invalid interpolation format o, peor, una cadena vacía donde esperabas un patrón.
Usar echo para crear el fichero de un secreto. Añade un salto de línea final que forma parte de la contraseña. Usa printf o recorta con .trim().
Poner comillas en el .env esperando que se ignoren. En algunos casos forman parte del valor; ante la duda, verifica con docker compose config.
Consejo: una regla de oro para decidir dónde va cada cosa. ¿Cambia entre entornos y no es sensible? Interpolación con ${VAR:-defecto}. ¿Es sensible? secrets:. ¿Es estructural y no cambia nunca, como DB_HOST: aurora-db? Escríbelo directamente en el compose.yaml.
Ejercicios
Ejercicio 1. Parametriza el puerto de la web con ${WEB_PUERTO:-8080} y demuestra las cuatro fuentes de valor con su precedencia: sin nada definido, con .env, con una variable exportada en el shell y con una variable en la propia línea de comandos. Usa docker compose config para verificar cada caso sin levantar nada.
Ejercicio 2. Define en el compose.yaml NODE_ENV: ${NODE_ENV:-production} y, además, un env_file que contenga NODE_ENV=desde-fichero. Predice qué valor verá el proceso, verifícalo, y explica el resultado con la tabla de precedencia.
Ejercicio 3. Convierte la contraseña de PostgreSQL en un secreto de fichero para aurora-db y aurora-api, y demuestra con tres comandos distintos que ya no aparece en el entorno de ninguno de los dos contenedores.
Soluciones
Solución 1.
# (a) Sin nada: gana el valor por defecto del propio ${...}
rm -f .env; unset WEB_PUERTO
docker compose config | grep -A1 "published"
# (b) Con .env
echo "WEB_PUERTO=9090" > .env
docker compose config | grep -A1 "published"
# (c) El shell gana al .env
export WEB_PUERTO=7070
docker compose config | grep -A1 "published"
# (d) La línea de comandos gana a todo
WEB_PUERTO=6060 docker compose config | grep -A1 "published"La escala completa en cuatro comandos: defecto < .env < shell exportado < línea de comandos. Que el shell gane al .env es lo que permite a un pipeline de CI cambiar la versión de la imagen sin tocar ficheros, y lo que a la vez explica un desconcierto muy común: si tienes una variable vieja exportada en tu sesión, el .env no la sobrescribe. Limpia con unset antes de sospechar del fichero.
Solución 2. El proceso verá production.
aurora-api:
environment:
NODE_ENV: ${NODE_ENV:-production}
env_file:
- ./config/api.env # contiene NODE_ENV=desde-ficheroEn la tabla de precedencia, environment (nivel 3) está por encima de env_file (nivel 5). El fichero solo aporta las variables que el bloque environment no define, así que sirve como base y environment como sobreescritura puntual. Y ojo con el ${NODE_ENV:-production}: si además exportas NODE_ENV=development en tu shell, la interpolación lo resuelve a development y el resultado cambia, aunque el nivel de precedencia siga siendo el 3.
Solución 3.
mkdir -p secretos && chmod 700 secretos
printf 'aurora_secreta' > secretos/db_password.txt && chmod 600 secretos/db_password.txt
grep -q "^secretos/" .gitignore || echo "secretos/" >> .gitignoresecrets:
db_password:
file: ./secretos/db_password.txt
services:
aurora-db:
environment:
POSTGRES_USER: aurora
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
POSTGRES_DB: aurora_libros
secrets: [db_password]
aurora-api:
environment:
DB_PASSWORD_FILE: /run/secrets/db_password
secrets: [db_password]Con el ajuste de server.js del apartado 8 para leer DB_PASSWORD_FILE, la verificación:
docker compose up -d --force-recreate
docker inspect aurora-libros-aurora-db-1 --format '{{range .Config.Env}}{{println .}}{{end}}' | grep -i pass
docker compose exec aurora-api env | grep -i password
docker compose exec aurora-api cat /proc/1/environ | tr '\0' '\n' | grep -i password
curl -s http://localhost:8080/api/libros | jq -r '.libros | length'POSTGRES_PASSWORD_FILE=/run/secrets/db_password
DB_PASSWORD_FILE=/run/secrets/db_password
DB_PASSWORD_FILE=/run/secrets/db_password
9Las tres vías muestran la ruta, nunca el valor, y los nueve libros confirman que la conexión sigue funcionando. Un detalle esencial: si cambias esto sobre una base de datos ya inicializada, la contraseña no se actualiza, porque POSTGRES_PASSWORD* solo se aplica en la primera inicialización del volumen. En un entorno de pruebas, docker compose down -v; en uno real, un ALTER USER.
Conclusión
Tienes separados los dos sistemas que causan casi toda la confusión con Compose. El sistema A es la sustitución ${VAR} que Compose hace sobre el YAML antes de crear nada, con sus modificadores :- y - para valores por defecto y :? y ? para valores obligatorios que abortan con un mensaje útil, el escape $$ para el dólar literal, y su orden de búsqueda: línea de comandos, shell exportado, --env-file y .env del proyecto. El sistema B es lo que ve el proceso: environment en forma de mapa o de lista —con el pass-through de una clave sin valor— y env_file con su required: false.
Conoces la tabla de precedencia completa, de run -e hasta el ENV del Dockerfile, y la trampa que encierra: el .env del proyecto no está en esa tabla, porque no inyecta nada en los contenedores. Aurora Libros queda parametrizada en versión de imagen, puertos y credenciales, con un .env local ignorado por Git y un .env.example versionado que documenta qué hace falta para arrancar.
Y sabes que environment no es sitio para una contraseña: la has visto en claro por tres vías distintas, y la has retirado con el bloque secrets:, que monta ficheros en /run/secrets/ y encaja con el patrón _FILE de las imágenes oficiales y con tres líneas de tu propio código. Con el aviso que no debes olvidar: para credenciales reales, valida siempre el enfoque con el responsable de seguridad de tu organización.
En la lección siguiente, Perfiles, Overrides y Múltiples Entornos, harás que un mismo proyecto sirva para desarrollo, pruebas y producción sin duplicar ficheros: perfiles para levantar bajo demanda herramientas como Adminer o un servicio de semillas, el compose.override.yaml automático y la combinación explícita con varios -f junto a sus reglas de fusión, extends e include: para compartir definiciones entre equipos, y la convención de nombres de proyecto que permite que las pilas de dos entornos convivan en la misma máquina.
Docker: De Principiante a Avanzado
Módulo 1: Introducción a Docker
- ¿Qué es Docker?
- Instalando Docker
- Arquitectura de Docker
- Comandos Básicos de Docker
- Entendiendo las Imágenes de Docker
- Creando tu Primer Contenedor Docker
- El Proyecto del Curso: la Plataforma Aurora Libros
Módulo 2: Trabajando con Imágenes Docker
- Docker Hub y Repositorios
- Construyendo Imágenes Docker
- Conceptos Básicos de Dockerfile
- Instrucciones Avanzadas del Dockerfile
- Gestionando Imágenes Docker
- Etiquetado y Publicación de Imágenes
Módulo 3: Contenedores Docker
- Ejecutando Contenedores
- Ciclo de Vida del Contenedor
- Gestionando Contenedores
- Inspección y Depuración de Contenedores
- Redes en Docker
- Persistencia de Datos con Volúmenes
- Límites de Recursos y Políticas de Reinicio
Módulo 4: Docker Compose
- Introducción a Docker Compose
- Definiendo Servicios en Docker Compose
- Comandos de Docker Compose
- Aplicaciones Multi-Contenedor
- Variables de Entorno en Docker Compose
- Perfiles, Overrides y Múltiples Entornos
- Desarrollo Local con Docker Compose
Módulo 5: Conceptos Avanzados de Docker
- Profundización en Redes Docker
- Opciones de Almacenamiento Docker
- Mejores Prácticas de Seguridad en Docker
- Optimizando Imágenes Docker
- Builds Avanzadas con BuildKit y Buildx
- Registro y Monitoreo en Docker
- El Runtime por Dentro: Namespaces, Cgroups y Capas
Módulo 6: Docker en Producción
- Preparar una Imagen para Producción
- CI/CD con Docker
- Orquestando Contenedores con Docker Swarm
- Introducción a Kubernetes
- Desplegando Contenedores Docker en Kubernetes
- Escalado y Balanceo de Carga
- Estrategias de Despliegue y Rollback
