Skip to main content

Docker Environment Variables — .env Files, Secrets, and Config Management

· 7 min read
Goel Academy
DevOps & Cloud Learning Hub

You hard-coded the database password in the Dockerfile. It worked in development. Then someone pushed the image to a public registry. Now your database password is on the internet forever, baked into an immutable image layer that docker history will happily reveal to anyone. Environment variables done right prevent this entire class of mistakes.

ENV in Dockerfile vs Runtime -e Flag

There are two fundamentally different ways to set environment variables, and confusing them causes real problems.

Build-time (ENV in Dockerfile): Baked into the image. Every container from this image inherits these values. Visible in docker inspect and docker history.

FROM node:20-alpine
WORKDIR /app

# These are defaults — baked into the image
ENV NODE_ENV=production
ENV PORT=3000

COPY . .
RUN npm ci --only=production
CMD ["node", "server.js"]

Runtime (-e flag or environment in Compose): Set when the container starts. Override anything set in the Dockerfile. Not stored in the image.

# Override ENV values at runtime
docker run -d \
-e NODE_ENV=staging \
-e PORT=8080 \
-e DATABASE_URL=postgresql://db:5432/myapp \
myapp:latest
MethodWhen SetStored In ImageVisible InUse For
ENV in DockerfileBuild timeYesdocker history, docker inspectDefaults (PORT, NODE_ENV)
-e flagRun timeNodocker inspect onlyConfiguration, credentials
--env-fileRun timeNodocker inspect onlyMultiple variables

The rule is simple: use ENV for non-sensitive defaults. Use runtime variables for everything else.

.env Files with docker run

When you have more than three or four variables, passing them individually with -e gets unwieldy. Use an env file.

# app.env
NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://postgres:secret@db:5432/myapp
REDIS_URL=redis://redis:6379
LOG_LEVEL=info
JWT_SECRET=my-jwt-secret-key
docker run -d --env-file app.env myapp:latest
# Verify the variables are set
docker exec myapp env | sort
# DATABASE_URL=postgresql://postgres:secret@db:5432/myapp
# LOG_LEVEL=info
# NODE_ENV=production
# PORT=3000
# ...

Important: .env files should never be committed to version control. Add them to .gitignore and .dockerignore.

# .gitignore
.env
*.env
!.env.example

Keep a .env.example file with placeholder values so developers know what variables are needed:

# .env.example (committed to git)
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://postgres:password@localhost:5432/myapp
REDIS_URL=redis://localhost:6379
LOG_LEVEL=debug
JWT_SECRET=change-me-in-production

Docker Compose Environment Variables

Compose offers several ways to inject environment variables, and they have a specific precedence order.

Inline environment

# docker-compose.yml
services:
api:
image: myapp:latest
environment:
- NODE_ENV=production
- PORT=3000
- DATABASE_URL=postgresql://db:5432/myapp

env_file directive

services:
api:
image: myapp:latest
env_file:
- ./common.env
- ./api.env
environment:
- NODE_ENV=production # This overrides anything in the env files

Variable substitution

Compose can reference variables from the shell or from a .env file in the project root.

# docker-compose.yml
services:
api:
image: myapp:${APP_VERSION:-latest}
environment:
- DATABASE_URL=${DATABASE_URL}
- PORT=${API_PORT:-3000}
# .env (in the same directory as docker-compose.yml)
APP_VERSION=v2.1.0
DATABASE_URL=postgresql://db:5432/myapp
API_PORT=8080
# Verify what Compose will use
docker compose config

Precedence Order

When the same variable is set in multiple places, Compose uses this precedence (highest wins):

  1. Shell environment variables
  2. environment directive in compose file
  3. env_file directive in compose file
  4. ENV in Dockerfile
# Shell env overrides everything
export DATABASE_URL=postgresql://override:5432/myapp
docker compose up -d
# Container gets the shell value, not the compose file value

environment vs env_file

