Applications need configuration — database URLs, API keys, feature flags. Hardcoding them means rebuilding for every environment. Containers solve this with runtime injection, but secrets demand extra care. This lesson shows what goes where and why.

1. The Configuration Problem

Every application has values that change between environments: dev, staging, production. Consider a simple web app:

  • Database URLlocalhost:5432 in dev, a cloud endpoint in prod
  • API keys — test keys vs live keys
  • Feature flags — enable experimental features in staging only
  • Log levelsDEBUG locally, WARN in production

Hardcoding any of these means you must rebuild the image for every environment. That breaks the core container promise: build once, run anywhere. The solution is to inject configuration at runtime.

The Golden Rule

The same image should run in dev, staging, and production. Only the configuration changes — never the code or binary.

2. Environment Variables

The simplest and most portable way to configure a container. Every OS supports them, every language can read them.

Setting env vars in a Dockerfile (defaults)

# Dockerfile
FROM node:20-slim
ENV NODE_ENV=production
ENV PORT=3000
ENV LOG_LEVEL=info
CMD ["node", "server.js"]

These become defaults baked into the image. They apply unless overridden at runtime.

Overriding at runtime

# Override at run time — takes precedence over Dockerfile ENV
docker run -e LOG_LEVEL=debug -e PORT=8080 myapp

Precedence (highest wins)

  1. docker run -e KEY=VALUE (runtime flag)
  2. --env-file values
  3. ENV in the Dockerfile (build-time default)

When to use env vars:

  • Non-sensitive configuration (ports, log levels, feature flags)
  • Service URLs (where the DB or cache lives)
  • Mode switches (NODE_ENV, RAILS_ENV)

3. The .env File

Typing dozens of -e flags is tedious. The --env-file flag reads key-value pairs from a file:

# .env (never commit this to git!)
DATABASE_URL=postgres://user:pass@db:5432/myapp
REDIS_URL=redis://cache:6379
API_KEY=sk-test-abc123
LOG_LEVEL=debug
FEATURE_NEW_UI=true
docker run --env-file .env myapp

Format rules: one KEY=VALUE per line, no spaces around =, lines starting with # are comments, blank lines ignored.

Never commit .env files

Add .env to your .gitignore immediately. A committed .env file with production credentials is a breach waiting to happen. Use .env.example (with placeholder values) as a template for your team.

4. Config Files via Bind Mounts

Some configuration doesn't fit in environment variables: nginx configs, YAML application profiles, TLS certificates, complex JSON schemas. Mount them at runtime:

# Mount a config file read-only into the container
docker run -v ./nginx.conf:/etc/nginx/nginx.conf:ro nginx

# Mount an entire config directory
docker run -v ./config/:/app/config/:ro myapp

The :ro (read-only) flag prevents the container from modifying your host file — a good security practice.

Use cases for config file mounts

  • nginx.conf or httpd.conf — web server tuning
  • application.yml — Spring Boot profiles
  • prometheus.yml — monitoring configuration
  • TLS certificates (.pem, .crt files)

Because bind mounts reflect the host filesystem in real time, changes to the file on the host are immediately visible inside the container (the app may need to reload, but the file is already updated).

5. Secrets — The Hard Problem

Secrets are credentials: database passwords, API tokens, TLS private keys. They require special handling because environment variables are not secure.

Why env vars are bad for secrets

  • docker inspect exposes all env vars in plain text
  • /proc/<pid>/environ is readable inside the container
  • Crash dumps and log frameworks often dump the full environment
  • Child processes inherit the entire environment
  • docker history shows ENV instructions from the build
# Anyone with Docker access can see your secrets:
$ docker inspect mycontainer --format '{{json .Config.Env}}'
["DATABASE_PASSWORD=s3cr3t","API_KEY=sk-live-xyz..."]

Better approaches

Method Security Complexity Best For
Env vars (-e) ⚠️ Low Trivial Local dev only
Mounted files (tmpfs) ✅ Medium Low Single-host deployments
Docker Secrets (Swarm) ✅ High Medium Swarm-mode clusters
External secret managers ✅✅ Highest High Production at scale

Mounted secrets file (practical approach)

# Mount secret as a file, use tmpfs so it never hits disk
docker run --tmpfs /run/secrets \
  -v ./db-password.txt:/run/secrets/db_password:ro \
  myapp

# App reads: cat /run/secrets/db_password

Docker Secrets (Swarm mode)

# Create a secret
echo "s3cr3t" | docker secret create db_password -

# Use in a service
docker service create --secret db_password myapp
# Secret appears at /run/secrets/db_password inside container
🚨 Never put secrets in Docker images or environment variables in production

If your production containers have passwords in ENV or baked into image layers, you have a security vulnerability. Secrets must be injected at runtime via files or a secrets manager, never persisted in images or container metadata.

6. Build-time vs Run-time Config

Docker has two mechanisms that look similar but serve different purposes:

ARG ENV
Available during Build only Build + Run
Persists in image No (unless copied to ENV) Yes
Visible in docker history docker inspect
Override at --build-arg -e at runtime
Use for Version pins, base image tags Runtime defaults
# Dockerfile
ARG NODE_VERSION=20        # Build-time only
FROM node:${NODE_VERSION}-slim

ENV PORT=3000              # Persists in the running container

# DANGER: this leaks the secret into the image layer history!
ARG SECRET_KEY
# Anyone can see it: docker history --no-trunc myimage

BuildKit secrets (the safe way)

