Docker · Lesson 7 of 11

Environment Variables, Config and Secrets

Configure Docker containers with environment variables, .env files and build args,, and keep secrets safe with BuildKit secret mounts and Compose secrets.

  • Intermediate
  • 15 min read
  • 4 objectives

Before this lessonLesson 6: Multi-stage Builds and Small Images

What you will learn

  • Pass configuration with -e, --env-file and Compose
  • Tell ARG apart from ENV
  • Keep secrets out of image layers with BuildKit secret mounts
  • Use Compose secrets mounted as files

Your Progress

0 of 11 lessons 0%

  • Lessons0 / 11
  • Completed0
  • Est. time left~ 3 hours

Create a free account to keep your progress on every device.

Tip: pressing Next marks this lesson complete automatically.

One of the promises of containers is build once, run anywhere: the exact same image runs on your laptop, in staging and in production. That only works if the image does not bake in environment-specific settings like database URLs, API keys or log levels. Those belong outside the image and get supplied when the container starts.

This follows the twelve-factor rule: store config in the environment. It also brings a hard rule for secrets: a password that ends up inside an image layer is effectively public to anyone who can pull that image.

Environment variables at run time

docker run --rm \
  -e APP_ENV=staging \
  -e LOG_LEVEL=debug \
  alpine sh -c 'echo "env=$APP_ENV level=$LOG_LEVEL"'
Output
env=staging level=debug

If you write -e LOG_LEVEL with no value, Docker copies the variable from your current shell. For more than a couple of settings, use an env file (one KEY=value per line, no export, no quotes needed):

# staging.env
APP_ENV=staging
LOG_LEVEL=info
DATABASE_URL=postgresql://app:app@db:5432/stackcone
docker run --rm --env-file staging.env alpine env | grep -E 'APP_ENV|LOG_LEVEL'
Output
APP_ENV=staging
LOG_LEVEL=info

Defaults with ENV, build-time values with ARG

Two Dockerfile instructions look similar but behave very differently:

  • ENV sets a variable that exists at build time and in every container. Use it for sensible defaults that -e can override.
  • ARG exists only during the build. Pass it with --build-arg. Use it for things like a version number or a base image tag.
ARG PYTHON_VERSION=3.12
FROM python:${PYTHON_VERSION}-slim

ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION} \
    LOG_LEVEL=info \
    PORT=8000
docker build --build-arg APP_VERSION=1.4.2 -t api:1.4.2 .
docker run --rm api:1.4.2 sh -c 'echo $APP_VERSION $LOG_LEVEL'
docker run --rm -e LOG_LEVEL=debug api:1.4.2 sh -c 'echo $APP_VERSION $LOG_LEVEL'
Output
1.4.2 info
1.4.2 debug

Proving the leak

It is worth seeing this once so you never forget it. Suppose someone builds with a token as a build arg:

docker build --build-arg NPM_TOKEN=npm_4bF9xQ2... -t leaky .
docker history --no-trunc leaky | grep NPM_TOKEN
Output
<missing>   3 minutes ago   RUN |1 NPM_TOKEN=npm_4bF9xQ2... /bin/sh -c npm ci   184MB   buildkit.dockerfile.v0

The token is sitting in plain text in the image. If this image is pushed to a public registry, the token must be revoked immediately.

Build secrets with BuildKit

Sometimes the build genuinely needs a secret, for example to install packages from a private registry. BuildKit's secret mount exposes the secret as a file only for the duration of one RUN, and it is never written to a layer:

# syntax=docker/dockerfile:1
FROM node:22-slim
WORKDIR /app
COPY package*.json .npmrc ./
RUN --mount=type=secret,id=npm_token,env=NPM_TOKEN \
    npm ci --omit=dev
COPY . .
# the secret comes from an environment variable on the build machine
NPM_TOKEN=npm_4bF9xQ2... docker build --secret id=npm_token,env=NPM_TOKEN -t api .

Here .npmrc contains //registry.npmjs.org/:_authToken=${NPM_TOKEN}, and the env= option makes the secret available as an environment variable inside that one RUN. You can also pass a file with --secret id=npm_token,src=$HOME/.npm-token, in which case it appears at /run/secrets/npm_token. Running docker history now shows nothing sensitive.

Runtime secrets as files

Environment variables are convenient, but they are visible in docker inspect, can leak into crash reports and are inherited by every child process. For real secrets, many teams prefer mounting them as files. Compose supports this directly:

services:
  api:
    image: ghcr.io/stackcone/api:1.4.2
    environment:
      DATABASE_HOST: db
      DATABASE_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt
docker compose up -d
docker compose exec api ls -l /run/secrets
Output
total 4
-r--r--r-- 1 root root 25 Sep 20 11:02 db_password

The official Postgres, MySQL and many other images understand the _FILE suffix convention and read the value from the file. In your own app, read the file at startup (and fall back to the plain variable for local development). Add secrets/ to both .gitignore and .dockerignore.

Compose variable interpolation

Compose also reads a .env file next to compose.yaml and substitutes ${VAR} in the YAML itself. This is different from env_file:, which passes variables into the container:

services:
  api:
    image: ghcr.io/stackcone/api:${API_TAG:-latest}
    env_file: .env.api          # goes into the container
    ports:
      - "${API_PORT:-8000}:8000"  # resolved by Compose

Run docker compose config to print the fully resolved file and check what Compose will actually use. In production, secrets usually come from a manager such as AWS Secrets Manager, Vault or Kubernetes Secrets rather than local files, but the application code that reads a file or variable stays the same.

Recap

  • Keep environment-specific config out of the image and pass it at run time with -e, --env-file or Compose.
  • ENV sets overridable defaults for containers; ARG exists only during the build.
  • Build args, ENV values and copied-then-deleted files are all recoverable from an image.
  • Use RUN --mount=type=secret for secrets needed during a build.
  • Prefer secrets mounted as files (Compose secrets:, the _FILE convention) over plain environment variables at run time.
# Write your solution here

Finished reading? Mark this lesson complete to track your progress.

Up next · Lesson 8Debugging and Inspecting ContainersDebug Docker containers that crash or misbehave using docker logs, exec, inspect, stats, events, exit codes, health checks and docker debug.