CoursesDocker for beginnersEnvironment variables

Environment variables

Configure a container at run time.

Beginner8 min · lesson 8 of 16
In plain terms
Environment variables are like setting the thermostat and preferences before someone moves into an apartment — same apartment, different settings each time. The image ships with sensible defaults; you dial in the specifics when you start it.

A sticky note stuck to the side of a machine: a short label on the left, a value on the right. PORT on one note, 8080 written beside it. Every program you start gets handed a small stack of those notes by the operating system before its first line of code runs. Each note is one environment variable, a name with a text value attached. Your program reads the notes it cares about and ignores the rest. None of it is built into the code. The notes sit beside the program while it runs, and you write a fresh stack every time you start it.

A frozen meal in the shop is sealed and identical to every other box on the shelf. Heat one up and you get tonight's dinner. A Docker image is that sealed box: your app's code, the runtime that actually executes it (Node.js, for example), and the files it needs, packed so the whole thing behaves the same on any machine. A container is the dinner on your plate, one running copy started from that image. Here is the useful part. One image can feed many containers, and each one can be handed a different stack of notes as it starts. This copy becomes your laptop setup. That copy becomes production. You never rebuild the image to move between them. You change the notes, not the meal.

One value at a time: -e

The -e flag sets one variable for one docker run. The shape never changes: -e NAME=value. Five variables means writing -e five times. Try it with alpine, a stripped-down Linux image small enough to download in a second or two.

terminal
docker run --rm -e NAME=Sam alpine sh -c 'echo "Hello, $NAME"'
output
Hello, Sam

Three separate things are going on in that one line. First, --rm tells Docker to bin the container the moment it finishes, so dead containers don't pile up on your disk. Second, alpine is the image you're running. Third, sh -c '...' hands the command to the shell (the small program inside the container that reads typed commands and runs them), where $NAME means "go and look up the note called NAME". Swap Sam for your own name and the greeting follows.

Curious about every note a container is holding, not only the ones you set? There is a built-in command called env that prints the lot.

terminal
docker run --rm -e ROLE=admin -e TIER=free alpine env
output
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=3f9a2c1b8e4d
ROLE=admin
TIER=free
HOME=/root

There they are. ROLE and TIER, the two you passed in, sitting alongside a handful that Docker and Alpine set for themselves, like PATH and HOSTNAME. Your app can read any name on that list, yours or theirs.

A whole file at once: --env-file

Typing -e eight times gets old fast. Put the variables in a plain text file instead, one KEY=value per line, and load the whole batch with --env-file. Create a file called app.env:

app.env
NAME=Priya
ROLE=editor
FEATURE_DARK_MODE=true
terminal
docker run --rm --env-file ./app.env alpine env
output
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=7c1e0a9f2b6d
NAME=Priya
ROLE=editor
FEATURE_DARK_MODE=true
HOME=/root
The env file is fussy about format
No quotes. No spaces either side of the =. No export word in front of the name (that word belongs in shell scripts, and copying it in here does nothing good). Write NAME=Priya and nothing more. Type NAME = "Priya" and Docker reads every character literally, so the value arrives with the spaces and the quote marks still attached, and your app misbehaves later for reasons that make no sense at the time. Blank lines are skipped, and any line starting with # counts as a comment.

Defaults in the image, and what breaks when a value is missing

