Environment variables
Configure a container at run time.
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.
docker run --rm -e NAME=Sam alpine sh -c 'echo "Hello, $NAME"'
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.
docker run --rm -e ROLE=admin -e TIER=free alpine env
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binHOSTNAME=3f9a2c1b8e4dROLE=adminTIER=freeHOME=/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:
NAME=PriyaROLE=editorFEATURE_DARK_MODE=true
docker run --rm --env-file ./app.env alpine env
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binHOSTNAME=7c1e0a9f2b6dNAME=PriyaROLE=editorFEATURE_DARK_MODE=trueHOME=/root
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.
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}`);
FROM node:22-alpineENV PORT=3000WORKDIR /appCOPY 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.
docker build -t myapp .docker run --rm myapp
[+] Building 2.3s (9/9) FINISHED=> => naming to docker.io/library/myapp:latestFATAL: 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.
docker run --rm -e DATABASE_URL=postgres://db:5432/payments myapp
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.
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.
printf 'GREETING=from-file\n' > envdemo.txtdocker run --rm -e GREETING=from-cli -e WHO=learner alpine:3.20 printenv GREETING WHOdocker run --rm --env-file envdemo.txt alpine:3.20 printenv GREETINGrm envdemo.txt
from-clilearnerfrom-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.