---
title: "How environment variables actually work (and why you should delete your .env file)"
canonical: "https://infisical.com/blog/how-environment-variables-work"
published: "2026-09-25"
tags: ["Technical"]
source-index: https://infisical.com/llms.txt
---

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

> **Figure:** 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](https://infisical.com/blog/what-is-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.

```js
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:hunter2@db.internal:5432/production node app.js
Connecting to postgresql://app:hunter2@db.internal: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:hunter2@db.internal: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`:

```js
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:hunter2@db.internal:5432/production
```

The three setups lead to three different outcomes:

> **Figure:** Three ways to give app.js a DATABASE_URL, run side by side. Setting it on the command line works. A .env file on its own prints undefined, because the app never reads the file. Loading the .env file with dotenv works.

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

```js
require('dotenv').config()
console.log(process.env)
```

Now, running `$ node app.js` shows us:

```js
{
  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:hunter2@db.internal: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:hunter2@db.internal:5432/production
```

becomes:

```bash
name:  DATABASE_URL
value: postgresql://app:hunter2@db.internal:5432/production
```

Then, `dotenv` assigns the values into `process.env`:

> **Figure:** 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.

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:

```js
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:hunter2@db.internal: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`.

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

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.

> **Figure:** 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.

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:hunter2@db.internal: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.

> **Figure:** 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.

## 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:hunter2@db.internal: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](https://infisical.com/talk-to-us) to see how it works for your use case or [sign up today](https://app.infisical.com/signup) to test it for free. To self-host, [try our open-source version](https://github.com/Infisical/infisical).
