Skip to content
C Codeloom
Docker

Docker: Environment Variables and Secrets

How to feed configuration into Docker containers without baking it into the image — ENV vs ARG, --env-file, Compose env, BuildKit secret mounts, and Docker Swarm secrets, with safe defaults.

·8 min read · By Yash Kesharwani
Intermediate 12 min read

What you'll learn

  • The difference between ENV and ARG in a Dockerfile
  • How to pass environment variables at run time with --env-file and Compose
  • BuildKit secret mounts for credentials needed only at build time
  • A short tour of Docker Swarm secrets for production
  • What you should never bake into an image and how to keep it out

Prerequisites

Application configuration belongs outside the image. The same image should be able to run in development, staging, and production with different database URLs, log levels, and API keys. The mechanism Docker gives you for this is environment variables, plus a small set of dedicated tools for the values that must stay secret.

ENV vs ARG

A Dockerfile has two ways to declare a variable, and they answer different questions.

# ARG — build-time only
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine

# ENV — present at build time AND inside the running container
ENV NODE_ENV=production

ARG is a build-time variable. It is visible to the Dockerfile during docker build and disappears at the end of the build. You can override it with --build-arg:

docker build --build-arg NODE_VERSION=22 -t my-app .

ENV is set in the image and present inside every container started from it. Application code can read it with process.env.NODE_ENV, os.environ['NODE_ENV'], and so on.

A practical rule: use ARG for things that affect how the image is built (versions, feature flags for RUN steps). Use ENV for things the application reads at runtime.

A worked Dockerfile

# Build-time only
ARG NODE_VERSION=20

FROM node:${NODE_VERSION}-alpine

WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .

# Defaults the container ships with — override at run time
ENV NODE_ENV=production
ENV PORT=3000

EXPOSE 3000
CMD ["node", "server.js"]

NODE_ENV and PORT get sensible defaults baked into the image. Anyone running it can override either without rebuilding.

Passing variables at run time

There are three main ways to inject env vars when you start a container.

-e and —env

docker run -e DATABASE_URL=postgres://... -e LOG_LEVEL=debug my-app

Quick for one-off testing. Tedious past three variables, and credentials end up in your shell history.

—env-file

Put your variables in a file:

# .env.dev
DATABASE_URL=postgres://user:pass@db:5432/app
LOG_LEVEL=debug
FEATURE_FLAG_BETA=true

Then:

docker run --env-file .env.dev my-app

The format is one KEY=value per line, no quoting, no shell expansion. This is the most common way to run a container with realistic config.

Always add .env* to .gitignore. A leaked .env is one of the most common security incidents in the wild.

environment in Compose

In compose.yaml, the environment: key sets variables on a service:

services:
  api:
    image: my-app
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/app
      LOG_LEVEL: info

You can also point Compose at a file:

services:
  api:
    image: my-app
    env_file:
      - .env.shared
      - .env.local

Later files override earlier ones, so .env.local (developer-specific) wins.

Compose interpolation

Compose itself reads a .env file in the same directory as compose.yaml and uses it to interpolate ${VAR} placeholders in the YAML:

services:
  api:
    image: my-app:${TAG:-latest}
    environment:
      DATABASE_URL: ${DATABASE_URL}

Note the subtle distinction. There are two different “.env” mechanisms:

  • env_file: loads variables into the container.
  • The top-level .env file loads variables into Compose itself for interpolation.

They are independent. Both are useful.

Try it yourself. Write a tiny Node script that prints process.env.MESSAGE. Build it into an image. Run it with docker run -e MESSAGE=hello and confirm the output. Then create a .env.test file with MESSAGE=fromfile and run with --env-file .env.test. Then add the image to a compose.yaml and set MESSAGE via environment:. Confirm all three paths work.

What NOT to bake into images

A Docker image is a static, distributable artifact. Anyone who can pull it can read every layer. Therefore:

  • Never COPY .env into an image. That file goes onto every developer’s machine and possibly your registry. Use runtime injection instead.
  • Never RUN echo "API_KEY=..." > /etc/secrets. It is permanently in a layer; deleting it in a later layer does not erase it.
  • Never ARG SECRET followed by using it in a RUN. The ARG value is recorded in the image history (docker history) in plain text.
  • Never check secrets into Git even briefly. Treat any leaked secret as compromised and rotate it.

