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.

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.

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.

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

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

$ 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.

But just having one isn’t enough:

$ 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:

require('dotenv').config()

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

If we run the app now, it succeeds:

The three setups lead to three different outcomes:

- Setting DATABASE_URLon the command line works, but the secret persists in shell history.

- Putting the value in .envalone does not work; the app never reads that neighboring file.

- Loading .envwithdotenvworks, becausedotenvpopulates an unsetDATABASE_URLinprocess.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:

require('dotenv').config()

console.log(process.env)

Now, running $ node app.js shows us:

{

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.

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:

becomes:

Then, dotenv assigns the values into process.env:

This isn’t specific to Node:

- In Python, python-dotenvassigns values intoos.environ

- In Go, godotenvassigns values intoos.Getenvandos.Setenv

- In Rust, dotenvyassigns values intostd::env::varandset_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.

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:

console.log(`pid ${process.pid}, parent ${process.ppid}`)

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

Running it gives us the process IDs.

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

Now we can walk up the line:

$ 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.

- launchdstarted at boot and set- HOME,- USER,- SHELL, and a starting- PATH, read from the user record at login.

- launchdstarted the terminal Ghostty, which added- TERM=xterm-ghostty, so programs drawing text know what this terminal can handle.

- Ghostty started zsh, which ran.zshrc. Everyexportin that file adds a pair. Most of them extendPATH.

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

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:

- It copies itself, called fork. Briefly there were twozshprocesses.

- The copy replaced itself with node, viaexecby asking the operating system to loadnodein its place.

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 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:

$ 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:

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.

- zshstarts- infisical(which authenticates, fetches the database string and adds it to- process.env)

- Infisical forks and execs nodewith the database string in the environment.

- nodeis the child of- infisicalrather than of zsh, so it receives the environment with the database string.

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:

$ 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.

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.