Featureenvironmentenv_file
Defined indocker-compose.ymlExternal .env file
Variable substitutionYesNo
Visible in compose fileYesNo (just filename)
Supports commentsNoYes (# comments)
Best forSmall number of vars, per-service overridesLarge number of vars, shared config

ARG: Build-Time Variables

ARG sets variables available only during the build process. They do not exist in the running container.

FROM node:20-alpine

# ARG is available during build only
ARG NPM_TOKEN
ARG BUILD_VERSION=unknown

WORKDIR /app

# Use ARG during build
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc && \
npm ci && \
rm .npmrc

# Convert ARG to ENV if needed at runtime
ENV APP_VERSION=${BUILD_VERSION}

COPY . .
CMD ["node", "server.js"]
docker build \
--build-arg NPM_TOKEN=abc123 \
--build-arg BUILD_VERSION=v2.1.0 \
-t myapp:v2.1.0 .
FeatureARGENV
Available during buildYesYes
Available at runtimeNoYes
Visible in docker historyYes (caution!)Yes
Overridable at build--build-argNo
Overridable at runN/A-e flag
Default valueARG NAME=defaultENV NAME=default
ScopeFrom declaration to end of stageEntire image

Critical warning: ARG values appear in docker history. Never use ARG for secrets — use BuildKit secret mounts instead.

# Anyone can see ARG values
docker history myapp:latest
# ... --build-arg NPM_TOKEN=abc123 ...

# BuildKit secrets are NOT visible in history
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci

Secrets in Docker Swarm

Docker Swarm has first-class secrets management. Secrets are encrypted at rest, transmitted over TLS, and mounted as in-memory files — never as environment variables.

# Create secrets
echo "supersecret" | docker secret create db_password -
echo "jwt-key-here" | docker secret create jwt_secret -

# List secrets
docker secret ls

# Use in a service
docker service create --name api \
--secret db_password \
--secret jwt_secret \
myapp:latest
# Application reads secret from file
import os

def get_secret(name):
"""Read a Docker secret from /run/secrets/"""
secret_path = f"/run/secrets/{name}"
try:
with open(secret_path, 'r') as f:
return f.read().strip()
except FileNotFoundError:
# Fall back to environment variable for local development
return os.environ.get(name.upper())

db_password = get_secret("db_password")

Docker Configs

Docker configs are like secrets but for non-sensitive configuration files — nginx configs, application config files, etc. They are not encrypted but offer the same deployment convenience.

# Create a config
docker config create nginx_conf ./nginx.conf

# Use in a service
docker service create --name web \
--config source=nginx_conf,target=/etc/nginx/nginx.conf \
nginx:alpine

12-Factor App Configuration

The 12-factor app methodology states that configuration should be stored in the environment, not in code. Docker makes this natural.

# docker-compose.yml — 12-factor style
services:
api:
build: .
environment:
# Factor III: Store config in the environment
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- PORT=3000
- LOG_LEVEL=${LOG_LEVEL:-info}
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:3000}
env_file:
- .env.${DEPLOY_ENV:-development}

Multi-Environment Setup

Use separate env files for each environment and select at deploy time.

# .env.development
NODE_ENV=development
DATABASE_URL=postgresql://postgres:devpass@localhost:5432/myapp_dev
LOG_LEVEL=debug
CORS_ORIGIN=http://localhost:3000

# .env.staging
NODE_ENV=staging
DATABASE_URL=postgresql://postgres:stagingpass@staging-db:5432/myapp_staging
LOG_LEVEL=info
CORS_ORIGIN=https://staging.myapp.com

# .env.production
NODE_ENV=production
DATABASE_URL=postgresql://postgres:prodpass@prod-db:5432/myapp_prod
LOG_LEVEL=warn
CORS_ORIGIN=https://myapp.com
# docker-compose.yml
services:
api:
build: .
env_file:
- .env.${DEPLOY_ENV:-development}
environment:
- NODE_ENV=${DEPLOY_ENV:-development}
# Deploy to staging
DEPLOY_ENV=staging docker compose up -d

# Deploy to production
DEPLOY_ENV=production docker compose up -d

Never Bake Secrets Into Images

This point deserves its own section because it is the most common and most dangerous mistake.

# NEVER DO THIS — secret is in the image forever
FROM node:20-alpine
COPY .env /app/.env
ENV DATABASE_PASSWORD=mysecret
# Anyone with the image can extract secrets
docker history myapp:latest --no-trunc
# Step 3: ENV DATABASE_PASSWORD=mysecret

docker run --rm myapp:latest cat /app/.env
# DATABASE_PASSWORD=mysecret

Even if you delete the file in a later layer, it still exists in the previous layer. Image layers are immutable and additive. The only safe approaches are:

  1. Pass at runtime with -e or --env-file
  2. Use Docker secrets (Swarm) or secret management tools
  3. Use BuildKit secret mounts for build-time secrets

Wrapping Up

Environment variables are the bridge between your immutable Docker image and the specific environment it runs in. Use ENV for non-sensitive defaults, runtime -e flags for configuration, --env-file for convenience, ARG for build-time parameters, and Docker secrets for credentials. Never commit .env files to git, never bake secrets into images, and always provide a .env.example so your team knows what configuration is needed.

In the next post, we will cover Docker in CI/CD — building, testing, and pushing images automatically with GitHub Actions, GitLab CI, and Jenkins, including layer caching strategies and multi-platform builds.