Environment Variables & Secrets
The Compose files in the last two lessons had passwords written straight into them. That file goes into Git, so everyone who can read the repository, today or in five years, can read the database password. This is one of the most common ways real companies leak credentials.
In this lesson you will move settings out of compose.yaml into a .env file, learn the difference between settings and secrets, and pass secrets as files instead of environment variables, both when containers run and when images are built.
Three different things called "environment"
Compose has three features that all involve variables, and mixing them up causes most confusion:
| Feature | What it does | Where values go |
|---|---|---|
Variable substitution with a .env file | Fills ${NAME} placeholders inside compose.yaml | Into the Compose file itself |
environment: and env_file: | Sets environment variables inside a container | Into the running container |
secrets: | Mounts sensitive values as files | /run/secrets/<name> inside the container |
Variable substitution with .env
Compose automatically reads a file named .env in the project folder and uses it to fill ${...} placeholders in compose.yaml:
# .env (never commit this file)
DB_NAME=notices
DB_USER=meera
DB_PASSWORD=Meera-Long-Random-Password-2026
DB_ROOT_PASSWORD=Root-Long-Random-Password-2026
WEB_PORT=8080services:
web:
build: .
ports:
- "${WEB_PORT:-8080}:80"
environment:
DB_HOST: db
DB_NAME: ${DB_NAME}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
db:
image: mysql:8.4
environment:
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:?set DB_ROOT_PASSWORD in .env}The placeholder forms are worth learning:
${NAME}uses the value, or an empty string if it is not set (with a warning).${NAME:-default}usesdefaultwhen the value is missing or empty.${NAME:?message}stops Compose with an error when the value is missing, which is perfect for passwords.
If DB_ROOT_PASSWORD is missing, Compose refuses to start:
error while interpolating services.db.environment.MYSQL_ROOT_PASSWORD: required variable DB_ROOT_PASSWORD is missing a value: set DB_ROOT_PASSWORD in .env
A variable set in your shell wins over the same variable in .env, so WEB_PORT=9000 docker compose up -d uses port 9000 for one run. Run docker compose config to see the file with every placeholder filled in.
Add .env to .gitignore and .dockerignore, and commit a .env.example with the same names but fake values, so teammates know which variables to set.
env_file: many variables for one container
When a service needs many settings, list them in a file and load it with env_file. Unlike .env, these values go directly into the container; they are not used for ${...} substitution:
services:
web:
build: .
env_file:
- ./config/web.env
environment:
APP_ENV: productionIf a variable appears in both, the value under environment: wins.
Why environment variables are a weak place for secrets
Environment variables are fine for settings such as APP_ENV or DB_HOST. For passwords and API keys they have problems:
- Anyone who can run
docker inspecton the container sees them in plain text. - Every child process inherits them, and error pages or crash reports sometimes print the whole environment.
- They tend to end up in logs, CI output and screenshots.
A secret delivered as a file avoids most of this. Only processes that read the file get the value, and it is not part of the container's configuration.
Compose secrets
Declare secrets at the top level and give each service access to the ones it needs. Compose mounts each one as a read-only file at /run/secrets/<name>:
services:
db:
image: mysql:8.4
environment:
MYSQL_DATABASE: notices
MYSQL_USER: meera
MYSQL_PASSWORD_FILE: /run/secrets/db_password
MYSQL_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
secrets:
- db_password
- db_root_password
web:
build: .
environment:
DB_HOST: db
DB_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt
db_root_password:
file: ./secrets/db_root_password.txtMany official images, including MySQL, PostgreSQL and MariaDB, support a _FILE version of their password variables: instead of the password, you give the path of a file that contains it. Check the environment now:
docker compose exec db env | grep -E 'PASSWORD|USER'MYSQL_PASSWORD_FILE=/run/secrets/db_password MYSQL_ROOT_PASSWORD_FILE=/run/secrets/db_root_password MYSQL_USER=meera
No password appears in the environment. Your own app can follow the same convention with a few lines of code. Here is a Node.js helper that prefers NAME_FILE and falls back to NAME:
import { readFileSync } from "node:fs";
// Read NAME_FILE if it is set, otherwise fall back to NAME
export function secret(name) {
const file = process.env[`${name}_FILE`];
if (file) return readFileSync(file, "utf8").trim();
const value = process.env[name];
if (value === undefined) throw new Error(`Missing secret ${name}`);
return value;
}
const dbPassword = secret("DB_PASSWORD");trim() removes the newline that most editors add at the end of the file, a classic cause of "access denied" errors. Keep the secrets folder out of Git too.
Build secrets
Sometimes the build needs a secret, for example a token to install private npm packages. Never use ARG or COPY for it: both leave the value in the image. BuildKit can mount a secret for a single RUN step only:
# syntax=docker/dockerfile:1
FROM node:26-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]docker build --secret id=npmrc,src=$HOME/.npmrc -t shop-api:1.0 .During that one RUN, npm finds its configuration with the token at /root/.npmrc. The file is not stored in any layer, so docker image history and anyone who pulls the image cannot see it.
Common mistakes
- Committing
.envor thesecretsfolder. Add them to.gitignorebefore the first commit. - Expecting
.envvalues inside containers automatically..envonly fills${...}incompose.yaml. Useenvironment:orenv_file:to pass values into containers. - Quotes and spaces in env files.
DB_PASSWORD = "abc"may not mean what you expect. WriteNAME=valuewith no spaces. - A trailing newline in a secret file. Trim the value when reading it.
- Changing
.envand expecting a running container to notice. Rundocker compose up -dagain so Compose recreates the container with the new values.
Key points
.envfills${NAME}placeholders incompose.yaml; use${NAME:-default}for defaults and${NAME:?message}for required values.environment:andenv_file:set variables inside containers;environment:wins on conflicts.- Environment variables are visible through
docker inspect, so pass passwords as secrets instead. - Compose
secretsappear as files in/run/secrets/; many images accept*_FILEvariables for them. RUN --mount=type=secretgives a build step a secret without storing it in the image.
Exercise
Rewrite this service so that the port has a default of 5000, the API key comes from a Compose secret stored in ./secrets/maps_key.txt, and Compose refuses to start if APP_ENV is not set in .env.
services:
api:
image: delivery-api:1.0
ports:
- "5000:5000"
environment:
APP_ENV: production
MAPS_API_KEY: AIza-real-key-do-not-shareShow solution
services:
api:
image: delivery-api:1.0
ports:
- "${API_PORT:-5000}:5000"
environment:
APP_ENV: ${APP_ENV:?set APP_ENV in .env}
MAPS_API_KEY_FILE: /run/secrets/maps_key
secrets:
- maps_key
secrets:
maps_key:
file: ./secrets/maps_key.txtThe key no longer appears in the Compose file or the container's environment; the app reads it from /run/secrets/maps_key. Because the key was in the old file, it may already be in Git history, so it should be revoked and replaced.
Quick Quiz
1. What does Compose do with a .env file in the project folder?
To pass values into a container, use environment: or env_file:.
2. What does ${DB_ROOT_PASSWORD:?set it in .env} do when the variable is missing?
The :? form makes a variable required.
3. Where does a Compose secret named db_password appear inside the container?
Secrets are mounted as read-only files.
4. Why is RUN --mount=type=secret safer than ARG NPM_TOKEN for a private package token?
Build arguments can be read from the image's history.
Learn Docker & DevOps with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
