Back to Blog
DevOps12 min readMay 6, 2026

Environment Variables in Docker: A Complete Guide

Docker gives you five different ways to get a variable into a container, and picking the wrong one is how secrets end up baked into an image layer that anyone with registry access can read.

Methods for Setting Environment Variables

Which one to reach for depends on whether the value is a secret, whether it needs to differ per environment, and whether it needs to survive a container restart.

The -e Flag on the Command Line

The simplest method is passing variables directly when running a container:

# Single variable
docker run -e DATABASE_URL=postgres://localhost/db myapp
# Multiple variables
docker run -e DATABASE_URL=... -e API_KEY=... myapp
# Pass from host environment
docker run -e DATABASE_URL myapp

Good for: a quick one-off variable, a debug session, a CI step that already has the value in scope
Bad for:anything you don't want showing up in a process list or your shell history, and anything past a handful of variables

Environment Files with --env-file

Point Docker at a file instead of typing every flag out:

# .env file
DATABASE_URL=postgres://localhost/db
API_KEY=secret123
DEBUG=true
# Run with env file
docker run --env-file .env myapp
# Multiple env files
docker run --env-file .env --env-file .env.local myapp

Good for: keeping one .env per environment and swapping which one you point at
Bad for: the file still sits on disk in plaintext, so it needs the same care as any other .env file

Dockerfile ENV Instructions

Bake a default value into the image so containers built from it start with something reasonable:

# Dockerfile
FROM node:20-alpine
# Set defaults (can be overridden at runtime)
ENV NODE_ENV=production
ENV PORT=3000
# Use build args for build-time values
ARG VERSION
ENV APP_VERSION=$VERSION

Don't put secrets in ENV instructions. They get baked into a layer, and a plain docker history or docker save on the image exposes them to anyone who can pull it, nothing more than registry access required.

Docker Compose

For anything beyond a single container, Compose handles both inline values and file-based ones in the same service definition:

# docker-compose.yml
version: '3.8'
services:
app:
image: myapp
environment:
- NODE_ENV=production
- DATABASE_URL=postgres://db:5432/app
env_file:
- .env
- .env.local

Variable Interpolation

Compose automatically loads a .env file in the same directory and allows variable substitution:

# .env (in same directory as docker-compose.yml)
DB_PASSWORD=supersecret
APP_VERSION=1.2.3
# docker-compose.yml
services:
app:
image: myapp:${APP_VERSION}
environment:
- DB_PASSWORD=${DB_PASSWORD}
- CACHE_SIZE=${CACHE_SIZE:-100} # Default value

Docker Secrets for Swarm and Kubernetes

Secrets take this further than any of the methods above: encrypted at rest, mounted only into the containers that need them, never sitting in an environment variable an inspect command can dump.

# Create a secret
echo "supersecretpassword" | docker secret create db_password -
# Or from a file
docker secret create db_password ./db_password.txt
# docker-compose.yml (Swarm mode)
services:
app:
secrets:
- db_password
secrets:
db_password:
external: true

Your application reads these as files at /run/secrets/secret_name, not as environment variables, which means a little extra code on the read side:

// Node.js example
const fs = require('fs');
const dbPassword = fs.readFileSync('/run/secrets/db_password', 'utf8').trim();

Multi-Stage Builds and Secrets

A build stage that needs a private npm token or an SSH key is a common way secrets leak, even when the final image never sees them, because the intermediate build layer still does. BuildKit's secret mounts fix that by making the secret available only for the duration of one RUN command:

# Dockerfile
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
# Mount secret only during this RUN command
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm install
# Secret is NOT in the final image
FROM node:20-alpine
COPY --from=builder /app /app
# Build with secret
DOCKER_BUILDKIT=1 docker build --secret id=npm_token,src=.npmrc .

Production Best Practices

Keep Secrets Out of the Image

ENV and ARG values in a Dockerfile end up visible in the image layers regardless of what happens at runtime. Inject secrets when the container starts, not when the image is built:

Bad - secret in image:
ENV API_KEY=supersecret123
Good - secret injected at runtime:
docker run -e API_KEY=$API_KEY myapp

Mount Secret Files Read-Only

A secrets volume with write access is a bug waiting for a bad script. Mount it read-only:

docker run -v ./secrets:/secrets:ro myapp

Split Config by Environment with Compose Overrides

Keep one base compose file and layer environment-specific overrides on top instead of maintaining separate full configs:

# docker-compose.yml - base config
# docker-compose.dev.yml - dev overrides
# docker-compose.prod.yml - prod overrides
# Development
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
# Production
docker compose -f docker-compose.yml -f docker-compose.prod.yml up

Fail Fast on Missing Variables

A container that starts with half its config missing and fails three requests later is harder to debug than one that refuses to start at all. Check for required variables before the app does anything else:

// entrypoint.js
const required = ['DATABASE_URL', 'API_KEY', 'JWT_SECRET'];
const missing = required.filter(key => !process.env[key]);
if (missing.length > 0) {
console.error(`Missing env vars: ${missing.join(', ')}`);
process.exit(1);
}

Add .env to .dockerignore

A COPY . . in your Dockerfile grabs .env along with everything else unless you tell it not to:

# .dockerignore
.env
.env.*
*.local
.git
node_modules

None of these five methods is universally right. A side project running on a single host is fine with --env-file. A team running Swarm or Kubernetes in front of paying customers should be on Docker Secrets, full stop. What matters more than which one you pick is that you pick deliberately, instead of defaulting to ENV in the Dockerfile because it was the first thing that worked in development.