Environment variables and configuration

-e, --env-file, the parsing rules Docker applies, and why env vars are not secret.

Beginner10 min · lesson 7 of 14
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (6 files, 1 KB): env.tar.gz. The lab VM shares no folders with your computer, so fetch them inside the VM: cd ~/lab && curl -fsSLO https://secopslog.com/lab-files/docker-fund/env.tar.gz && tar -xzf env.tar.gz, which creates ~/lab/env/. SHA-256: 5b4dbfdc0d5c7fa89543e04cd30611a9d9ddeeb4d1a4d7e91084cfad926564a1

Someone copies the settings block from a deployment shell script into a file, passes it to docker run --env-file, and Docker refuses to start anything: variable 'export NAME' contains whitespaces, exit status 125. The file looked fine to a human. Docker's env-file format is stricter and simpler than a shell script, and this lesson covers its rules, how -e, --env-file and the image's own defaults combine, and why none of them is a place for a password.

An environment variable is a NAME=value pair the operating system hands to a process when it starts. Programs read the names they know and ignore the rest. Containers use them for configuration because the same image can then run on a laptop, in CI and in production with different settings and no rebuild. Use the main lab VM and unpack the lesson files into ~/lab/env.

One variable at a time with -e

-e NAME=value sets one variable for one container; repeat the flag for more. The shell inside the container expands $NAME when it runs the echo. Running env instead prints the whole environment: your two variables plus PATH and HOME from the image and HOSTNAME, which Docker sets to the short container ID.

ubuntu@secopslog-docker:~/lab/env · Docker 29.8.2
$ docker run --rm -e NAME=Sam alpine:3.22 sh -c 'echo "Hello, $NAME"'
Hello, Sam
$ docker run --rm -e ROLE=admin -e TIER=free alpine:3.22 env
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin HOSTNAME=9fab1310bb54 TIER=free ROLE=admin HOME=/root

-e NAME without =value copies the variable from the environment of the docker command, which is handy in CI where the value already sits in the job's environment and should not be typed into the command line. If that variable is not set where you run docker, the container does not get it at all, and printenv exits with status 1 because there is nothing to print.

ubuntu@secopslog-docker:~/lab/env · Docker 29.8.2
$ export GREETING=from-host docker run --rm -e GREETING alpine:3.22 printenv GREETING
from-host
$ docker run --rm -e NOT_SET_ANYWHERE alpine:3.22 printenv NOT_SET_ANYWHERE

Many variables with --env-file

An env file holds one NAME=value per line. Lines that start with # are comments and blank lines are skipped. Docker reads the file on the client side, before it asks the daemon to create anything.

ubuntu@secopslog-docker:~/lab/env · Docker 29.8.2
$ cat app.env
# settings for the demo container NAME=Priya ROLE=editor FEATURE_DARK_MODE=true
$ docker run --rm --env-file app.env alpine:3.22 env
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin HOSTNAME=e4c1045ac59b NAME=Priya ROLE=editor FEATURE_DARK_MODE=true HOME=/root

The format is not shell syntax, and Docker does not try to guess. A space around = or an export in front of the name makes the name contain whitespace, and the docker CLI rejects the whole file with exit status 125, the code for "the docker command itself failed". No container is created.

ubuntu@secopslog-docker:~/lab/env · Docker 29.8.2
$ docker run --rm --env-file spaces.env alpine:3.22 env
docker: --env-file: invalid env file (spaces.env): variable 'NAME ' contains whitespaces Run 'docker run --help' for more information
$ docker run --rm --env-file export.env alpine:3.22 env
docker: --env-file: invalid env file (export.env): variable 'export NAME' contains whitespaces Run 'docker run --help' for more information

Other differences are silent, which makes them worse. Quotes are part of the value, so QUOTED="Priya" gives the application a seven-character string with two quote marks in it. A # after the value is not a comment either. EMPTY= sets a variable to the empty string, which is different from not setting it. A bare name with no = behaves like -e NAME, copying the value from your shell, and is left out if your shell does not have it.

ubuntu@secopslog-docker:~/lab/env · Docker 29.8.2
$ cat quirks.env
QUOTED="Priya" COMMENTED=blue # trailing text is part of the value EMPTY= FROM_HOST
$ FROM_HOST=set-in-my-shell docker run --rm --env-file quirks.env alpine:3.22 sh -c 'env | grep -E "^(QUOTED|COMMENTED|EMPTY|FROM_HOST)="'
QUOTED="Priya" FROM_HOST=set-in-my-shell EMPTY= COMMENTED=blue # trailing text is part of the value

Compose reads .env files with its own parser, which does remove the quotes, so the same file can produce different values under docker run and docker compose. "Multi-container apps with Compose" covers Compose's rules. When a file has to work in both, write values without quotes or comments.

Which value wins

An image can carry defaults of its own, set with the ENV instruction in its Dockerfile. The lesson files include a small Node.js program and a four-line Dockerfile for it; "Writing a Dockerfile" goes through every instruction in the next lesson. The program needs two settings. PORT has a default in the image, DATABASE_URL does not, and the program refuses to start without it.

envapp/server.js
const port = process.env.PORT;
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
console.error('FATAL: DATABASE_URL is not set');
process.exit(1);
}
console.log(`listening on ${port}, database ${dbUrl}`);
envapp/Dockerfile
FROM node:24-alpine
ENV PORT=3000
WORKDIR /app
COPY server.js .
CMD ["node", "server.js"]
ubuntu@secopslog-docker:~/lab/env/envapp · Docker 29.8.2
$ docker build -t lab-envapp:1 .
#0 building with "default" instance using docker driver ... #8 naming to docker.io/library/lab-envapp:1 done #8 unpacking to docker.io/library/lab-envapp:1 0.0s done #8 DONE 0.1s

