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.
postgresql://••••@db.internal/ productionDATABASE_URL=postgresql://•••Can you send the dev config?
prod.env · 1 KBDATABASE_URL••••••••••value: postgresql://•••secrets.envsyncedThis 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.
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.
DATABASE_URL=postgresql://app:[email protected]:5432/production
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:
$ node app.js Connecting to postgresql://app:[email protected]:5432/production
The three setups lead to three different outcomes:
1console.log(`Connecting to ${process.env.DATABASE_URL}`).env file1console.log(`Connecting to ${process.env.DATABASE_URL}`)1DATABASE_URL=postgresql://app:[email protected]:5432/production1require('dotenv').config()2console.log(`Connecting to ${process.env.DATABASE_URL}`)
1DATABASE_URL=postgresql://app:[email protected]:5432/production- 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.
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:
DATABASE_URL=postgresql://app:[email protected]:5432/production
becomes:
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.
dotenv actually does1require('dotenv').config()2console.log(`Connecting to ${process.env.DATABASE_URL}`)
1DATABASE_URL=postgresql://app:[email protected]:5432/productionSHELL/bin/zshUSERdevHOME/Users/devPATH/opt/homebrew/bin:/usr/bin:/binTERMxterm-ghosttyThis 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.
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:
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 setHOME,USER,SHELL, and a startingPATH, read from the user record at login.launchdstarted the terminal Ghostty, which addedTERM=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.
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.
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.
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 execlaunchdpid 1launchdpid 4818Ghosttypid 4821zshpid 5310The 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:
$ 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:
$ 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.
zshstartsinfisical(which authenticates, fetches the database string and adds it toprocess.env)- Infisical forks and execs
nodewith the database string in the environment. nodeis the child ofinfisicalrather 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.
1console.log(`Connecting to ${process.env.DATABASE_URL}`)1{2 "workspaceId": "6f2a1c94e8b3",3 "defaultEnvironment": "development"4}
DATABASE_URLb7Xk92pQ4mZvR8tLw3Ns6eY1hD5gJ0cF2aUiSHELL/bin/zshUSERdevHOME/Users/devPATH/opt/homebrew/bin:/usr/bin:/binTERMxterm-ghosttyWhere 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:
$ 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.



