How to Manage Environment Variables Across Development, Staging, and Production
Environment variables are one of those things that look simple at the beginning of a project and become increasingly difficult to manage as the project grows.
At first, you might have a single .env file:
DATABASE_URL=postgresql://localhost:5432/myapp
JWT_SECRET=my-secret
API_URL=http://localhost:3000
That works perfectly for local development.
Then you deploy the application.
Now you have production variables.
A few weeks later, you add a staging environment.
Then another developer joins the project.
Then you need different API keys, database URLs, feature flags, email providers, storage credentials, analytics tokens, and third-party integrations depending on the environment.
Suddenly, environment variables are no longer just a .env file.
They become part of your application's configuration strategy.
In this article, we'll look at how to manage environment variables across development, staging, and production, what commonly goes wrong, and some practical ways to make the workflow more maintainable.
What are environment variables?
Environment variables are key-value pairs used to configure an application without hardcoding configuration directly into the source code.
For example:
PORT=3000
DATABASE_URL=postgresql://localhost:5432/myapp
NODE_ENV=development
Your application can then read those values at runtime.
In Node.js:
const port = process.env.PORT
const databaseUrl = process.env.DATABASE_URL
This separation is useful because the same application code can run in different environments with different configuration values.
The code stays the same.
The configuration changes.
Why do we use environment variables?
Imagine hardcoding your database connection:
const databaseUrl = 'postgresql://localhost:5432/myapp'
This might work locally, but your production database will obviously use another URL.
You could technically write something like:
const databaseUrl =
process.env.NODE_ENV === 'production'
? 'postgresql://production-server/myapp'
: 'postgresql://localhost:5432/myapp'
But this quickly becomes difficult to maintain.
It also creates a much bigger problem when credentials are involved.
You should never hardcode secrets such as:
API keys
database passwords
JWT secrets
OAuth credentials
SMTP passwords
cloud credentials
payment provider keys
inside your source code.
Environment variables allow configuration and secrets to stay separate from your application logic.
The three common environments
Most modern applications have at least three environments:
- Development
- Staging
- Production
Each has a different purpose.
Development
Development is where developers build and test the application locally.
Typical values might look like:
NODE_ENV=development
API_URL=http://localhost:3000
DATABASE_URL=postgresql://localhost:5432/myapp_dev
LOG_LEVEL=debug
The development environment usually prioritizes convenience and debugging.
For example, you might enable:
DEBUG=true
LOG_LEVEL=debug
while disabling certain production-only integrations.
Staging
Staging is an environment designed to behave as closely as possible to production without actually affecting production users.
Example:
NODE_ENV=staging
API_URL=https://staging-api.example.com
DATABASE_URL=postgresql://staging-db/myapp
LOG_LEVEL=info
Staging is useful for:
- integration testing
- QA
- testing deployment pipelines
- testing third-party integrations
- validating migrations
- verifying production-like configuration
Ideally, the staging environment should be structurally similar to production.
The actual credentials and resources should still be different.
Production
Production is the environment used by real users.
For example:
NODE_ENV=production
API_URL=https://api.example.com
DATABASE_URL=postgresql://production-db/myapp
LOG_LEVEL=warn
Production configuration should receive the most protection because it often contains sensitive credentials.
Access should be restricted to people and systems that actually need it.
The .env file
A common way to manage environment variables locally is through a .env file.
Example:
PORT=3000
DATABASE_URL=postgresql://localhost:5432/myapp
JWT_SECRET=super-secret-value
Libraries such as dotenv can load these values into the runtime.
For Node.js:
npm install dotenv
Then:
import 'dotenv/config'
console.log(process.env.DATABASE_URL)
Frameworks like Next.js, NestJS, Laravel, Rails, Django, and many others provide their own approaches or integrations for managing environment variables.
Never commit your real .env file
One of the most important rules when working with environment variables is:
Do not commit files containing secrets to your Git repository.
Your .gitignore should normally contain something like:
.env
.env.local
.env.development.local
.env.production.local
Otherwise, credentials could accidentally become part of your repository history.
And removing a secret from the latest commit does not necessarily remove it from previous commits.
If a secret is accidentally committed, the safest approach is usually to rotate it.
Use .env.example
While the real .env file should not normally be committed, an .env.example file can be extremely useful.
Example:
PORT=
DATABASE_URL=
JWT_SECRET=
API_URL=
This file documents which environment variables the project expects without exposing their actual values.
A developer cloning the project can run:
cp .env.example .env
and then fill in the required values.
This is much better than making a new team member discover missing environment variables one runtime error at a time.
The problem with multiple .env files
For small projects, you might start with:
.env
Then add:
.env.development
.env.staging
.env.production
This can work well.
But there is an important limitation:
you still need a reliable way to distribute the correct values.
For example, suppose three developers work on the same application.
Developer A creates:
STRIPE_SECRET_KEY=...
Developer B needs the same value.
What happens next?
Many teams end up sending environment variables through:
- Slack
- Discord
- Notion
- text files
- shared documents
This creates several problems.
Values become duplicated.
Nobody knows which version is current.
Old secrets remain in message history.
New team members may not know which variables they need.
And production credentials may accidentally be shared with people who only need development access.
Keep environment structure consistent
A good approach is to keep the same variable names across environments whenever possible.
For example:
Development
DATABASE_URL=postgresql://localhost/myapp
API_URL=http://localhost:3000
Staging
DATABASE_URL=postgresql://staging-db/myapp
API_URL=https://staging-api.example.com
Production
DATABASE_URL=postgresql://production-db/myapp
API_URL=https://api.example.com
The application always reads:
process.env.DATABASE_URL
process.env.API_URL
It doesn't need to know which environment it is running in.
The deployment environment provides the correct values.
This keeps application code simpler.
Avoid environment-specific variable names
Something like this:
DEV_DATABASE_URL=
STAGING_DATABASE_URL=
PRODUCTION_DATABASE_URL=
is often unnecessary.
It forces your application to determine which one it should use.
Instead, prefer:
DATABASE_URL=
and provide a different value in each environment.
This makes the configuration portable and predictable.
Validate environment variables
One of the biggest mistakes in environment-variable management is assuming that a variable exists.
Consider:
const apiKey = process.env.API_KEY
What happens if API_KEY is missing?
The application may fail much later when that value is finally used.
A better approach is to validate configuration during startup.
For example, with Zod:
npm install zod
Then:
import { z } from 'zod'
const envSchema = z.object({
NODE_ENV: z.enum([
'development',
'staging',
'production'
]),
DATABASE_URL: z.string().url(),
API_URL: z.string().url(),
JWT_SECRET: z.string().min(32)
})
const env = envSchema.parse(process.env)
Now the application fails immediately if the configuration is invalid.
This is much easier to debug than discovering a missing variable after the application has already started.
Validate types too
Remember that environment variables are strings.
This:
PORT=3000
does not automatically become a JavaScript number.
If you write:
console.log(typeof process.env.PORT)
you will get:
string
So configuration validation is also a good place to transform values.
Example:
const envSchema = z.object({
PORT: z.coerce.number().default(3000),
ENABLE_ANALYTICS: z
.enum(['true', 'false'])
.transform(value => value === 'true')
})
Now your application receives correctly typed values.
Separate secrets from non-secret configuration
Not every environment variable is a secret.
For example:
PORT=3000
NODE_ENV=production
LOG_LEVEL=info
are configuration values, but they are usually not sensitive.
These are very different:
DATABASE_PASSWORD=...
JWT_SECRET=...
STRIPE_SECRET_KEY=...
AWS_SECRET_ACCESS_KEY=...
Those are secrets.
Treating everything as equally sensitive may create unnecessary complexity.
Treating nothing as sensitive is much worse.
A useful distinction is:
Configuration
↓
values that control application behavior
Secrets
↓
credentials that grant access to external resources
This distinction becomes important when deciding where variables should be stored and who should be able to read them.
Limit access by environment
Not every developer needs production credentials.
A team might use permissions like:
Developer
→ Development
→ Staging
Senior developer
→ Development
→ Staging
→ limited Production access
Operations
→ Production
This follows the principle of least privilege.
People should receive only the permissions they need to perform their work.
This is especially important as the team grows.
Be careful with frontend environment variables
Another common mistake is assuming that an environment variable is secret simply because it lives in a .env file.
Frontend applications run in the user's browser.
Any value bundled into client-side JavaScript can potentially be inspected by the user.
For example, in Next.js:
NEXT_PUBLIC_API_URL=https://api.example.com
is intentionally exposed to the browser.
You should never put something like:
NEXT_PUBLIC_DATABASE_PASSWORD=...
or:
NEXT_PUBLIC_STRIPE_SECRET_KEY=...
because those values would no longer be secret.
The .env file protects configuration from source control.
It does not automatically protect variables from being exposed in a frontend bundle.
Environment variables in Docker
Docker applications commonly receive configuration through environment variables.
For example:
services:
api:
image: my-api
environment:
NODE_ENV: production
PORT: 3000
Or:
services:
api:
env_file:
- .env.production
But you should still consider where .env.production comes from.
Copying production secrets around servers manually can become difficult to manage as infrastructure grows.
For larger systems, deployment platforms or dedicated secret-management tools are usually a better choice.
Environment variables in CI/CD
CI/CD pipelines usually provide secrets through encrypted repository or project settings.
For example, GitHub Actions allows secrets to be configured and referenced from workflows.
Conceptually:
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
The deployment pipeline then injects the variable into the application environment.
This has an important advantage:
the production secret does not need to live in the Git repository.
Avoid manually copying .env files to servers
A workflow like this:
developer machine
↓
copy .env.production
↓
SSH
↓
paste file into server
works.
But it has several weaknesses:
- easy to make mistakes
- difficult to audit
- difficult to synchronize
- difficult to automate
- difficult to revoke access
- difficult to track configuration changes
The more environments and developers you have, the more painful this becomes.
Treat environment configuration as part of your deployment process
Your deployment pipeline should answer this question:
Where does the application receive its configuration?
For example:
GitHub
↓
CI/CD
↓
production server
↓
environment variables injected at runtime
The exact implementation depends on your infrastructure.
The important point is that configuration delivery should be intentional rather than improvised.
Document every variable
A simple but very effective habit is to document each environment variable.
Instead of:
REDIS_URL=
SESSION_SECRET=
EMAIL_FROM=
maintain documentation like:
REDIS_URL
Redis connection URL used for caching and sessions.
Required:
Yes
Example:
redis://localhost:6379
This becomes increasingly important as the number of variables grows.
It is also useful when onboarding new developers.
Keep naming consistent
Good names:
DATABASE_URL
REDIS_URL
JWT_SECRET
SMTP_HOST
SMTP_PORT
SMTP_USERNAME
SMTP_PASSWORD
Poor naming:
DB
REDIS_CONNECTION_THING
JWT
MAILPW
The purpose of a variable should be obvious from its name.
Remove unused environment variables
Environment variables tend to accumulate.
Months later you might have:
OLD_API_KEY=
LEGACY_DATABASE_URL=
TEMP_FEATURE_FLAG=
OLD_SMTP_PASSWORD=
but nobody knows whether they are still used.
Periodically review your configuration.
Unused variables should be removed.
Unused secrets should also be revoked when appropriate.
This reduces configuration complexity and security risk.
Rotate secrets
Secrets should not necessarily live forever.
Credentials may need to be rotated because:
- someone leaves the team
- a credential is exposed
- a provider recommends rotation
- a repository accidentally contains the secret
- an employee device is compromised
- security policy requires regular rotation
A good environment-management workflow should make it reasonably easy to update a value across the systems that depend on it.
How should teams share environment variables?
For very small projects, developers can manually maintain their local .env files.
This is often enough.
As the team or number of projects grows, it can become useful to have a centralized place where environment variables are organized by:
Project
↓
Environment
↓
Variables
For example:
My API
├── Development
│ ├── DATABASE_URL
│ ├── REDIS_URL
│ └── API_KEY
│
├── Staging
│ ├── DATABASE_URL
│ ├── REDIS_URL
│ └── API_KEY
│
└── Production
├── DATABASE_URL
├── REDIS_URL
└── API_KEY
This structure makes it much easier to understand which configuration belongs to which environment.
This is why I built Envly
I recently started building Envly, a developer tool for organizing environment variables across projects and environments.
The idea came from a very common workflow problem.
I often had several projects containing multiple .env files, and keeping development, staging, and production configuration organized became increasingly annoying.
The typical project might look like:
project-a
project-b
project-c
And every project might have:
development
staging
production
Instead of treating environment variables as random files scattered across machines, Envly organizes them around those concepts directly.
Conceptually:
Envly
Project
├── Development
├── Staging
└── Production
Each environment then contains its own variables.
A CLI makes this workflow even more useful
I'm also working on an Envly CLI so developers can interact with their environments directly from the terminal.
For example, the workflow can become something like:
envly login
Then:
envly projects
And eventually retrieving configuration directly for a project/environment rather than manually moving .env files around.
The goal is not to replace environment variables.
Environment variables are already a good abstraction.
The goal is to make managing them less fragmented.
You can find the project here:
And the source code is available on GitHub:
https://github.com/justoverclockl/envly
A practical environment variable workflow
For a typical application, a reasonable workflow might look like this:
1. Commit an .env.example
DATABASE_URL=
REDIS_URL=
JWT_SECRET=
API_URL=
2. Ignore local .env files
.env
.env.*
Depending on your framework, you may want to exclude certain example files from this rule.
3. Validate variables on startup
Use a schema library such as Zod, Joi, or your framework's validation system.
4. Separate environments
Maintain different values for:
Development
Staging
Production
while keeping variable names consistent.
5. Keep secrets out of Git
Never store production credentials directly in the repository.
6. Inject production values during deployment
Use your hosting provider, CI/CD pipeline, container runtime, or secret-management system.
7. Restrict production access
Not every team member needs every secret.
8. Maintain documentation
Developers should know what every required variable does.
Example project structure
A Node.js application might look like:
my-project/
├── src/
├── package.json
├── .env
├── .env.example
└── .gitignore
.gitignore:
.env
.env.local
.env.example:
NODE_ENV=
PORT=
DATABASE_URL=
JWT_SECRET=
API_URL=
Local .env:
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://localhost:5432/myapp
JWT_SECRET=local-development-secret
API_URL=http://localhost:3000
Production values would then come from the deployment environment rather than from the repository.
Common mistakes
Here are some of the environment-variable mistakes I've seen most often.
Committing .env
Probably the most obvious one.
Once a secret has entered Git history, assume it may have been exposed.
Rotate it.
Sharing secrets through chat
Sending:
DATABASE_PASSWORD=...
through Slack or Discord may be convenient, but those credentials can remain in message history indefinitely.
Using the same credentials everywhere
Avoid using the same database password, API token, or secret in development and production.
If development credentials leak, production should not automatically be compromised.
Giving everyone production access
Convenient?
Yes.
Necessary?
Usually not.
Not validating variables
Missing environment variables should ideally stop the application during startup rather than causing unexpected failures later.
Exposing secrets in frontend code
Anything delivered to the browser should be considered visible to the user.
Keeping abandoned variables forever
Remove obsolete configuration.
Your future self will thank you.
Environment variables vs secret managers
Environment variables and secret managers solve related but different problems.
Environment variables are a configuration delivery mechanism.
A secret manager is typically responsible for securely storing and controlling access to sensitive values.
A common production architecture might therefore be:
Secret manager
↓
deployment system
↓
environment variables
↓
application
The application still consumes:
process.env.DATABASE_URL
but the value originates from a more secure system.
When does a simple .env file stop being enough?
There is no universal number.
A .env file can remain perfectly reasonable for a solo developer or small application.
You may want to consider a more structured workflow when you start having:
- several developers
- multiple projects
- development, staging, and production environments
- frequent configuration changes
- sensitive credentials
- onboarding difficulties
- repeated manual sharing of
.envfiles - inconsistent configuration between team members
The problem usually isn't the .env format itself.
The problem is synchronization and organization.
Final thoughts
Environment variables are easy to introduce but surprisingly easy to mismanage.
For small projects, the basics are often enough:
.env
.env.example
.gitignore
As the application grows, the workflow should grow with it.
A solid environment-variable strategy usually includes:
- separate development, staging, and production configuration
- consistent variable names
- startup validation
- no secrets committed to Git
- controlled production access
- documented variables
- secure secret storage
- automated configuration during deployment
Most importantly, configuration should be treated as an intentional part of application architecture rather than as a collection of .env files copied between machines.
That is also the problem I'm trying to solve with Envly: making environment variables easier to organize across projects and environments without changing the way applications already consume configuration.
If you're interested, you can check it out here:
Top comments (0)