Here’s a small mystery that trips up almost everyone at first. You write one codebase. You run it on your laptop and it connects to a database on your laptop. You copy that exact same code onto a live server, and somehow it connects to a completely different database, uses different passwords, and points users at a different web address. Nothing in the code changed. So how does the app know which set of values to use?
The answer is environment configuration — the practice of keeping all the values that change between machines outside of your code, and feeding them in from the outside. Once this idea clicks, a whole class of deployment headaches disappears, and your app gets dramatically safer in the process. Let’s build it up from the ground.
What “configuration” actually means
When people say config, they mean the settings your program needs to run, but that aren’t really part of the program’s logic. Think about the difference between these two things:
- The logic — “when a user signs up, save their record to the database.” That’s the same everywhere. It belongs in the code.
- The config — which database to save to, what password to use, which email service to send through, what URL the site lives at. These change depending on where the app is running.
Configuration is everything in that second bucket. It’s not the recipe; it’s the specific ingredients you happen to have in this particular kitchen. A useful test: if a value would be different on your laptop versus on the production server, it’s almost certainly config — not code.
Common things that live in config:
- Database connection details (host, port, name, username, password)
- API keys and secret tokens for third-party services
- The public URL or domain of your site
- Email/SMTP server settings
- Feature flags (turn a half-finished feature on or off)
- The current environment’s name itself (
development,staging,production)
Why config must live outside your code
It is tempting, when you’re starting out, to just type the values straight into your source files. The database password goes right there in database.js. It works on your machine, so why not? There are three solid reasons not to, and each one bites eventually.
1. The same code must run in many places. Your laptop, a teammate’s laptop, a test server, and the live production server are four different environments, each with its own database and its own credentials. If the values are baked into the code, you’d need four different copies of the code — and they’d drift apart, and you’d ship the wrong one. (If the words “development”, “staging”, and “production” aren’t fully clear yet, the earlier piece on deployment environments walks through exactly what each one is for.)
2. Secrets must never end up in version control. When you commit code to Git, that history is forever and often shared. A password or API key hardcoded into a file gets copied to every clone, every fork, every backup. People have leaked real production credentials this way and had their cloud accounts drained within minutes by bots that scan public repositories. Config kept outside the code never enters that history in the first place.
3. Changing a setting shouldn’t mean changing code. If your database password rotates, you don’t want to edit a source file, re-test, and redeploy the whole application just to update one string. With config on the outside, you change the value where it lives and restart — the code itself is untouched.
The classic, costly mistake
The single most common beginner security incident is committing a secret to a public repository. Once a key has been pushed — even if you delete it in the very next commit — assume it is compromised and rotate it (generate a new one, retire the old). Git remembers deleted lines in its history. The fix isn’t to scrub the history and hope; it’s to make sure secrets are never committed in the first place, which is exactly what good environment configuration gives you.
Environment variables: the universal mechanism
So if config lives outside the code, where exactly, and how does the code read it? The most universal answer — one that works on every operating system, in every language, on every hosting platform — is the environment variable.
An environment variable is just a named value that the operating system hands to a program when it starts. The program asks the OS, “what’s the value of DATABASE_URL?” and the OS gives back whatever was set for it. Your code never contains the value itself — only the name of the variable it wants to look up.
On a standard Linux or macOS shell, you set one like this and run your program in the same breath:
# set a variable just for this one command
DATABASE_URL="postgres://localhost/myapp_dev" node server.js
# or export it so every command in this shell session can see it
export DATABASE_URL="postgres://localhost/myapp_dev"
export SECRET_KEY="dev-only-not-a-real-secret"
node server.js
Inside the program, you read it by name. The exact syntax depends on your language, but the idea is identical everywhere:
JavaScript / Node process.env.DATABASE_URL
Python os.environ["DATABASE_URL"]
PHP getenv("DATABASE_URL")
Ruby ENV["DATABASE_URL"]
Go os.Getenv("DATABASE_URL")
Notice what’s powerful here: the code is identical on every machine. It always just asks for DATABASE_URL. What differs is the value the operating system has set — myapp_dev on your laptop, the real production database on the live server. Same code, different environment, different behaviour. That’s the whole trick.
┌──────────────────────────────────────────┐
│ YOUR CODE (the same everywhere) │
│ │
│ db = connect( env["DATABASE_URL"] ) │
└──────────────────┬───────────────────────┘
│ asks the OS for the value
┌────────────────┴─────────────────┐
▼ ▼
ON YOUR LAPTOP ON PRODUCTION
DATABASE_URL = DATABASE_URL =
postgres://localhost/dev postgres://prod-host/live
The .env file: a friendlier way to manage them
Typing export for fifteen variables every time you open a terminal gets old fast. The common solution during development is a .env file — a plain text file that lists your variables, one per line, in KEY=value form:
# .env (lives in your project folder, NEVER committed)
DATABASE_URL=postgres://localhost/myapp_dev
SECRET_KEY=dev-only-not-a-real-secret
PORT=3000
SMTP_HOST=localhost
FEATURE_NEW_CHECKOUT=false
A small library in your language (or a feature of your framework) reads this file when the app boots and loads each line into the environment, exactly as if you’d exported them by hand. From your code’s point of view, nothing changes — it still just reads env["DATABASE_URL"]. The .env file is purely a convenience for setting the values in one tidy place.
The one rule you must never break: the .env file does not go into version control. Add it to your .gitignore immediately, before you even create it. Then, so teammates know what variables exist without seeing your actual secrets, commit a template file instead:
# .env.example (this one IS committed — values are blank or fake)
DATABASE_URL=
SECRET_KEY=
PORT=3000
SMTP_HOST=
FEATURE_NEW_CHECKOUT=false
A new developer copies .env.example to .env, fills in real values, and they’re running — without anyone ever sharing live secrets through Git.
Names tell a story — use a convention
Environment variable names are written in UPPER_SNAKE_CASE by long-standing convention, so DATABASE_URL, not databaseUrl. Keep them descriptive and group them by prefix when it helps — SMTP_HOST, SMTP_PORT, SMTP_USER read as a clear family. A consistent naming scheme makes your .env.example double as documentation: a new teammate can glance at it and understand everything the app needs to run.
How config differs across environments
The real point of all this is that each environment plugs in its own values. The shape of the config (the list of variable names) stays the same; only the values change. Here’s the same app, three places:
VARIABLE DEVELOPMENT STAGING PRODUCTION
─────────────────────────────────────────────────────────────────────────────
DATABASE_URL localhost/myapp_dev staging-db/myapp prod-db/myapp
SECRET_KEY dev-only-fake (real staging key) (real prod key)
SITE_URL http://localhost:3000 https://staging.site https://www.site
LOG_LEVEL debug info warn
SEND_REAL_EMAILS false false true
ENVIRONMENT development staging production
A few things worth noticing in that table. The development row uses fake secrets and a local database on purpose — nothing real should ever touch a developer’s laptop. The SEND_REAL_EMAILS flag is off until production, so testing never accidentally emails real customers. And LOG_LEVEL is chattier in development (where you want to see everything) and quieter in production (where noise costs money and hides real problems). Same code reads LOG_LEVEL everywhere; the value decides the behaviour.
One variable earns special mention: the one that names the current environment, often called NODE_ENV, APP_ENV, or just ENVIRONMENT. Your code and frameworks frequently check it to decide things like whether to show detailed error pages (helpful in development, a security risk in production) or whether to cache aggressively. Setting it correctly per environment is small but important.
Where production values actually come from
If you never commit your .env file, a fair question is: how do the right values get onto the production server, where there’s no developer typing export? You generally have three options, and real teams use a mix.
- Set them on the server / platform. Most hosting platforms and process managers let you define environment variables in their dashboard or config, separate from your code. The platform injects them into your app when it starts. This is the cleanest approach for cloud hosting — secrets live in the platform, never in the repo.
- A
.envfile placed on the server by hand or by your deploy process. The file exists only on that machine, created during deployment, with strict file permissions so only your app’s user can read it. It’s still never in Git; it’s copied or generated at deploy time. - A dedicated secrets manager. For bigger or more security-sensitive setups, a separate service stores secrets, controls who can read them, and logs every access. The app fetches its config from there at startup. This is overkill for a small project but standard at scale.
Whichever you choose, the golden rule holds: the values reach the running process as environment variables, the code reads them the same universal way, and the secrets themselves never live inside the source code.
Config is closely related to environment variables in general
Everything here is a deployment-focused view of a more general Linux/OS concept. If you want the lower-level picture — how the operating system stores these variables, how export and shell sessions actually work, and how child processes inherit them — the dedicated article on environment variables in the Linux section goes a layer deeper than we need to here.
A few habits that save you pain
None of this is complicated, but a handful of habits separate a smooth setup from a leaky, fragile one:
.gitignorethe.envfile first, every time. Make it the very first thing you do in a new project, before any secret could possibly slip in.- Commit a
.env.examplewith blank or fake values. It documents what’s needed and lets teammates get started without a side-channel password exchange. - Never log secrets. It’s easy to accidentally print your whole config to a log file while debugging. Logs get shared and stored; treat them as if anyone might read them.
- Fail loudly if a required variable is missing. Have your app check on startup that the variables it needs are present, and refuse to boot with a clear error if one is absent. A crash at startup beats a mysterious failure at 3 a.m.
- Rotate anything that leaks. If a secret ever reaches a place it shouldn’t — a commit, a chat, a screenshot — generate a new one and retire the old. Assume the leaked one is now public.
Wrapping up
Here’s the whole idea in one place:
- Configuration is everything your app needs that changes from machine to machine — database details, keys, URLs, flags — as opposed to the logic, which stays the same.
- Config must live outside your code so one codebase runs in many environments, so secrets never enter version control, and so changing a setting doesn’t mean changing code.
- Environment variables are the universal way to feed config in: the code reads a value by name from the operating system, and that value differs per environment while the code stays identical.
- A
.envfile is a convenient way to set those variables during development — but it must be git-ignored, paired with a committed.env.exampletemplate that carries names but no real secrets. - In production, the values come from the hosting platform, a server-side file, or a secrets manager — never from the repository.
- Build the safe habits early: ignore
.env, never log or commit secrets, fail loudly on missing variables, and rotate anything that leaks.
With configuration handled cleanly, your app is portable and your secrets are safe — which is exactly the footing you want before the next big deployment concern: getting a real domain to serve traffic securely over HTTPS, which is where SSL certificates in practice come in.