Environment Variables & Secrets

Docker & DevOps · Lesson 18 of 26 7 min read Updated

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:

FeatureWhat it doesWhere values go
Variable substitution with a .env fileFills ${NAME} placeholders inside compose.yamlInto the Compose file itself
environment: and env_file:Sets environment variables inside a containerInto 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:

Text
# .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=8080
YAML
services:
  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} uses default when 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:

Output
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:

YAML
services:
  web:
    build: .
    env_file:
      - ./config/web.env
    environment:
      APP_ENV: production

If 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 inspect on 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>:

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

Many 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:

Terminal
docker compose exec db env | grep -E 'PASSWORD|USER'
Output
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:

JavaScript
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:

Dockerfile
# 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"]
Terminal
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 .env or the secrets folder. Add them to .gitignore before the first commit.
  • Expecting .env values inside containers automatically. .env only fills ${...} in compose.yaml. Use environment: or env_file: to pass values into containers.
  • Quotes and spaces in env files. DB_PASSWORD = "abc" may not mean what you expect. Write NAME=value with no spaces.
  • A trailing newline in a secret file. Trim the value when reading it.
  • Changing .env and expecting a running container to notice. Run docker compose up -d again so Compose recreates the container with the new values.

Key points

  • .env fills ${NAME} placeholders in compose.yaml; use ${NAME:-default} for defaults and ${NAME:?message} for required values.
  • environment: and env_file: set variables inside containers; environment: wins on conflicts.
  • Environment variables are visible through docker inspect, so pass passwords as secrets instead.
  • Compose secrets appear as files in /run/secrets/; many images accept *_FILE variables for them.
  • RUN --mount=type=secret gives 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.

YAML
services:
  api:
    image: delivery-api:1.0
    ports:
      - "5000:5000"
    environment:
      APP_ENV: production
      MAPS_API_KEY: AIza-real-key-do-not-share
Show solution
YAML
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.txt

The 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?

2. What does ${DB_ROOT_PASSWORD:?set it in .env} do when the variable is missing?

3. Where does a Compose secret named db_password appear inside the container?

4. Why is RUN --mount=type=secret safer than ARG NPM_TOKEN for a private package token?

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