Environment Variables Explained From the Operating System Up
Your app never reads the .env file. It reads its environment, a list set by whoever starts the process. See how that list is built, why the file causes secret sprawl, and how runtime injection fills it without a file on disk.
Looking to improve your secret management processes?Talk to an expert
Environment Variables Explained From the Operating System Up
The .env file almost every app has
Almost every app you work on has a .env file. It sits at the root of the project and holds the database password, the Stripe API key, and every other token the app needs to talk to the outside world. It is in .gitignore, so it never gets committed. But it does get sent over Slack when someone new joins, it ends up on every developer's laptop, and it gets pasted into the settings page of the CI system.
Does the app actually need that file? It does not. The app never asks for a file. It asks for its environment. And there is a way to fill that environment that is both safer and easier than a file on disk. It is called runtime injection. To see why it works, it helps to look at how programs actually start.
Secret sprawl: what happens when a password is text in a file
Take a small Node app. It reads one value, DATABASE_URL, and prints that it is connecting to it. Next to it sits a .env file with one line: DATABASE_URL set to a Postgres connection string with the password inside. The first line of the app loads the dotenv library, and when the app runs, it prints the connection string. That is how most apps start out.
The problem is that the password is now text in a file, and once a secret is text in a file, it gets copied. Each developer has a copy. The CI system has a copy in its settings. There is one in a Slack thread from the day somebody was onboarded, and probably one on a wiki page that someone wrote so they would stop being asked for it. If anyone committed the file before .gitignore existed, it is in source control too. If the Dockerfile copies the whole directory, there is a copy inside every image ever built.
This is secret sprawl. Nobody knows how many copies exist, and nobody could find them all if they wanted to. The consequences follow directly:
- Changing the password breaks everything that still holds an old copy, so rotation is painful and rarely happens.
- Onboarding someone means sending them the file, which is more sprawl.
- Offboarding someone does effectively nothing. The file is already on their machine, and it still works.
The app reads its environment, not the file
Node has no idea the .env file exists. The app reads its environment: a set of variables the operating system attached to the process when it started. Run printenv in a terminal and you can see all of them.
dotenv is a library that reads that file and copies its contents into the environment after the app is already running. Delete the first line of the app, the one that loads dotenv, and run it again. It fails. The file is still in the directory, and Node never touched it.
Now run the app with the variable written on the command line, before the command. It works, without dotenv and without the .env file. The shell put that variable into the app's environment directly, which is the same place dotenv was putting it. The file was only ever one way of storing variables to be copied into the environment. The command line filled the environment without the file.
So what actually put the value there? To answer that, look at what happens the moment a program starts.
Processes, parents, and the family tree
A program like Node is a file on disk, and it does nothing until you run it. When you run it, the operating system loads the program's instructions into memory, gives it an ID number, and starts executing it. That running thing is a process: a program while it is running, plus some information the operating system attaches to it, such as who is running it, what directory it is in, and which arguments it was given.
A process does not start itself. Every process was started by some other process, called its parent. On a Mac, the operating system starts a process called launchd at boot. launchd starts the terminal app. The terminal starts the shell, zsh. When you type node app.js and press enter, zsh starts Node. Node's parent is zsh, zsh's parent is the terminal, and the terminal's parent is launchd. Every process on the machine is running somewhere in this tree.
Fork and exec: how the environment gets set
When zsh starts Node, it has a problem. It needs Node to start running, but it also needs to keep existing, because when Node finishes you want your prompt back. It cannot simply turn itself into Node. So it does two things.
First, it makes a copy of itself. This is called a fork. Now there are two zsh processes, the original and the copy. Next, the copy transforms into Node. This step is called exec. The copy asks the operating system to throw away everything inside it and load the Node program in its place. That request includes two things Node starts with: the arguments, which here is app.js, and a list of lines. That list of lines is the environment.
The environment is set once, by the parent, as part of the exec request that creates the child. The child starts with it already in place.
What the environment actually is
printenv prints the environment of the shell. Every line is a name, an equals sign, and a value. HOME is your home directory. USER is your username. PATH is the list of directories the shell searches when you type a command. There are dozens of these, and most were written by the operating system or by the shell's own startup file before you typed anything.
The entire structure of the environment is a list of plain text lines in the form name equals value. It looks exactly like a .env file, and that is not a coincidence. The .env format was designed to mirror the environment. But the environment is not a file. It lives in the memory of the process that owns it, and it was placed there by the parent when the process started.
So when the app ran with DATABASE_URL on the command line, this is what happened. zsh made its copy. The copy took zsh's own environment, added one line to it, and became Node with that list present. When the app asked for DATABASE_URL, it looked in its own list and found it.
The list flows down the process tree
If the parent hands the list to the child, where did the parent get it? From its own parent. zsh received a list from the terminal when the terminal started it. The terminal received a list from launchd. Each process gets a copy of its parent's environment and can add lines before passing a copy to its own children. The list flows down the family tree and grows a little at each level.
The lines were written at different levels. The operating system wrote HOME and USER at login. zsh added lines from the .zshrc file when it started, because every export statement in that file is an instruction to add a line. Typing DATABASE_URL before the command added one more line, for that one child only.
One detail matters here. The child gets a copy, not a shared reference. If Node changes its own list, zsh does not see the change. If zsh changes its list after Node started, Node does not see that either. This is why a variable set in one terminal window does not appear in another.
What dotenv actually does, and why it is not Node-specific
dotenv is a library with one job. When the app calls it on line one, it opens the .env file in the current directory, reads it line by line, and adds each line to the app's own environment list. If a name is already in the list, it skips that line. Then it is finished, and it never runs again. dotenv fills the list from inside the process, after it has started, by reading a file. That file is one way to put lines in the list, and it is the way that leads to plain text copies on every disk.
None of this is specific to Node. Node exposes the list as process.env. Python calls it os.environ. Go reads it through the os package. Ruby calls it ENV. The app only sees the list, and it cannot tell who started it. Anything that can start a process and set its environment can be the parent. On a Linux server, that is systemd reading the service's unit file. In a container, it is Docker reading the -e flags. On Kubernetes, it is the kubelet reading the pod spec. The app runs the same line of code in every case.
Runtime injection: make the parent fetch the secrets
Back to the problem. The passwords were in a file, and the file got copied everywhere. The fix has two parts.
First, put the secrets in one centralized place instead of a file on every machine: a secrets manager like Infisical. It stores the values, it controls who can read them, and it records every read.
Second, make the parent fetch from there. Something authenticates to the secrets manager, asks for the secrets this app needs, receives them as name and value pairs, puts them into the environment, and starts the app. The app reads its environment exactly the way it always did.
This is runtime injection. The values are fetched and placed at the moment a process starts, and they never live in a file on disk. The thing doing the fetching can be a CLI, an agent that runs next to the app, or code inside the app itself.
Runtime injection with the Infisical CLI
For local development, the simplest option is the Infisical CLI. With an Infisical account and the secrets already stored in a project, the setup is three commands. Log in once with infisical login. Point the CLI at the remote project with infisical init. Then, instead of typing node app.js, type infisical run -- node app.js.
The CLI fetches the secrets for this project and environment, puts them in the list, and starts the app. The app still prints the connection string. There is no .env file in the directory, and the app has not changed a single line since the beginning, other than the dotenv import it no longer needs. For a closer look at swapping the file for the CLI, see The Death of the .env File.
With the app still running in a second terminal, you can ask the operating system for the other process's environment, and DATABASE_URL is there with the password in it. The value is in the process. Change the value in the secrets manager, stop the app, start it again, and it fetches and prints the new value.
What changes when there is no file
The problem was copies of a file. Now there is no file, and each consequence of sprawl reverses.
- When someone joins, they log in to the secrets manager and run the same command as everyone else.
- When someone leaves, you remove their access, and nothing on their laptop keeps working, because nothing was on their laptop.
- When you change a password, you change it in one place and restart what needs restarting. The change reaches everything that fetches.
- Every fetch is logged, so you know who read the password instead of guessing who has the file.
Here the parent process was the CLI on a laptop, injecting secrets for local development. The same idea works on a server with systemd. It works in Docker, in Kubernetes, and in CI, each with its own way of being the parent. If your images are the place the copies pile up, Your Docker Image Is Leaking Secrets covers that case specifically.
The one thing to take from all of this: the app reads a list. The list is set by whoever starts the app. The file was only ever one way to fill that list, and it is the way that comes with problems.
Starting with Infisical is simple, fast, and free.