Run it with nothing set, then with the missing value, then with a different port. The first run is the good kind of failure: the program names the missing variable and exits with status 1, and --rm removes the stopped container. The second run takes PORT from the image and DATABASE_URL from -e. The third overrides the image default without rebuilding anything.

ubuntu@secopslog-docker:~/lab/env · Docker 29.8.2
$ docker run --rm lab-envapp:1
FATAL: DATABASE_URL is not set
$ docker run --rm -e DATABASE_URL=postgres://db:5432/payments lab-envapp:1
listening on 3000, database postgres://db:5432/payments
$ docker run --rm -e DATABASE_URL=postgres://db:5432/payments -e PORT=8080 lab-envapp:1
listening on 8080, database postgres://db:5432/payments

The defaults are stored in the image configuration, next to the ones the node base image set (NODE_VERSION, YARN_VERSION). Anyone who has the image can read them this way.

ubuntu@secopslog-docker:~/lab/env · Docker 29.8.2
$ docker image inspect -f '{{json .Config.Env}}' lab-envapp:1
["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","NODE_VERSION=24.21.0","YARN_VERSION=1.22.22","PORT=3000"]

When the same name is set in several places, Docker applies them in a fixed order and the last one wins: the image's ENV, then the values from --env-file, then each -e. A value given with -e therefore beats the same name in an env file, whatever order the flags appear in.

ubuntu@secopslog-docker:~/lab/env · Docker 29.8.2
$ printf 'GREETING=from-file\n' > greet.env docker run --rm --env-file greet.env -e GREETING=from-cli alpine:3.22 printenv GREETING
from-cli
How a container's environment is assembled
1ENV in the image
defaults such as PORT=3000
2--env-file
values from the file replace image defaults
3-e NAME=value
replaces both, one name at a time
4container environment
fixed until the container is recreated
Changing a value means starting a new container. docker restart reuses the environment the container was created with.

Environment variables are not secret

Everything above is fine for ports, hostnames and feature flags. It is the wrong home for passwords and tokens. The value is stored in plain text in the container's configuration, so anyone who can run docker inspect on the host reads it, and any process inside the container reads it from its own environment. Values typed after -e also land in your shell history, and applications tend to dump their environment into crash reports and debug pages.

ubuntu@secopslog-docker:~/lab/env · Docker 29.8.2
$ docker run -d --name lab-envdemo -e DB_PASSWORD=example-only-not-real alpine:3.22 sleep 300
e09ee608fd81ef66633a44f3bb7e945a00d639a1d1768ce3185e1630ac7623e7
$ docker inspect -f '{{json .Config.Env}}' lab-envdemo
["DB_PASSWORD=example-only-not-real","PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
$ docker exec lab-envdemo printenv DB_PASSWORD
example-only-not-real

For real credentials, mount them as files and have the application read the file. Many official images support this with a _FILE variant of the variable, for example POSTGRES_PASSWORD_FILE in the postgres image. "Runtime secrets, done right" in Advanced container security covers the options and their trade-offs. Keep in mind that being in the docker group already gives a user root-level control of the host, so docker inspect access is never a small thing to hand out.

When the application ignores a variable

Three checks find nearly every case. Names are case-sensitive, so DATABASE_URL and Database_Url are different variables. The program may never read the name you set; check its documentation or code. And the container may predate your change, because the environment is fixed at creation. docker exec <name> printenv NAME answers the question from inside the running container in one command, and docker inspect -f '{{json .Config.Env}}' <name> answers it from outside, also for a stopped container.

Clean up

ubuntu@secopslog-docker:~/lab/env · Docker 29.8.2
$ docker rm -f lab-envdemo && docker rmi lab-envapp:1
lab-envdemo Untagged: lab-envapp:1 Deleted: sha256:06df279bbed859d65f863712b05c6967c476e856c54e577c8dadbebc2a3e573a
Quick check
01docker run --env-file prod.env myapp exits immediately with status 125 and invalid env file (prod.env): variable 'export DB_HOST' contains whitespaces. What is wrong?
Incorrect — There is no such length limit here. The message is about the name, which contains a space.
Correct — "export DB_HOST" becomes the variable name, it contains whitespace, and the CLI rejects the file before creating a container.
Incorrect — Status 125 means the docker command itself failed. No container was created, so the application never ran.
Incorrect — Docker reads any file name you pass to --env-file with the same rules.
02An env file contains GREETING="hello". What value does a program in the container started with docker run --env-file see?
Incorrect — docker run does not strip quotes. Compose's .env parser does, which is why the same file behaves differently there.
Incorrect — Only whitespace in the name is rejected. Quotes in the value are accepted and kept.
Incorrect — Docker does not add escapes. The value is the raw text after the = sign.
Correct — Everything after the first = is the value, quote marks and all.
03An image sets ENV PORT=3000. You run it with --env-file app.env -e PORT=8080, and app.env contains PORT=9000. Which port does the application read?
Correct — The image default is applied first, then the env file, then -e, and the last value for a name wins.
Incorrect — The env file is applied before the -e flags, so -e PORT=8080 replaces its value.
Incorrect — ENV only sets defaults. Both run-time sources override it without a rebuild.
Incorrect — Defining a name several times is normal; Docker resolves it by order instead of failing.

Try this

Work through “Clean up” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

If you keep one thing from environment variables and configuration, keep “Clean up”. Decide now which check you will run when this shows up on a live system, and write it somewhere your team will find it.

Related