# Environment Variables & Config (.env, YAML)

> What config files really do, why settings live outside your code, how environment variables and .env files work, and how YAML/JSON/TOML and config precedence fit together.


---

# Environment Variables & Config (.env, YAML)

You cloned a project, ran it, and it died with `Error: DATABASE_URL is not set`. Or you opened a repo
and found a file called `config.yaml`, a file called `.env.example`, and a warning in the README saying
**never commit your `.env`** - with no explanation of why. None of this was ever taught to you; it just
showed up in every project and you were expected to already know.

This guide makes it knowable. By the end you'll understand what a config file *actually does*, why your
settings live outside your code in the first place, what an environment variable really is, and how the
common config formats (YAML and friends) work - including the indentation trap in YAML that bites
everyone exactly once.

## How to read this

- **Just need to get a project running?** Skim [Phase 2: Environment Variables & .env Files](02-env-vars-and-dotenv.md)
 - it covers reading variables and the `.env` file most projects expect.
- **Want it to finally make sense?** Read in order. Each phase builds on the one before, starting with
  the mental model that makes the rest obvious.

## The phases

1. **[Why Config Lives Outside Code](01-why-config-lives-outside-code.md)** - the same app runs on your
   laptop, on staging, and in production with different settings; config is how you separate
   what-changes-per-environment from the code itself.
2. **[Environment Variables & .env Files](02-env-vars-and-dotenv.md)** - what an environment variable
   actually is, how to read one from the shell and from code, and how `.env` files make local
   development sane (and why you must never commit them).
3. **[Config Files: YAML & Friends](03-config-files-yaml.md)** - YAML, JSON, and TOML for structured
   config, the YAML indentation gotcha, and the precedence order that decides which setting wins.

> Deeper material - *how* to store production secrets safely (vaults, encrypted secrets, cloud secret
> managers) - lives in its own guide: [Secrets Management](/guides/secrets-management). This guide gets
> you to the point where you understand *why* secrets belong in config and out of Git.


---

# Why Config Lives Outside Code

Here's a moment every developer hits: you've got code that works perfectly on your laptop. It talks to a
database on your machine, uses a test API key, prints lots of debug logging. Then it's time to put it on
a real server for real users - and *nothing about those settings is right anymore*. The database lives
somewhere else. The API key needs to be the real, paid one. You definitely don't want debug logging
spraying everywhere in front of customers.

The naive fix is to open the code and change those values before you deploy. That way lies pain. The real
answer is older and calmer: **keep the things that change per environment *out* of the code entirely.**
Let's build the mental model that makes the rest of this guide click.

## What an "environment" actually is

**What it actually is.** An **environment** is one place your app runs, with its own surroundings. The
same exact code can run in several of them at once:

```mermaid
flowchart TD
  Code[The SAME application code] --> Dev[Development<br/>your laptop · local DB · debug logs]
  Code --> Staging[Staging<br/>test server · staging DB · debug logs]
  Code --> Prod[Production<br/>real users · live DB · quiet logs]
```

📝 **Terminology.** People throw these names around constantly:
- **Development** (or "dev", "local") - your own machine, where you write and test code.
- **Staging** - a rehearsal server that mimics production, where you check things before real users see
  them. (Some teams also have "QA" or "test" environments; same idea.)
- **Production** (or "prod") - the real deal. Real users, real data, real consequences.

**Why people get this wrong.** The tempting mental picture is "different environment means different
code." It doesn't. Shipping different code to each place means every environment is testing something
slightly different from what you'll actually run - so a bug can hide in production that never appeared in
staging. The whole point of staging is to be a faithful rehearsal, and that only works if it runs the
*same code* prod will.

**What it does in real life.** You build your code once and deploy that identical build everywhere. What
differs between the boxes above isn't the code - it's a small set of values: which database to connect
to, which API key to use, how loudly to log. Those values are the **configuration**.

## What configuration actually is

