Most applications begin on a developer's machine. You build the application, run the services it depends on, connect it to a database, and get everything working locally.
Then the application has to run somewhere else.
It might move to another developer's system, a staging server, or production. Ideally, the same application build should be able to run in each place. What changes is the system around it: database connections, credentials, service addresses, ports, and other values that depend on where the application is running.
We generally call each of these setups an environment.
Your local machine is one environment. Staging is another. Production is another.
The question is how we design the application so that it can move between these environments without changing the code every time.
I'll use Node.js for the examples here, but the underlying idea applies across languages, frameworks, containers, and deployment platforms.
Start with a value that changes
Suppose our application needs to connect to a database.
When working locally, we already know where that database is. It can be tempting to put the value directly into the code:
const databaseUrl = "postgres://localhost:5432/my-app";
That gets the application running locally, but the value only describes our machine.
Staging will have a different database, and production will have another. If we change the source code every time the application moves between environments, then deployment-specific information has become part of the application itself.
A natural next step might be to move the value into a configuration object:
export const config = {
databaseUrl: "postgres://localhost:5432/my-app"
};
That is better organized, but the underlying problem remains.
The database URL is still stored in the source code. We have centralized the value, but we have not separated it from the application.
What we want instead is for the application to know that it needs a database URL without deciding what that URL should be.
That value should come from the environment in which the application is running.
Let the environment provide the value
Instead of defining the database URL inside the application, we can ask the environment for it.
Conceptually:
const databaseUrl = environment.DATABASE_URL;
Now the responsibility is clearer.
The application declares that it needs DATABASE_URL. The environment in which it runs provides the appropriate value.
On a developer's machine, that might be:
DATABASE_URL=postgres://localhost:5432/my-app
Staging provides its own value. Production provides another.
The application keeps asking for the same configuration in every environment.
Values that change between systems no longer have to be stored in the source code. The same application can run in different places while receiving the configuration appropriate for each one.
That is the boundary we are trying to create:
the application defines what information it needs, while the environment provides the values appropriate for where it is running.
Environment variables are one way to create that boundary
Operating systems already provide processes with a mechanism for receiving values from their surrounding environment: environment variables.
A system can provide:
DATABASE_URL=...
and in Node.js the application can read it through:
process.env.DATABASE_URL
The application does not need to know whether the value was supplied through a shell, Docker, Kubernetes, a deployment platform, or another configuration system.
It only needs to know the name of the value it expects and how to use it.
Environment variables therefore give us a simple interface between an application and the system running it.
They are not the only way to supply configuration, but they are one of the most common.
Then what is a .env file?
During local development, manually setting environment variables every time we start the application would quickly become inconvenient.
This is where a **.env file** is useful.
For example, a local development .env file might contain:
DATABASE_URL=postgres://localhost:5432/my-app
PORT=3000
LOG_LEVEL=debug
A library, runtime, or framework can read this file and make those values available to the application.
The application still reads:
process.env.DATABASE_URL
It does not need to know that the value originally came from a .env file.
A .env file is therefore not the configuration architecture itself. It is simply one convenient way of supplying configuration, particularly during development.
When the application reaches production, there may be no .env file at all.
How does the same idea work in deployment?
On a server, environment variables can be defined before the application starts. Deployment platforms usually provide a configuration interface where those values can be stored and supplied at runtime.
Containers follow the same model.
The container image contains the application, while configuration is supplied when the container is started.
For example, a Docker environment file used when starting a container might contain:
DATABASE_URL=postgres://database:5432/my-app
PORT=3000
LOG_LEVEL=info
The same container image could be started locally, in staging, or in production with different values without changing the application inside it.
Platforms such as Kubernetes and cloud providers provide their own ways of managing and supplying configuration. Sensitive values such as passwords or API keys may also come from a secret-management system rather than being stored directly in deployment files.
Moving a secret out of source code does not automatically make it secure. Environment variables and .env files are ways of supplying values; protecting sensitive values is handled separately by the system responsible for storing and managing them.
The delivery mechanism can change while the application continues to ask for the same configuration.
The application still needs to understand its configuration
Once configuration comes from outside the application, the application still needs to verify that what it received is usable.
If the application requires a database and DATABASE_URL is missing, it is better to fail when the application starts than to discover the problem later when something tries to access the database.
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error("DATABASE_URL is required");
}
As the application grows, reading values directly from process.env throughout the codebase also becomes difficult to manage.
A dedicated configuration layer gives us one place to read, validate, and normalize those values.
For example:
export const config = {
databaseUrl: process.env.DATABASE_URL
};
The rest of the application can then use:
config.databaseUrl
instead of depending directly on process.env.
In a larger application, this layer might also validate URLs, convert strings into numbers or booleans, apply defaults where appropriate, and stop the application during startup when required configuration is missing.
The environment provides the raw values, and the configuration layer turns them into something the rest of the application can safely use.
What if the application needs to know which environment it is running in?
Sometimes the environment itself is useful information.
A Node.js application, for example, might receive:
NODE_ENV=production
or an application might define something more explicit:
APP_ENV=staging
There is nothing special about these values. They are configuration too.
If the application needs to behave differently in development, staging, or production, the environment can tell it where it is running in the same way it provides a database URL, port, log level, or any other value.
The important part is that the environment supplies that information rather than requiring the application code to be changed for each deployment.
What we are really designing
The process we have gone through is a progression.
We begin with a value embedded directly in the application:
Application
└── database URL
We might then move that value into a configuration module:
Application
└── configuration module
└── database URL
That improves organization, but the application still owns the value.
External configuration changes the relationship:
Environment
↓
Configuration
↓
Application configuration layer
↓
Rest of the application
Locally, configuration might come from a .env file. A Docker container might receive it through an environment file or container configuration. A server, Kubernetes cluster, or cloud platform might provide it through its own deployment system.
The source of those values can change without changing what the application expects.
The application defines what it needs to run. The environment provides the appropriate values.
Once those responsibilities are separated, the same application can move between development, staging, and production without carrying the details of each environment inside its source code.
Top comments (0)