Configuration and Environment Variables
One image, many environments. Passing configuration in at run time with environment variables and env files, and why a secret baked into an image is a secret you have leaked.
One image, many environments. Passing configuration in at run time with environment variables and env files, and why a secret baked into an image is a secret you have leaked.
The same application has to run in three or four places: your laptop, a test environment, and production. The code is identical in all of them. The database it connects to, the log level it uses and the API keys it needs are not.
The container answer is to build the image once and hand it its settings when it starts. By the end of this lesson you will know how to do that, which of the four ways of setting a variable wins when they disagree, and why a secret written into a Dockerfile is a secret you have given away.
The temptation is obvious: put the database URL in a config file, copy it into the image, done. It works immediately and fails as soon as there is a second environment.
Now you need a second image for staging, which means two images to build, two to test and two to keep in step — and the image you tested is no longer the image you deploy. That was the main thing containers bought you, given away in exchange for one hard-coded line.
So the rule is: the image is the same everywhere, and the environment supplies what differs. One artefact, promoted from test to production unchanged, behaving differently because its surroundings are different.
The direct form is -e, once per variable:
docker run -d -p 8000:8000 \
-e GREET_NAME=Antonii \
-e LOG_LEVEL=debug \
-e DATABASE_HOST=db \
hello-dockerInside the container these are ordinary environment variables, read the way your language already reads them:
import os
name = os.environ.get("GREET_NAME", "world")
log_level = os.environ.get("LOG_LEVEL", "info")
database_host = os.environ["DATABASE_HOST"] # requiredThat distinction is worth being deliberate about. os.environ.get
with a default means the setting is optional. Bracket access means
the application refuses to start without it — which is what you
want for a database URL, because a service that starts happily and
then fails on its first request is much harder to diagnose than one
that will not start at all.
To pass a variable through from your own shell, give -e just the
name:
A dozen -e flags is unreadable, so put them in a file:
The format is deliberately dumb, and its quirks catch people:
It is not a shell script. Nothing is expanded, quotes are not
stripped, and export at the start of a line is not understood.
When a value arrives with quotation marks around it, this is why.
ENV sets a variable inside the image, which is the right place for
a sensible default:
An ENV value is baked in and visible to anyone with the image, so
this is for defaults and never for secrets. PYTHONUNBUFFERED=1 is
a good example of the genre: it makes Python flush its output
immediately, which is what stops docker logs appearing empty
while your program is clearly running.
There is also ARG, which looks similar and is not:
Build time only.
Not present in the running container at all. Use it for things that shape how the image is built — a base version, a build date, a feature flag for the compiler.
Persists into the container.
What the program reads when it runs. Use it for defaults that
are correct everywhere, and let -e override them where they
are not.
Four places can set the same variable, and they have a strict order.
Follows the same logic as -e, and arrives in the next
lesson.
An explicit flag beats a file. This is what you reach for when overriding one thing for one run.
Replaces the image's default for every variable it names.
The image's default, and the only one that is the same everywhere the image runs.
To see what a container actually ended up with, ask it:
When configuration is not behaving, read that output before theorising. It settles the question in one line.
Now the part that matters most, because the failure is silent and permanent.
Bad — the key is in the image forever, readable by anyone who pulls it:
Good — the image knows the variable exists and never carries its value:
Every layer of an image is stored and shipped, and layer metadata
includes the instructions that built it. docker history prints
that ENV line back out in plain text to anyone who has the image,
including everyone on your team registry and everyone on the
internet if it is ever published. Deleting the key in a later layer
does not help — the earlier layer still exists. The only fix is to
rotate the key, which means the leak costs you an incident rather
than an edit.
The same applies to COPY. A .env file in your project directory
is picked up by COPY . . and travels with the image, which is one
of the reasons .dockerignore is not optional:
Environment variables are the standard interface, so the question is who fills them in.
Locally, an untracked .env file with development values.
Commit a .env.example alongside it listing every key with fake
values, so the next person knows what is needed:
In CI, the platform's secret store — encrypted, injected as environment variables for the job, masked in logs.
In production, whatever your platform provides: a hosting provider's environment settings, Kubernetes Secrets, or a dedicated manager like Vault or AWS Secrets Manager. The application reads the same variable names either way, which is the point: your code does not know or care where the value came from.
You now have every piece of a real multi-service application: an image you built, a volume for data, a network for containers to find each other, and configuration supplied from outside. What you do not have is a way to start it all without four long commands in the right order.
That is the next lesson — Compose, where all of this becomes one
file and one command. Before it, write a .env.example for
something you are working on. Listing every value your application
needs from its environment is a small exercise that usually turns up
one you had forgotten was hard-coded.
# .env.development
GREET_NAME=Antonii
LOG_LEVEL=debug
DATABASE_HOST=db
DATABASE_PORT=5432# Comments and blank lines are ignored.
GOOD=plain value with spaces is fine
QUOTED="value" # the quotes become part of the value
NO_SPACES = around # the key becomes "NO_SPACES " with a space
NOT_A_SHELL=$HOME # literal $HOME, no expansion.env
.env.*
*.pem
*.key# .env.example — copy to .env and fill in
DATABASE_URL=postgresql://user:pass@localhost:5432/dev
STRIPE_API_KEY=sk_test_replace_me
LOG_LEVEL=debug# .env — never committed
DATABASE_URL=postgresql://user:pass@db:5432/app
STRIPE_API_KEY=sk_test_...
# .dockerignore — keep secrets out of the image
.env
.env.*
*.pem
*.keyexport API_TOKEN=abc123
docker run -e API_TOKEN hello-docker # takes the value from heredocker run -d -p 8000:8000 --env-file .env.development hello-dockerFROM python:3.12-slim
WORKDIR /app
ENV LOG_LEVEL=info \
PORT=8000 \
PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
CMD ["python", "app.py"]ARG PYTHON_VERSION=3.12
FROM python:${PYTHON_VERSION}-slim
ARG BUILD_DATE
LABEL org.opencontainers.image.created=$BUILD_DATEdocker build --build-arg PYTHON_VERSION=3.11 -t app .# Image has ENV LOG_LEVEL=info
docker run --env-file .env.development hello-docker # debug
docker run --env-file .env.development \
-e LOG_LEVEL=trace hello-docker # tracedocker exec api env | sort
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' apiFROM python:3.12-slim
ENV STRIPE_API_KEY=sk_live_51H8xQ2eZvKYlo2C
COPY . .
CMD ["python", "app.py"]FROM python:3.12-slim
COPY . .
CMD ["python", "app.py"]docker run -e STRIPE_API_KEY="$STRIPE_API_KEY" myapp# Setting variables at run time
docker run -e LOG_LEVEL=debug app # one variable
docker run -e API_TOKEN app # take value from my shell
docker run --env-file .env.development app # a whole file
docker run --env-file .env -e LOG_LEVEL=trace app # flag wins
# Seeing what a container actually has
docker exec api env | sort
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' api
# Precedence, lowest to highest
# 1. ENV in the Dockerfile (image default)
# 2. --env-file
# 3. -e NAME=value
# 4. Compose environment:# In the Dockerfile
ENV LOG_LEVEL=info PORT=8000 # defaults, present at run time
ENV PYTHONUNBUFFERED=1 # so docker logs is not empty
ARG PYTHON_VERSION=3.12 # build time only, not in container