Sign in
← Linux: administration
10 min · easy · Lesson 5 of 10

Environment variables and .env

What environment variables are, how processes set and inherit them, and why app config lives in the environment.

What environment variables are, how processes inherit them, and why app config lives in the environment.

Why this matters

The same app runs on your laptop, on a test environment, and in prod. The code is the same, but the database, the port and the log level differ everywhere. Almost always, those settings travel through environment variables: in Docker, in Kubernetes, in CI, in systemd.

That's where the usual problems come from. A script "can't see" a variable you just set. A container crashes over a config value that makes no sense. A database password ends up in git. This lesson covers where each of these comes from.

What an environment variable is

An environment variable is a name-value pair that a process gets from its parent at startup and sees for its whole lifetime. Apps get their configuration through these variables: which database to connect to, which port to listen on, which mode to run in.

See the current shell's environment:

printenv | head
PATH=/usr/local/bin:/usr/bin:/bin
HOME=/home/deploy
LANG=en_US.UTF-8
...

You can print a single variable with printenv HOME or echo "$HOME":

echo "$HOME"
/home/deploy

You refer to a variable as $NAME. By convention, environment variable names are uppercase: DB_HOST, LOG_LEVEL.

Shell variable vs environment variable

A shell has two kinds of variables:

NAME=value              # shell variable, visible only in this shell
export NAME=value       # environment variable, child processes get it

The difference shows up as soon as the shell starts another process. Here sh -c starts a child shell:

X=1; export Y=2; sh -c 'echo "X=[$X] Y=[$Y]"'
X=[] Y=[2]

X stayed inside the parent shell, Y made it to the child. You can also export later: set X=1, then export X. To remove a variable entirely: unset X.

Because of this, echo and printenv sometimes give different answers. echo "$X" shows the shell variable, while printenv X prints nothing: it's not in the environment.

How inheritance works

When a process starts another one, the child gets a copy of the parent's environment. It really is a copy: each one then changes its own, and the changes never travel back.

Two things follow from this. First: a child can't change its parent's environment. A script that does export DB_HOST=... internally only changes its own environment, and once it finishes, nothing changes in your terminal:

sh -c 'export DB_HOST=db.local'; echo "DB_HOST=[$DB_HOST]"
DB_HOST=[]

Second: the environment of a process already running doesn't change from the outside. Fixed a setting? Restart the process, or it keeps working with the old values. On Linux, the environment a process started with is visible in /proc/<PID>/environ (values are separated by a null byte, so you turn them into lines with tr):

tr '\0' '\n' < /proc/1234/environ | grep APP_

Swap 1234 for your process's PID (how to find it was covered in the lesson on processes).

Set a variable for a single command without changing your own environment:

PORT=9090 ./app         # app sees PORT=9090, your shell doesn't

The .env file

When there are a lot of variables, they live in an .env file:

# .env
DB_HOST=localhost
DB_PORT=5432
DB_PASSWORD=lab-only-password
LOG_LEVEL=info

These are KEY=value pairs, one per line. Nobody reads .env on its own: it's loaded by the app (through something like dotenv), by docker compose (env_file:), by docker run --env-file, or by hand in a shell:

set -a; . ./.env; set +a

. ./.env runs the file's lines in the current shell, which means it sets shell variables. set -a turns on a mode where every variable you set gets marked for export right away, and set +a turns it off. Without set -a, the variables from the file stay in the shell and never reach the programs you launch.

There's no single standard for the .env format. A shell reads it by shell rules, docker compose by its own, docker run --env-file by its own. Quotes, spaces around =, and trailing comments are each handled differently. So when an app behaves strangely, don't look at the file, look at what actually reached the process: printenv inside it, docker inspect for a container.

.env and git

The main rule: don't commit .env with secrets. Add it to .gitignore, and keep .env.example next to it: the same keys with no values, a template of what to fill in.

# .env.example
DB_HOST=
DB_PORT=
DB_PASSWORD=
LOG_LEVEL=

.gitignore only works on files git isn't already tracking. If .env has already made it into a commit, a line in .gitignore won't remove it: git keeps tracking changes to it. And a secret from an old commit stays in history, readable by anyone with a copy of the repository. A password like that counts as leaked, and you rotate it.

Why config lives in the environment

Keeping config in environment variables has been standard practice for a long time; it's one of the points in the twelve-factor app approach. The reasons:

That's why containers are configured through -e KEY=value, --env-file and env_file: in compose, rather than through config baked into the image:

docker run --rm -e LOG_LEVEL=debug busybox:1.36.1 printenv LOG_LEVEL
debug

If the same variable comes from both --env-file and -e, -e wins.

The environment isn't a vault. A container's variables are visible in docker inspect to anyone with access to Docker, and a process's environment can be read from /proc by its owner and by root. Serious secrets have their own mechanisms (Docker secrets, Kubernetes Secret). We'll cover those in their own tracks.

Common mistakes

What you'll need for the tasks

Lab 1 and lab 3 run in your regular terminal (Linux, macOS, WSL2), lab 2 in Docker. You'll need:

What to remember

An environment variable is a name-value pair that a process gets as a copy from its parent. That's how config gets passed to an app.

export marks a variable for handing down to children; without it, a child process won't see it. VAR=value command sets a variable for one run. A child can't change its parent's environment, and a running process doesn't see fixes until it's restarted.

Config lives in the environment (.env, -e, --env-file, env_file:), so the same image works everywhere and secrets don't sit in code. Don't commit an .env with secrets to git; keep an .env.example next to it.

Next lesson: disk space, where it goes and how to find what's eating it.

Done reading?

Mark the lesson as read to move your track progress forward.

Go through it with an AI agent

The agent explains anything unclear in plain words, gives you 3 labs for this lesson and drills you on interview questions. $49 one-time, lifetime access.

Get access for $49

Tell the agent: use MCP izi, give me lesson linux-admin-05-env-vars