How to Use Environment Variables Across Dev and Production

How to Use Environment Variables Across Dev and Production

Environment variables let the same codebase run on your laptop, on staging and on the live host without anyone editing a config file. You put the values that differ between machines (database credentials, API keys, cache hosts, debug flags) into the process environment, and you read them from inside the application through a single small accessor. The code stays identical; only the environment it wakes up in changes.

Why environment variables development production separation matters

Every project starts with a hardcoded connection string and ends up regretting it. The moment a second machine exists, a literal in the source becomes a bug: the developer's local database password ships to production, or the production credentials get committed to a public repository. Reading configuration from the environment fixes the shape of the problem, because the source tree no longer contains any machine specific value at all.

The second reason is deployment. If your application reads its database host from the environment, promoting a build from staging to production is a matter of starting the same artifact with different variables set. Nothing is rebuilt, nothing is re-tested, and there is no chance that a "just change this one line before deploying" step gets forgotten.

The cost is that the environment is invisible. Nothing in the repository tells a new developer what variables the app expects. That gap is what the rest of this article is about.

Reading variables in your code

In a shell, the environment is a table of name and value pairs handed to every process you start. A child process inherits a copy of its parent's environment. That inheritance is the whole mechanism, and it explains the two most common surprises: a variable you exported in one terminal is missing in another, and a variable you set in a service manager never reaches a process started by hand.

Reading one is trivial in most languages. In Python, os.environ is a mapping and os.environ["DATABASE_URL"] raises KeyError when the name is absent, while os.environ.get("DATABASE_URL") returns None. In Node, it is process.env.DATABASE_URL. In Go, os.Getenv returns an empty string for a missing variable and os.LookupEnv tells you whether it was set at all. That last distinction matters more than it looks.

Prefer the strict form. A missing configuration value should stop the process at startup with a clear message, not produce a None that travels three layers deep and fails as a confusing type error. Validate every required variable once, at boot, and exit with a non zero status if one is absent. Many languages have a small library that does this, but a dozen lines of your own code is usually clearer.

Two rules keep this from turning into a mess. Read each variable exactly once, at module load or in a single settings object, so the rest of the code depends on a typed value rather than on the raw string. And keep the names stable and uppercase, because on Windows the environment is case insensitive while on Linux it is not, and a lowercase name that works on your laptop will quietly fail in a container.

$ DATABASE_URL=postgres://localhost/app DEBUG=1 python manage.py runserver
$ echo $DATABASE_URL
postgres://localhost/app

That prefix form sets the variable only for the duration of the command. It is the cleanest way to test a single override without polluting your shell for the rest of the session.

The .env file and what belongs in it

Typing variables before every command gets old, so most projects keep a local file, conventionally .env, holding the development values. A small loader reads it at startup and merges it into the process environment. The file must be listed in .gitignore, always, without exception. It contains real credentials for your local database and often a development API key, and committing it is the single most common way secrets leak.

What you commit instead is a template, usually called .env.example, with the same keys and empty or obviously fake values. It is documentation that cannot drift, because a missing key in it is visible in review. When someone adds a variable to the code, they add the key to the template in the same change.

# .env.example
DATABASE_URL=
REDIS_URL=
SESSION_SECRET=
LOG_LEVEL=info

Be careful about precedence. A loader that overwrites existing environment values will clobber whatever the real deployment sets, which can silently point a staging process at a developer's local database. The safer convention, and the one most loaders follow by default, is to let a variable already present in the environment win, and only fill in what is missing.

Also resist the temptation to put everything in there. Values that are the same on every machine, such as a fixed page size or a protocol version, belong in code where they can be reviewed and typed. The environment is for what genuinely varies, and for secrets.

Setting variables on a server

On a remote machine you have three practical options, and the choice is mostly about who can read the values.

Exporting them from a shell profile or a unit file works, but the values end up readable by anyone who can read that file, and they are easy to lose track of. A process manager such as systemd can point at a file with EnvironmentFile=/etc/myapp/env, which keeps the values out of the unit definition and lets you set restrictive permissions on the file. That is a reasonable default for a single server.

A secret manager is the better answer once more than one machine needs the same credential. The application asks the manager for the value at startup, authenticated with a role or token rather than a static password, and the value never sits in a file on disk. The tradeoff is an extra dependency at boot: if the manager is unreachable, your process must fail loudly rather than start with an empty string.

Whatever you choose, set the variables before the process starts rather than fetching them lazily during a request. A configuration read that happens inside a request handler can fail at the worst possible moment, and it makes the set of required variables impossible to see in one place.

Keeping dev and production honest

The failure mode to guard against is drift. A variable exists in production and not in the template, or the template lists a name that was renamed months ago. Both are invisible until something breaks.

Give the application a startup check that compares the variables it actually reads against the keys in .env.example, and run that check in continuous integration. It costs almost nothing and catches the whole class of problem. A second useful check is a schema for the values themselves: assert that PORT parses as an integer and that LOG_LEVEL is one of a known set, so a typo fails at boot instead of at the first log line.

Finally, treat the environment as part of the interface. If a variable is required, say so in the template and in the deployment documentation. If it is optional, give it a default in code and write the default down. The next person to deploy will thank you, and that person is often you.

What to do next

Pick one project and move a single hardcoded value, the database URL, into the environment. Add a .env file for local work, a .env.example template to the repository, and a startup check that fails when a required name is missing. Once that path works end to end, repeat it for the rest of the configuration, one value at a time. The pattern is small enough to learn in an afternoon and it will outlast every framework you use it with.

Related articles

Subscribe to our newsletter

Get the latest hosting tips, performance insights, and industry news.