A practical mental model: anything that should be different in dev vs prod should arrive at run time. Anything sensitive should arrive at run time through a secret mechanism, not as an environment variable on the image itself.

BuildKit secret mounts

Some builds genuinely need a secret — fetching a private npm package, downloading from a private S3 bucket, signing a binary. BuildKit (Docker’s modern builder, enabled by default) provides a clean way to do this without leaking the secret into the image.

In your Dockerfile:

# syntax=docker/dockerfile:1.7

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci --omit=dev

COPY . .
CMD ["node", "server.js"]

The --mount=type=secret line mounts a file only during that RUN step. The file does not appear in any layer. Build it like this:

DOCKER_BUILDKIT=1 docker build \
  --secret id=npmrc,src=$HOME/.npmrc \
  -t my-app .

docker history for the resulting image will show the RUN step but not the secret content. This is the right way to handle build-time credentials.

Docker Swarm secrets (briefly)

If you run a small fleet with Docker Swarm, it has a first-class secret store:

echo "supersecret" | docker secret create db_password -

Reference it in compose.yaml for a Swarm stack:

services:
  api:
    image: my-app
    secrets:
      - db_password
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password

secrets:
  db_password:
    external: true

The secret is mounted as a file at /run/secrets/db_password inside the container. Your app reads the file, not an environment variable. Kubernetes Secrets follow exactly the same pattern.

For local development with plain docker compose up, Swarm secrets work in a limited form (file-based), but you will more commonly stick with env_file.

The _FILE convention

Many official images (Postgres, MySQL, Redis) accept VAR_FILE alongside VAR:

environment:
  POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
  - db_password

The image reads the secret out of the file at startup. This avoids putting the secret in the container’s environment — where any other process inside the container, plus docker inspect, could read it.

Build this pattern into your own images. A few lines at the top of your entrypoint:

#!/bin/sh
if [ -n "$DB_PASSWORD_FILE" ] && [ -z "$DB_PASSWORD" ]; then
  export DB_PASSWORD="$(cat "$DB_PASSWORD_FILE")"
fi
exec "$@"

Now your image works with both DB_PASSWORD (dev) and DB_PASSWORD_FILE (prod, via mounted secret).

Try it yourself. Take a small image you have built. Add the _FILE shim above as entrypoint.sh, set it as the ENTRYPOINT, and verify that both -e DB_PASSWORD=plain and a mounted DB_PASSWORD_FILE produce the same behaviour inside the app.

Inspecting and debugging

A handful of commands you will reach for constantly.

# See an image's env defaults (and its full history)
docker inspect my-app --format '{{json .Config.Env}}'

# See what is actually set inside a running container
docker exec my-container env | sort

# Confirm a Compose file resolves to what you expect
docker compose config

docker compose config is especially useful — it prints the fully-resolved YAML with interpolation applied, so you can see exactly which value is reaching each service.

A safe defaults checklist

A short list to keep open while writing Dockerfiles and Compose files:

  1. .env, .env.*, and secrets/ are in .gitignore. Always. Day one.
  2. ARG is for build-time-only values; ENV is for runtime. Never the other way around.
  3. Secrets at build time go through --mount=type=secret, not ARG.
  4. Production secrets go through a real secret store (Swarm secrets, Kubernetes Secrets, AWS Secrets Manager, HashiCorp Vault) — not --env-file.
  5. Images carry safe defaults. No real credentials. The image should be runnable by a stranger.
  6. Rotate any secret that ever lands in Git or chat. Speed matters; assume the worst.

Following this list prevents the long tail of “we leaked a key in 2023” stories that haunt every team.

Recap

You now know:

  • ARG is build-time only; ENV persists into the running container
  • Pass runtime config with -e, --env-file, or Compose’s environment: / env_file: keys
  • Compose has two different .env mechanisms — one for the container, one for YAML interpolation
  • Never bake secrets into image layers, ARG, or committed files
  • BuildKit --mount=type=secret handles build-time credentials safely
  • Swarm/Kubernetes secrets plus the _FILE convention are the production patterns

Next steps

With configuration handled, the last piece for collaborative work is keeping a clean Git history — which is where rebase shines if you know its rules.

→ Next: Git Rebase Explained: When to Rebase and When to Merge

Questions or feedback? Email codeloomdevv@gmail.com.