Most real apps want a value or two before they will start at all. An image can carry its own defaults using the ENV instruction in a Dockerfile, the plain text file that lists the steps for building an image. Anything you pass at run time wins over those defaults. What follows is a tiny web server written in Node.js (JavaScript running outside a browser). It reads two variables. PORT has a default. DATABASE_URL does not, and the server flatly refuses to run without it.

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}`);
Dockerfile
FROM node:22-alpine
ENV PORT=3000
WORKDIR /app
COPY server.js .
CMD ["node", "server.js"]

That ENV PORT=3000 line bakes a sensible default straight into the image. Build it, tag it myapp with the -t flag, then run it with no variables at all and watch it topple over.

terminal
docker build -t myapp .
docker run --rm myapp
output
[+] Building 2.3s (9/9) FINISHED
=> => naming to docker.io/library/myapp:latest
FATAL: DATABASE_URL is not set

The container printed its complaint, quit, and --rm swept up the remains. PORT was fine, because the Dockerfile's ENV handed it 3000. DATABASE_URL had no default and nobody supplied one, so the app stopped itself on purpose. That is the good kind of failure. It is loud, and it names the exact variable it wanted. Give it that value and run again.

terminal
docker run --rm -e DATABASE_URL=postgres://db:5432/payments myapp
output
Listening on 3000, database postgres://db:5432/payments

DATABASE_URL now arrives from your -e flag, while PORT still comes from the image's built-in default. You can push that default aside too. Add -e PORT=8080 to the same command and the app reports 8080 instead of 3000, with no rebuild and no edit to the image.

One caution before you start writing real values on these notes. Environment variables are not secret. Anyone who can run docker inspect against your container reads every one of them, passwords included, and the values have a habit of turning up in logs and crash reports. They are a fine home for ordinary settings: ports, hostnames, feature flags (on/off switches for parts of your app). For genuine secrets, reach for mounted files or a dedicated secrets manager, which the security courses cover properly.

How Docker settles on a variable's final value
1Dockerfile ENV
Baseline defaults baked into the image, like PORT=3000
2--env-file ./app.env
Loads a batch of KEY=value lines when the container starts
3-e KEY=value
Overrides single values right on the run command
4Container environment
The final merged set of name=value notes for this one container
5Your app reads it
process.env, os.environ, or $VAR, whatever your language uses

Reading configuration from the environment is not a Docker invention. Database engines, web frameworks and anything written in the twelve-factor style (a short checklist for building apps that run well on servers you do not own) all expect names like PORT, DATABASE_URL and feature flags to arrive from outside the code. Docker fits that habit neatly with -e and --env-file, which is why one image can serve your laptop, a test runner and a production cluster without being rebuilt for each.

Order decides everything when the same name shows up twice. ENV in the Dockerfile sits at the bottom as the image's default. A file loaded with --env-file stacks on top of that. A -e flag on the run command sits highest of all. Whatever survives that stack becomes the container's environment, and it is fixed for the life of that container. Restart with different flags and you get a different set of notes.

Two habits are worth forming early. A password typed after -e lands in your shell history file, sitting there in plain text long after you have forgotten the command. And nothing here encrypts anything, so a value you type is a value that anyone with access to the host can read straight back. Once you are handling real credentials, you move to mounted secret files, Docker Compose secrets, or a proper secrets manager. For a port number or a feature flag, -e is exactly the right tool.

When an app seems to ignore a variable you set, three checks explain nearly every case. The name is spelled differently from what the code reads, and case counts, so DATABASE_URL and Database_Url are two separate notes. The program never looks for that name at all. Or you set it on the wrong service in a Compose file, which is easy to do once four service blocks are on screen. Run printenv inside the container and the argument is over in seconds.

Try this

Pass your own variable into Alpine, prove it landed with printenv, then watch a value arrive from a one-line env file instead.

terminal
printf 'GREETING=from-file\n' > envdemo.txt
docker run --rm -e GREETING=from-cli -e WHO=learner alpine:3.20 printenv GREETING WHO
docker run --rm --env-file envdemo.txt alpine:3.20 printenv GREETING
rm envdemo.txt
output
from-cli
learner
from-file

Takeaway

-e and --env-file set a container's configuration at the moment it starts, and both beat the defaults baked in with ENV. Right for a port or a database hostname. Wrong for a password, because the value stays readable to docker inspect and to anything running inside the container.

Quick check
01A Dockerfile has ENV PORT=3000 baked into the image. You start it with docker run -e PORT=8080 myapp. Which port does the app actually read?
Incorrect — The ENV line supplies a default. It applies only when nothing at run time says otherwise.
Correct — A -e value on the run command replaces the image's ENV default for this container.
Incorrect — A name holds one value at a time. The run-time value wins cleanly, and nothing about it is random.
Incorrect — Overriding a default is completely normal, so nothing errors out here.
02The lesson tells you to keep passwords out of environment variables. What is the reason?
Correct — The values are stored as plain text and surface in inspect output and in logs.
Incorrect — There is no encryption and no key. Docker stores the text exactly as you typed it.
Incorrect — They stay put for the whole life of the container, which is how the app reads them.
Incorrect — They hold any text you like. The problem is who else gets to see it.
03You built myapp from a Dockerfile with ENV PORT=3000, and its server exits with a FATAL error when DATABASE_URL is missing. You run docker run --rm myapp with no -e flags. What do you see?
Incorrect — PORT has a default. DATABASE_URL has none, so there is no database URL to fall back on.
Incorrect — Nothing prompts you. The app prints its fatal error and exits straight away.
Incorrect — The ENV default covers PORT, so the container does start. The app is what quits.
Correct — PORT comes from ENV, the missing DATABASE_URL makes the app stop itself, and --rm clears the remains.

Related