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 URL —
localhost:5432in dev, a cloud endpoint in prod - API keys — test keys vs live keys
- Feature flags — enable experimental features in staging only
- Log levels —
DEBUGlocally,WARNin 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 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)
docker run -e KEY=VALUE(runtime flag)--env-filevaluesENVin 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.
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.conforhttpd.conf— web server tuningapplication.yml— Spring Boot profilesprometheus.yml— monitoring configuration- TLS certificates (
.pem,.crtfiles)
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 inspectexposes all env vars in plain text/proc/<pid>/environis readable inside the container- Crash dumps and log frameworks often dump the full environment
- Child processes inherit the entire environment
docker historyshowsENVinstructions 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
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.
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
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
HashiCorp Vault and AWS Secrets Manager are the industry standard for secret injection. The pattern:
- Secrets are stored encrypted in the manager, with access policies and audit logs
- At container start, a sidecar or init process authenticates to the manager
- Secrets are written to a
tmpfsmount (/run/secrets/) — never to disk - The app reads secrets from the file — no env vars involved
- 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
-eoverrides DockerfileENV - .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=secretis the safe way to use secrets during builds - Twelve-Factor Factor III works for config, but production secrets need stronger isolation