DEV Community

Marko Colia
Marko Colia

Posted on

How to manage environment variables across development, staging and production

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Your application can then read those values at runtime.

In Node.js:

const port = process.env.PORT
const databaseUrl = process.env.DATABASE_URL
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The development environment usually prioritizes convenience and debugging.

For example, you might enable:

DEBUG=true
LOG_LEVEL=debug
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Libraries such as dotenv can load these values into the runtime.

For Node.js:

npm install dotenv
Enter fullscreen mode Exit fullscreen mode

Then:

import 'dotenv/config'

console.log(process.env.DATABASE_URL)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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=
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then add:

.env.development
.env.staging
.env.production
Enter fullscreen mode Exit fullscreen mode

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=...
Enter fullscreen mode Exit fullscreen mode

Developer B needs the same value.

What happens next?

Many teams end up sending environment variables through:

  • Slack
  • Discord
  • email
  • WhatsApp
  • 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
Enter fullscreen mode Exit fullscreen mode

Staging

DATABASE_URL=postgresql://staging-db/myapp
API_URL=https://staging-api.example.com
Enter fullscreen mode Exit fullscreen mode

Production

DATABASE_URL=postgresql://production-db/myapp
API_URL=https://api.example.com
Enter fullscreen mode Exit fullscreen mode

The application always reads:

process.env.DATABASE_URL
process.env.API_URL
Enter fullscreen mode Exit fullscreen mode

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=
Enter fullscreen mode Exit fullscreen mode

is often unnecessary.

It forces your application to determine which one it should use.

Instead, prefer:

DATABASE_URL=
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

does not automatically become a JavaScript number.

If you write:

console.log(typeof process.env.PORT)
Enter fullscreen mode Exit fullscreen mode

you will get:

string
Enter fullscreen mode Exit fullscreen mode

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')
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

are configuration values, but they are usually not sensitive.

These are very different:

DATABASE_PASSWORD=...
JWT_SECRET=...
STRIPE_SECRET_KEY=...
AWS_SECRET_ACCESS_KEY=...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

is intentionally exposed to the browser.

You should never put something like:

NEXT_PUBLIC_DATABASE_PASSWORD=...
Enter fullscreen mode Exit fullscreen mode

or:

NEXT_PUBLIC_STRIPE_SECRET_KEY=...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Or:

services:
  api:
    env_file:
      - .env.production
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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=
Enter fullscreen mode Exit fullscreen mode

maintain documentation like:

REDIS_URL

Redis connection URL used for caching and sessions.

Required:
Yes

Example:
redis://localhost:6379
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Poor naming:

DB
REDIS_CONNECTION_THING
JWT
MAILPW
Enter fullscreen mode Exit fullscreen mode

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=
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And every project might have:

development
staging
production
Enter fullscreen mode Exit fullscreen mode

Instead of treating environment variables as random files scattered across machines, Envly organizes them around those concepts directly.

Conceptually:

Envly

Project
├── Development
├── Staging
└── Production
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then:

envly projects
Enter fullscreen mode Exit fullscreen mode

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:

https://envly.dev

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=
Enter fullscreen mode Exit fullscreen mode

2. Ignore local .env files

.env
.env.*
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

.gitignore:

.env
.env.local
Enter fullscreen mode Exit fullscreen mode

.env.example:

NODE_ENV=
PORT=
DATABASE_URL=
JWT_SECRET=
API_URL=
Enter fullscreen mode Exit fullscreen mode

Local .env:

NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://localhost:5432/myapp
JWT_SECRET=local-development-secret
API_URL=http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

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=...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The application still consumes:

process.env.DATABASE_URL
Enter fullscreen mode Exit fullscreen mode

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 .env files
  • 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
Enter fullscreen mode Exit fullscreen mode

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:

https://envly.dev

Top comments (0)