**What it actually is.** **Configuration** is the set of values your app needs to know that depend on
*where it's running*, not on *what it does*. The code says "connect to the database." The config says
"...and here's *which* database." The code is the recipe; the config is which kitchen you're cooking in.

A quick test for whether something belongs in config: **would this value be different on someone else's
machine, or on the production server?** If yes, it's config. If it's the same everywhere forever, it's
probably just part of the code.

```text
   CODE (same everywhere)            CONFIG (varies per environment)
   ─────────────────────            ───────────────────────────────
   how to connect to a DB     +     DATABASE_URL  = which DB
   how to call the payment API +    STRIPE_KEY    = which key (test vs live)
   how to write a log line    +     LOG_LEVEL     = debug vs warn
```

## Why we don't just hardcode it

**Why people get this wrong.** It feels easier to write the value straight into the code:

```text
   db = connect("postgres://localhost:5432/myapp_dev")   ← hardcoded
```

That works on your laptop and quietly creates four problems:

- **You can't deploy without editing code.** Every environment needs a different value, so you'd be
  hand-editing the source for each one - exactly the error-prone ritual we're trying to kill.
- **Secrets end up in your repository.** If that line had your real production database password or API
  key, it's now in Git history forever, visible to everyone with access to the repo. That's how leaks
  happen. (The proper handling of secrets is its own topic - see
  [Secrets Management](/guides/secrets-management).)
- **You can't change a setting without a redeploy.** Want to turn on debug logging in production for ten
  minutes to chase a bug? With a hardcoded value you'd have to change code, rebuild, and redeploy. With
  config, you change one value and restart.
- **Collaboration breaks.** Your teammate's database password isn't yours. If it's baked into the code,
  every `git pull` overwrites their settings with yours.

