How environment variables actually work (and why you should delete your .env file)

Internally, we joke about the “weekly .env tweet”. Someone realizes sharing plaintext documents with the organization’s most sensitive credentials is sketchy, so they ask on X/Twitter: “How do you securely share .env files?”

To many developers, these files are the way apps get database passwords, API keys, auth tokens, and other variables in local development. It’s the standard in many organizations, and few understand how a string in a .env file becomes an environment variable, which means they don’t know these files are optional. This creates both operational and security headaches.

Security-wise a .env file is in .gitignore, but ends up in Slack messages, developer laptops, CI systems’ settings pages, GitOps manifests, etc. In the end, nobody knows which secrets exist, where they are, and who has access to them.

One DATABASE_URL value, copied into a local .env file, a Slack thread, a CI settings page, a deployment manifest and a sync backup.

This is called secret sprawl. It also makes operational work more annoying:

  • Changing a credential requires manually updating it everywhere (and breaks every copy you did not update).
  • Onboarding someone means sending them the file, which creates another copy and makes the problem worse.
  • Offboarding someone does nothing because the file is on their laptop and potentially in their notes app, clipboard, or elsewhere.

(and let’s not even bring up .env itself sprawling into .env.development, .env.staging, .env.production, etc.)

If you’ve only used .env to deliver credentials to apps, that’s just the way things are. But there are other methods like runtime injection that prevent these problems by obviating .env files.

Understanding this starts with understanding that an app never actually reads the .env file, but its environment.

What a typical .env setup looks like

Here is a Node app that reads a value and prints that it is connecting to it. We’ll use this as our example: If it prints the database URL, we succeeded. If it prints undefined, it failed.

JavaScript
console.log(`Connecting to ${process.env.DATABASE_URL}`)

The fastest way to make it work is to set the value on the command line:

Bash
$ DATABASE_URL=postgresql://app:[email protected]:5432/production node app.js
Connecting to postgresql://app:[email protected]:5432/production

This works, but puts the full database credential into your shell history. Plus, any app you’d actually work on requires dozens of credentials, each of which you’d need to copy-paste every time the app runs.

That’s why teams use .env files. Let’s create one.

Bash
DATABASE_URL=postgresql://app:[email protected]:5432/production

But just having one isn’t enough:

Bash
$ node app.js
Connecting to undefined

The app still prints undefined because our .env is just a file and an app doesn’t automatically know what to do with it. For DATABASE_URL to enter the app’s environment, we load dotenv:

JavaScript
require('dotenv').config()

console.log(`Connecting to ${process.env.DATABASE_URL}`)

If we run the app now, it succeeds:

Bash
$ node app.js
Connecting to postgresql://app:[email protected]:5432/production

The three setups lead to three different outcomes:

Three ways to give the app a DATABASE_URL
1Set it on the command line
app.js
console.log(`Connecting to ${process.env.DATABASE_URL}`)
no .env file
$
2Put it in .env
app.js
console.log(`Connecting to ${process.env.DATABASE_URL}`)
.env
DATABASE_URL=postgresql://app:[email protected]:5432/production
$
3Load .env with dotenv
app.js
require('dotenv').config()console.log(`Connecting to ${process.env.DATABASE_URL}`)
.env
DATABASE_URL=postgresql://app:[email protected]:5432/production
$
  • Setting DATABASE_URL on the command line works, but the secret persists in shell history.
  • Putting the value in .env alone does not work; the app never reads that neighboring file.
  • Loading .env with dotenv works, because dotenv populates an unset DATABASE_URL in process.env.

process.env is the environment. An environment is a list of key-value pairs an app expects without searching for them. In Node, the environment is process.env.

We can inspect it by making a change to the app:

JavaScript
require('dotenv').config()
console.log(process.env)

Now, running $ node app.js shows us:

JavaScript
{
  SHELL: '/bin/zsh',
  USER: 'dev',
  HOME: '/Users/dev',
  PATH: '/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin',
  PWD: '/Users/dev/code/demo',
  LANG: 'en_US.UTF-8',
  TERM: 'xterm-ghostty',
  DATABASE_URL: 'postgresql://app:[email protected]:5432/production'
}