# Dockerfile — secret is mounted, never stored in a layer
RUN --mount=type=secret,id=npm_token \
  NPM_TOKEN=$(cat /run/secrets/npm_token) \
  npm install

# Build command
docker build --secret id=npm_token,src=./npm-token.txt .

The secret is available during that single RUN instruction but is never written to any image layer.

7. The Twelve-Factor Way

Factor III of the Twelve-Factor App states: Store config in the environment. Containers make this natural — you inject config at runtime rather than bundling it with the code.

  • ✅ Same artifact (image) in every environment
  • ✅ Config changes don't require a rebuild or redeploy
  • ✅ Clear separation of concerns: code vs config

But remember the secrets caveat: Twelve-Factor was written in 2011 when "environment" meant Heroku config vars with no docker inspect. In a container world, secrets need stronger isolation than plain env vars provide.

The Modern Interpretation

Use environment variables for non-sensitive config (Factor III). Use secret managers or mounted files for credentials. The spirit of Twelve-Factor — externalise config from code — still holds. The mechanism for secrets has evolved.

Config Injection Points

Build-time ARG Image Layer ENV (defaults) Runtime Injection -e KEY=VALUE --env-file .env -v config:/app/cfg App Secret Manager Vault / AWS SM / K8s Secrets /run/secrets/<name> --build-arg Baked in image Injected at start Reads config Precedence: runtime overrides image defaults Lowest (ARG — gone after build) Highest (runtime -e)

When to Use What

Config Type Mechanism Example
Simple key-value, non-sensitive Environment variable (-e) LOG_LEVEL=info, PORT=8080
Many non-sensitive vars --env-file Local dev .env with service URLs
Complex / structured config Bind mount (-v) nginx.conf, prometheus.yml
Secrets (dev/test) Mounted file or .env (local only) DB password for local Postgres
Secrets (production) Secret manager + mounted file Vault → /run/secrets/db_pass
Build-time selection ARG + --build-arg Base image version, build profile

Industry Practice: Secret Managers

🏭 How Production Systems Handle Secrets

HashiCorp Vault and AWS Secrets Manager are the industry standard for secret injection. The pattern:

  1. Secrets are stored encrypted in the manager, with access policies and audit logs
  2. At container start, a sidecar or init process authenticates to the manager
  3. Secrets are written to a tmpfs mount (/run/secrets/) — never to disk
  4. The app reads secrets from the file — no env vars involved
  5. Secrets can be rotated without restarting the container

Kubernetes takes a similar approach with Secret objects mounted as volumes, though base64 encoding is not encryption — always enable encryption at rest.

Hands-on Tasks

Task 1: Run a container with environment variables

# Run with custom env vars
docker run -d --name env-test \
  -e APP_NAME=myapp \
  -e APP_ENV=staging \
  -e LOG_LEVEL=debug \
  alpine sleep 3600

# Verify the variables are set
docker exec env-test env | grep APP
# APP_NAME=myapp
# APP_ENV=staging

# See that docker inspect also shows them (security implication!)
docker inspect env-test --format '{{json .Config.Env}}' | python3 -m json.tool

# Clean up
docker rm -f env-test

Task 2: Mount a config file and observe live changes

# Create a config file on the host
echo "setting: original_value" > /tmp/app-config.yaml

# Run container with bind-mounted config
docker run -d --name config-test \
  -v /tmp/app-config.yaml:/app/config.yaml:ro \
  alpine sleep 3600

# Read the config inside the container
docker exec config-test cat /app/config.yaml
# setting: original_value

# Modify the file on the HOST
echo "setting: updated_value" > /tmp/app-config.yaml

# Read again inside the container — it sees the change immediately!
docker exec config-test cat /app/config.yaml
# setting: updated_value

# Clean up
docker rm -f config-test
rm /tmp/app-config.yaml

This demonstrates that bind mounts provide live access to the host filesystem — no container restart needed for the file content to update.

Knowledge Check

Quiz 1: Secrets in Environment Variables

Why are environment variables considered insecure for storing secrets in production containers?

Correct! Environment variables are stored in plain text and exposed through multiple vectors: docker inspect, /proc/<pid>/environ, crash dumps, logging frameworks, and all child processes. This makes them unsuitable for production secrets.

Quiz 2: ARG vs ENV

What happens to a value set with ARG in a Dockerfile after the image is built?

Correct! ARG values are only available during the build and do not persist in the running container. However, they ARE visible in docker history, which is why you should never pass secrets via --build-arg. Use BuildKit's --mount=type=secret instead.

Quiz 3: Configuration Precedence

A Dockerfile sets ENV PORT=3000. You run the container with docker run -e PORT=8080 myapp. What port does the app see?

Correct! Runtime -e flags always override Dockerfile ENV defaults. This is by design — it enables the "build once, run anywhere" pattern where the same image is configured differently per environment.

Key Takeaways

  • Build once, configure at runtime — the same image runs in every environment with different config
  • Env vars are great for non-sensitive config; runtime -e overrides Dockerfile ENV
  • .env files are convenient for local dev but must never be committed to version control
  • Bind mounts handle complex config (YAML, nginx.conf) and reflect host changes in real time
  • Secrets need special treatment — env vars are visible everywhere; use mounted files or secret managers
  • ARG is build-only, ENV persists in the image — neither is safe for secrets
  • BuildKit --mount=type=secret is the safe way to use secrets during builds
  • Twelve-Factor Factor III works for config, but production secrets need stronger isolation