**Why this saves you later.** Pulling these values out into config means one codebase runs unchanged in
every environment, secrets stay out of your source history, and you can re-point or re-tune a running
system without touching code. This idea is old and battle-tested - it's the "config" part of the
widely-cited **Twelve-Factor App** guidelines, which recommend strict separation of config from code
(source: <https://12factor.net/config>).

## So where *does* config live?

If not in the code, then where? Two places, and the rest of this guide is about each:

1. **Environment variables** - values handed to your program by the operating system when it starts.
   Great for single values and secrets, and the standard way production systems inject config. That's
   [Phase 2](02-env-vars-and-dotenv.md).
2. **Config files** - structured files (YAML, JSON, TOML) that live alongside your project, good for
   larger or nested settings. That's [Phase 3](03-config-files-yaml.md).

Most real projects use *both*, with a clear order of who-wins-when. We'll get to that precedence rule at
the end.

## Recap

1. An **environment** is one place your app runs - dev (your laptop), staging (rehearsal), production
   (real users) - all running the **same code**.
2. **Configuration** is the values that differ per environment: database URL, API keys, log level.
3. **Hardcoding** those values traps you: no clean deploys, secrets leak into Git, no live changes, and
   teammates clobber each other.
4. Keeping config **outside the code** lets one codebase run everywhere - the long-standing Twelve-Factor
   recommendation.
5. Config lives in **environment variables** and **config files**, often both, with a defined precedence.

Next, the workhorse of real-world config: the environment variable.


---

# Environment Variables & .env Files

You've seen them in READMEs (`export API_KEY=...`), in error messages (`DATABASE_URL is not set`), and in
that mysterious `.env` file everyone has but nobody explains. Environment variables are the single most
common way config reaches a running program, and once you see what they actually are, they stop being
magic. Let's make them ordinary.

## What an environment variable actually is

**What it actually is.** An **environment variable** is a `NAME=value` pair that the operating system
keeps for a running program and its children. When your program starts, it inherits a little bag of these
pairs - its **environment** - and it can look any of them up by name. That's the whole concept: named
values handed to a process from the outside.

```text
   ┌──────────── the process's environment ────────────┐
   │  DATABASE_URL = postgres://localhost:5432/myapp    │
   │  LOG_LEVEL    = debug                              │
   │  API_KEY      = sk_test_abc123                     │
   │  PATH         = /usr/local/bin:/usr/bin:/bin       │
   └────────────────────────────────────────────────────┘
            │  your program reads these by name
            ▼
        "give me DATABASE_URL"  →  "postgres://localhost:5432/myapp"
```

📝 **Terminology.** The convention is **ALL_CAPS_WITH_UNDERSCORES** for the name - not an OS rule, but
every project follows it. The value is always text (a string); even a "number" like a port is stored as
the characters `"5432"`.

**Why people get this wrong.** People assume environment variables are something Python or Node invented.
They're not - they're an operating-system feature that predates all of them. Your shell has them, every
program has them, and that `PATH` variable that tells your terminal where to find commands is the same
mechanism. Frameworks just give you a convenient way to *read* them.

## Reading a variable from the shell

The fastest way to see one is from your terminal. On macOS or Linux, `echo` prints a value and `$NAME`
asks the shell for that variable:

```console
$ echo $HOME
/home/ada
```
*What just happened:* The shell looked up the `HOME` variable - which the OS set for you at login - and
substituted its value, then `echo` printed it. `HOME` is one your system always sets; it points at your
user folder.

Ask for one that isn't set, and you get nothing - an empty line, not an error:

```console
$ echo $DATABASE_URL

```
*What just happened:* `DATABASE_URL` isn't set in this shell, so `$DATABASE_URL` expanded to *nothing*,
and `echo` printed a blank line. This is the root of those `is not set` errors: the program asked for a
variable, the OS had nothing, and the program gave up.

⚠️ **Gotcha - Windows is different.** `echo $VAR` is Unix-shell syntax (bash, zsh). On **PowerShell** the
same lookup is `echo $env:HOME`; in the old **Command Prompt** it's `echo %HOME%`. The concept is
identical everywhere - only the reading syntax differs.

You can set one for the current shell session like this (Unix):

```console
$ export LOG_LEVEL=debug
$ echo $LOG_LEVEL
debug
```
*What just happened:* `export` created `LOG_LEVEL` and marked it so programs launched *from this shell*
inherit it. No spaces around the `=`, and it's **temporary** - it lives only until you close this
terminal. That temporariness is exactly the problem `.env` files solve, coming up.

## Reading a variable from your code

Every language has a built-in way to read the environment. You don't import a library for this part - the
variables are just *there* for any program. A few of the common ones:

```text
   Python      os.environ["DATABASE_URL"]        # or os.environ.get(...) for a safe default
   Node.js     process.env.DATABASE_URL
   Ruby        ENV["DATABASE_URL"]
   Go          os.Getenv("DATABASE_URL")
   Rust        std::env::var("DATABASE_URL")
```

Here's the shape it takes in practice (Python), and what it does when you run it:

```console
$ export GREETING=hello
$ python3 -c "import os; print(os.environ.get('GREETING', 'default'))"
hello
$ python3 -c "import os; print(os.environ.get('MISSING', 'default'))"
default
```
*What just happened:* The first call read `GREETING` from the environment we just exported. The second
asked for `MISSING`, which we never set - but because we used `.get()` with a fallback of `'default'`, it
printed that instead of crashing. That fallback pattern is how you give a setting a sensible default while
still letting the environment override it.

⚠️ **Gotcha - everything is a string.** A variable read from the environment is always text. Set
`PORT=8080` and need a number? Convert it (`int(os.environ["PORT"])` in Python) - forgetting this gives
confusing errors like trying to do math on the string `"8080"`.

## `.env` files - sane local development

**Why people get this wrong.** Setting variables by hand with `export` works, but it's miserable daily:
the values vanish when you close the terminal, and a real app might need a dozen of them. Nobody wants to
re-type twelve `export` lines every morning. There's a standard fix.

**What it actually is.** A **`.env` file** (pronounced "dot-env") is a plain text file in your project,
one `NAME=value` per line, that a small library loads into the environment when your app starts. It's a
convenience: instead of exporting variables by hand, you write them down once.

A typical `.env` looks like this:

```text
DATABASE_URL=postgres://localhost:5432/myapp_dev
LOG_LEVEL=debug
API_KEY=sk_test_abc123
PORT=8080
```

To load it, you add a tiny library - `python-dotenv` for Python, `dotenv` for Node, and so on. In Python:

```console
$ pip install python-dotenv
$ python3 -c "from dotenv import load_dotenv; import os; load_dotenv(); print(os.environ['LOG_LEVEL'])"
debug
```
*What just happened:* `load_dotenv()` found the `.env` file, read each line, and placed those pairs into
the process environment - exactly as if you'd `export`ed each by hand. `os.environ['LOG_LEVEL']` then
reads `debug` from the file. Your code doesn't change; it still just reads environment variables. The
`.env` file only *populates* them.

💡 **Key point.** The `.env` file is a **development** convenience. In staging and production you usually
*don't* ship one - the hosting platform (or container, or secrets manager) sets the real environment
variables directly. Your code reads `os.environ[...]` the same way everywhere; only the *source* of those
values changes. That's the payoff from [Phase 1](01-why-config-lives-outside-code.md): one codebase,
different surroundings.

## ⚠️ Never commit your `.env`

This is the rule that gets its own heading because getting it wrong is genuinely costly.

Your `.env` file holds the real values for *your* machine - and on real projects, that includes
**secrets**: API keys, database passwords, tokens. Commit it to Git and those secrets go into the
repository's history, visible to everyone with access and effectively impossible to erase (deleting the
file in a later commit doesn't remove it from history).

So you tell Git to ignore it. Add this one line to a file named `.gitignore` in your project:

```text
.env
```

Then confirm Git is actually ignoring it:

```console
$ git status
On branch main
nothing to commit, working tree clean
```
*What just happened:* Even though `.env` exists on disk, it doesn't appear in `git status` - `.gitignore`
told Git to pretend it isn't there, so you can't accidentally stage or commit it. If `.env` *did* show up,
it isn't ignored yet - fix that before your next commit.

📝 **The `.env.example` convention.** Since `.env` itself is secret and uncommitted, projects commit a
companion **`.env.example`** instead - it lists the *names* every variable needs, with fake or blank
values, so a new teammate knows what to fill in:

```text
DATABASE_URL=
LOG_LEVEL=debug
API_KEY=your_test_key_here
PORT=8080
```

When you clone a project, the ritual is: copy `.env.example` to `.env`, then fill in the real values.
That's the `is not set` error from the start of this guide, solved.

This is only the *first* layer of keeping secrets safe. Storing and distributing production secrets
properly (vaults, encrypted files, cloud secret managers) is its own topic: [Secrets
Management](/guides/secrets-management).

**Why this saves you later.** Reading config from the environment means the *same code* works on your
laptop with a `.env` file and in production with platform-injected variables - and keeping `.env` out of
Git means a leaked laptop or a public repo doesn't hand an attacker your production keys.

## Recap

1. An **environment variable** is a `NAME=value` pair the OS gives your running program; your code reads
   it by name (`os.environ`, `process.env`, `ENV[...]`, etc.).
2. Read one from the shell with `echo $NAME` (Unix), `$env:NAME` (PowerShell), or `%NAME%` (cmd). An
   unset variable reads as **empty**, which is what `is not set` errors come from.
3. Everything is a **string** - convert ports and numbers yourself.
4. A **`.env` file** loads many variables at once for local development; a small library reads it into the
   environment so your code doesn't change.
5. **Never commit `.env`** - add it to `.gitignore`. Commit a `.env.example` listing the variable names
   instead.

Next: when a handful of variables isn't enough and you need structured, nested config - YAML and friends.


---

# Config Files: YAML & Friends

Environment variables are perfect for a handful of flat values. But open a real project and you'll find a
`config.yaml` or `appsettings.json` describing whole *structures* - a database section with host, port,
pool size, a list of allowed origins, nested feature flags. Cramming that into `DATABASE_HOST`,
`DATABASE_PORT`, `DATABASE_POOL_SIZE` flat variables gets ugly fast. This is where structured config files
earn their place - read one without fear, then settle *which setting wins* when the same thing is defined
twice.

## Why a config file instead of more env vars

**What it actually is.** A **config file** is a text file that describes settings in a structured,
nested way - sections within sections, lists, grouped values. Where an environment variable is one flat
name and value, a config file can express *shape*: "the database section contains a host and a port."

**What it does in real life.** Keep the structural, non-secret settings in a file committed to the repo
(so the team shares them), and keep per-machine values and secrets in environment variables. The file is
the skeleton; env vars fill in the parts that differ or must stay private.

The three formats you'll meet most:

| Format | Looks like | Strengths | Watch out for |
|---|---|---|---|
| **YAML** | indentation-based | very readable, common for app & infra config | indentation is significant; tabs forbidden |
| **JSON** | braces & quotes | universal, every language reads it | no comments; trailing commas are errors |
| **TOML** | `key = value` + `[sections]` | clear, hard to mess up | less common for app config |

None is "best" - teams pick by convention and tooling. YAML is the one most likely to trip you up, so
we'll spend the most time there.

## Reading YAML

**What it actually is.** **YAML** ("YAML Ain't Markup Language") stores nested data using **indentation**
to show what belongs inside what - the way an outline uses indentation. A `key: value` pair is one
setting; indenting pairs under a key groups them into a section.

📝 **Terminology.** Here are the pieces, named:
- A **key-value pair**: `key: value` (note the space after the colon - it's required).
- A **mapping** (section): a key whose value is a group of indented pairs underneath it.
- A **list**: items each starting with `- ` (dash, space).

Here's an annotated example - read it top to bottom:

```text
# config.yaml - a '#' starts a comment, ignored by the parser

app_name: My Service          # a top-level key-value pair (a string)
port: 8080                    # a number - no quotes needed for plain numbers
debug: false                  # a boolean: true or false

database:                     # a key with NOTHING after the colon = a section...
  host: localhost             # ...these indented lines belong INSIDE 'database'
  port: 5432                  # 'database.port' - distinct from the top-level 'port'
  pool_size: 10

allowed_origins:              # a key whose value is a LIST
  - https://example.com       # each '- ' is one list item
  - https://app.example.com
```

*What this describes:* a service named "My Service" on port 8080 with debug off; a `database` section
holding its own host, port, and pool size; and a two-item list of allowed origins. The indentation is
doing all the structural work - `host` is "inside" `database` purely because it's indented under it.

Loading it in code gives you ordinary nested data. In Python:

```console
$ pip install pyyaml
$ python3 -c "import yaml; c = yaml.safe_load(open('config.yaml')); print(c['database']['host'])"
localhost
```
*What just happened:* `yaml.safe_load` read the file into a nested dictionary, so
`c['database']['host']` walks into the `database` section and pulls out `host`. The structure in the file
is exactly the structure you get in code. (Use `safe_load`, not plain `load` - that can execute arbitrary
tags from an untrusted file.)

## ⚠️ The YAML indentation gotcha

This is the one that bites *everybody*, usually at the worst possible time. In YAML, **indentation is
meaning**, and there are two ways to get it wrong.

**First: tabs are forbidden.** YAML requires **spaces** for indentation - a literal Tab character is not
allowed and the parser will reject the file. The cruel part is that a tab and a few spaces can look
*identical* on screen. You'll swear the indentation is right because your eyes can't see the difference.

```console
$ python3 -c "import yaml; yaml.safe_load(open('config.yaml'))"
yaml.scanner.ScannerError: while scanning for the next token
found character '\t' that cannot start any token
  in "config.yaml", line 5, column 1
```
*What just happened:* The parser hit a Tab character (`\t`) where it expected spaces and stopped cold.
Fix: set your editor to insert spaces on Tab (most have an "indent using spaces" setting), then replace
the offending tab - the error message even tells you the line.

**Second: inconsistent depth changes the meaning.** Because indentation defines what's nested inside what,
a single misaligned space silently restructures your config:

```text
database:
  host: localhost
   port: 5432          # ← one extra space: this line is now MORE indented than 'host'
```

Depending on the parser this either errors or - worse - produces a structure you didn't intend, and your
app reads the wrong shape with no crash. The defense: **pick one indent width (two spaces is common) and
keep every level perfectly consistent.**

💡 **Key point.** If a YAML file mysteriously won't load or your settings come out wrong, suspect the
indentation *first*. Nine times out of ten it's a stray tab or a misaligned line, not a bug in your code.

## Config precedence - which setting wins

Now the question that confuses people once they're using both files *and* environment variables: if the
same setting is defined in two places, **which one does the app actually use?**

**What it actually is.** **Precedence** is the order, decided by the application, in which config sources
override each other. The widely-used convention, from lowest priority to highest, is:

```mermaid
flowchart LR
  Defaults[Defaults in code<br/>lowest priority] -->|overridden by| File[Config file]
  File -->|overridden by| Env[Environment variable<br/>highest - wins]
```

Read it as a stack: the code ships with sensible **defaults**; a **config file** overrides those for the
project; and an **environment variable** overrides even the file. The most specific, most external source
wins - which is exactly what you want, because the environment is where per-deployment and secret values
come from.

Here's the rule in action. Say your code defaults `port` to `3000`, your `config.yaml` sets it to `8080`,
and you launch with an environment variable:

```console
$ echo "port: 8080" > config.yaml
$ PORT=9000 python3 -c "
import os, yaml
cfg = {'port': 3000}                       # 1. default in code
cfg.update(yaml.safe_load(open('config.yaml')))   # 2. file overrides default
if 'PORT' in os.environ:                    # 3. env var overrides file
    cfg['port'] = int(os.environ['PORT'])
print(cfg['port'])
"
9000
```
*What just happened:* All three layers set `port`, and you watched the precedence play out: the default
`3000` was overwritten by the file's `8080`, which was overwritten by the environment's `9000`. The
environment variable won because it sits highest in the stack. Run it again *without* `PORT=9000` and
you'd get `8080` (the file); delete the file too and you'd get `3000` (the default).

**Why people get this wrong.** Without knowing the precedence, you'll change a value in the config file,
see no effect, and lose an hour - an environment variable was quietly overriding it the whole time. Most
config libraries follow defaults < file < env, but **not all** - check your framework's docs for its
exact order rather than assuming.

**Why this saves you later.** Understanding the stack means that when a setting "won't change," you know
immediately where to look: start from the highest-priority source (the environment) and work down. And it
explains the whole design - defaults keep the app runnable out of the box, the file holds shared project
settings, and the environment has the final say for whatever this particular deployment needs.

## Recap

1. **Config files** (YAML, JSON, TOML) hold structured, nested settings that flat environment variables
   express awkwardly; commit the non-secret ones, keep secrets in env vars.
2. **YAML** uses indentation to show nesting: `key: value` pairs, indented **mappings** for sections,
   `- ` for **list** items.
3. The **indentation gotcha**: spaces only (never tabs), and keep every level consistently aligned - a
   stray tab or misaligned line breaks the file or silently changes its meaning. Suspect indentation
   first.
4. **Precedence** is `defaults < config file < environment variable` - the most external source wins.
   When a setting won't change, check the highest-priority source first. Confirm the exact order in your
   framework's docs.

You now have the full picture: *why* config lives outside code, *how* environment variables and `.env`
files work, and *how* structured files and precedence fit together. From here, the natural next step is
handling production secrets properly - see [Secrets Management](/guides/secrets-management).