Without the dotenv line, we’d get all of the variables, but not DATABASE_URL, which is the only one we consciously set. So dotenv set the database string, but didn’t create the whole environment.

What dotenv actually does

When the app calls dotenv, it opens a file named .env and produces key/value pairs for each item contained in it. So our .env with:

Bash
DATABASE_URL=postgresql://app:[email protected]:5432/production

becomes:

Bash
name:  DATABASE_URL
value: postgresql://app:[email protected]:5432/production

Then, dotenv assigns the values into process.env:

Stepping through node app.js. process.env starts without DATABASE_URL. The first line is dotenv: it reads the .env file and adds DATABASE_URL to process.env as a key and a value. The second line then reads process.env, never the file, and prints the connection string.

What dotenv actually does
app.js
require('dotenv').config()console.log(`Connecting to ${process.env.DATABASE_URL}`)
.env
DATABASE_URL=postgresql://app:[email protected]:5432/production
process.env
SHELL/bin/zsh
USERdev
HOME/Users/dev
PATH/opt/homebrew/bin:/usr/bin:/bin
TERMxterm-ghostty
$

This isn’t specific to Node:

  • In Python, python-dotenv assigns values into os.environ
  • In Go, godotenv assigns values into os.Getenv and os.Setenv
  • In Rust, dotenvy assigns values into std::env::var and set_var
  • And many more

Each is a way for humans to decide which variables need to be in the app’s environment. This is unusual because environment variables are set automatically by what’s called their parent processes.

How parent processes set the environment

When an operating system runs a program, it loads instructions into memory, gives it an ID, and executes it as a process. A process is a running program plus the information the operating system attaches to it.

But every process is started by another process, which is called its parent. For this example, let’s log our app’s process ID:

JavaScript
console.log(`pid ${process.pid}, parent ${process.ppid}`)
console.log(`Connecting to ${process.env.DATABASE_URL}`)

Running it gives us the process IDs.

Bash
$ node app.js
pid 5310, parent 4821
Connecting to postgresql://app:[email protected]:5432/production

Now we can walk up the line:

Bash
$ ps -o pid=,ppid=,comm= -p 4821
 4821  4818 -zsh

$ ps -o pid=,ppid=,comm= -p 4818
 4818     1 /Applications/Ghostty.app/Contents/MacOS/ghostty

Each process added to the environment by passing its own environment to the next and adding something.

  1. launchd started at boot and set HOME, USER, SHELL, and a starting PATH, read from the user record at login.
  2. launchd started the terminal Ghostty, which added TERM=xterm-ghostty, so programs drawing text know what this terminal can handle.
  3. Ghostty started zsh, which ran .zshrc. Every export in that file adds a pair. Most of them extend PATH.

This makes the environment grow over time until we arrive at app.js.

The chain of processes that filled in your app's environment. launchd sets HOME, USER, SHELL and a starting PATH. Ghostty inherits them and adds TERM. zsh inherits those and replaces PATH from .zshrc. node inherits the finished document and adds nothing.

How the environment got therePick a process to see the environment as it had it, or a variable to see what it is for
process.env

So when we run node app.js from the terminal running zsh, it creates the environment dotenv adds to. This happens in two steps every time any process launches:

  1. It copies itself, called fork. Briefly there were two zsh processes.
  2. The copy replaced itself with node, via exec by asking the operating system to load node in its place.

Every process in the chain arrived the same way. launchd forks a copy of itself and that copy execs Ghostty, keeping its pid. Ghostty does the same to start zsh, and zsh does the same to start node. A copy, then a replacement, three times over.

fork and exec
launchdpid 1
launchdpid 4818
Ghosttypid 4821
zshpid 5310

The exec request carries the arguments (app.js) and the environment. Every environment is set this way, whether in a Docker container, a Linux VM, or anywhere else.

dotenv is different because it’s nobody’s parent process, but runs inside app.js, after node has started the app. It’s a rare time a launched process edits its own environment.

This shows that we can add to the environment from inside the process or via the parent process. This means we can obviate .env by putting our additions to the environments into a different parent process.

Runtime injection: fetch at start, store nothing

Runtime injection adds environment variables by giving your app a parent process that creates the environment before launching your app.

Instead of zsh handing node an environment assembled from a file on disk, a tool called a secrets manager fetches values from a secure store, adds them to the environment, and hands that environment over at exec time. The app still only sees its environment and requires zero code changes.

That parent can be anything that authenticates to the secrets manager: a CLI, an agent running next to the app, or anything else. For local development, a CLI is easiest. Infisical’s secrets manager requires logging in and pointing it at the project:

Bash
$ infisical login
$ infisical init

infisical init creates .infisical.json, which records the workspace, domain, and environment, which tells Infisical which variables to fetch. This is benign configuration and contains no secrets (if it leaks, attackers get stopped at Infisical’s authentication), so you can commit it.

After this, we can run the app correctly without our .env file or the dotenv line. After deleting both, we can make Infisical the parent of node app.js:

Bash
$ infisical run -- node app.js
Connecting to postgresql://app:[email protected]:5432/production

The new parent command now means the app receives the database string in the correct place, but without the string being in any project file.

  • zsh starts infisical (which authenticates, fetches the database string and adds it to process.env)
  • Infisical forks and execs node with the database string in the environment.
  • node is the child of infisical rather than of zsh, so it receives the environment with the database string.

infisical run reads the committed .infisical.json for the project and environment, authenticates an identity, fetches the encrypted DATABASE_URL over TLS, then forks and execs node with that value already in its environment. It arrives in process.env under the same key, app.js is unchanged, and nothing is written to disk.

Runtime injection
app.js
console.log(`Connecting to ${process.env.DATABASE_URL}`)
.infisical.json
{  "workspaceId": "6f2a1c94e8b3",  "defaultEnvironment": "development"}
Infisicalnot logged in
DATABASE_URLb7Xk92pQ4mZvR8tLw3Ns6eY1hD5gJ0cF2aUi
process.env
SHELL/bin/zsh
USERdev
HOME/Users/dev
PATH/opt/homebrew/bin:/usr/bin:/bin
TERMxterm-ghostty
$

Where the secret is, and where it is not

One of the problems with .env files is that secrets end up just about everywhere and become impossible to track. Runtime injection simplifies this. While the app runs, we can find the process’s environment:

Bash
$ ps eww -p 5310 | tr ' ' '\n' | grep DATABASE_URL
DATABASE_URL=postgresql://app:[email protected]:5432/production

The value is inside the process, in memory, and any process running as your user can read it while the app runs. In contrast, a .env file sits on disk indefinitely where any software or person with access can read it.

Runtime injection is already a big improvement for local development, but its benefits vs. .env are massive at scale. The secret doesn’t exist in the repository, a Slack thread, a Dockerfile image, or any other persistent text file. This also fixes the operational headaches dotenv introduces.

How runtime injection fixes secret sprawl

Using runtime injection instead of .env files effectively fixes secret sprawl:

  • When somebody joins, they get access to the secrets manager and run the same command as everyone else.
  • When a team member leaves or a contractor’s project is complete, you remove their access, and nothing on their laptop keeps working, because there was never a plaintext secret file and their access is validated with every fetch.
  • When you change a credential, you change it in one place and restart, and every process that fetches at startup picks up the new value.
  • Every fetch is logged, so you know who read the password instead of guessing who still has the file.

Even though .env files seem like the standard for getting credentials into local deployments, what’s important is the environment, not the file. And runtime injection is a more secure, efficient way to set the environment.

Infisical offers a secrets manager that lets you securely deliver secrets wherever your apps or workloads run today. Talk to an expert to see how it works for your use case or sign up today to test it for free. To self-host, try our open-source version.

Finn avatar

Finn

Technical Content Marketer, Infisical

READ NEXT

Starting with Infisical is simple, fast, and free.