Data as of Sep 9, 2026 · Based on 337 AI responses from ChatGPT Search and Google AI Mode · See how Parse measures this
Full-stack developers typically use a hybrid approach: local .env files for development environments and a professional-grade secret management service or CI/CD platform features for staging and production. This ensures that sensitive credentials remain encrypted, rotated, and properly scoped to the correct environment during deployment.
Brands AI recommends here
Named in 65% of answers
Ideal for enterprise-grade environments or complex applications requiring rigorous security and secret rotation. It provides a robust, centralized, and self-hosted option for teams needing maximum control over their infrastructure secrets.
Named in 64% of answers
Named in 61% of answers
Named in 52% of answers
The best way for a full-stack developer to manage environment variables across multiple environments is to **decouple configuration from code by using local `.env` files for development and platform-native secret managers or CI/CD dashboards for staging and production** . This adheres to the **Twelve-Factor App**…
The best way for a full-stack developer to manage environment variables across multiple environments is to decouple configuration from code by using local .env files for development and platform-native secret managers or CI/CD dashboards for staging and production . This adheres to the Twelve-Factor App methodology, ensuring your application remains portable and secure.
Here is a structured, highly scannable breakdown of how to implement this workflow across your entire stack.
dev)The goal locally is simplicity, developer onboarding speed, and preventing data leaks.
.env files locally: Store your local variables in a .env or .env.development file..env file to your .gitignore to avoid accidentally pushing secrets to version control..env.example file in your repository. Populate it with keys but leave the values blank or set to dummy defaults so new developers know what to configure.stg & prod)Never use .env files in production or staging. They are security liabilities and make scaling across multiple server instances difficult.
How you handle these variables inside your frontend and backend code determines your application's reliability.
process.env . If a required variable is missing, block the app from booting.NEXT_PUBLIC_ or VITE_ ) and never include backend secrets in these builds.process.env.MY_VAR randomly throughout your codebase, import them from a single config.ts or env.js file. This provides a single source of truth and IDE autocomplete.| Environment | Storage Mechanism | Access Strategy | Risk Level |
|---|---|---|---|
| Development | .env file (local only) | Loaded via tools like dotenv | Low (Dummy/local data) |
| Staging | Platform Dashboard / Secret Manager | Injected via CI/CD / Runtime | Medium (Test data mimic) |
| Production | Encrypted Secret Manager (Vault/AWS/Doppler) | Real-time injection / Restricted access | Critical (Live user data) |
If you'd like to tailor this workflow to your specific stack, let me know:
For a full-stack developer, the best approach is to treat environment variables as **configuration with a lifecycle**, not just as random `.env` files. The goal is: - The **same code artifact** runs everywhere. - Each environment supplies its own configuration.
For a full-stack developer, the best approach is to treat environment variables as configuration with a lifecycle, not just as random .env files. The goal is:
A practical setup looks like this:
Avoid:
DATABASE_URL_DEV=
DATABASE_URL_STAGING=
DATABASE_URL_PROD=
Prefer:
DATABASE_URL=
API_URL=
STRIPE_SECRET_KEY=
JWT_SECRET=
Then each environment provides a different value.
Example:
Development
APP_ENV=development
DATABASE_URL=postgres://localhost/myapp_dev
STRIPE_SECRET_KEY=sk_test_xxx
LOG_LEVEL=debug
Staging
APP_ENV=staging
DATABASE_URL=postgres://staging-db/myapp
STRIPE_SECRET_KEY=sk_test_staging_xxx
LOG_LEVEL=info
Production
APP_ENV=production
DATABASE_URL=postgres://production-db/myapp
STRIPE_SECRET_KEY=sk_live_xxx
LOG_LEVEL=warn
This keeps environment decisions outside your application code.
.env.example as documentation, not storageCommit:
.env.example
Example:
DATABASE_URL=
REDIS_URL=
JWT_SECRET=
STRIPE_SECRET_KEY=
NEXT_PUBLIC_API_URL=
Do not commit:
.env
.env.local
.env.production
Your .gitignore:
.env
.env.*
!.env.example
The repository should describe what variables exist, but never contain real credentials.
Local:
.env.local
Production:
Examples:
This is usually enough for early projects.
Use a dedicated secrets manager:
Production applications should generally fetch secrets from a controlled system rather than relying on manually copied .env files.
Not everything is a secret.
Usually safe:
PORT=3000
LOG_LEVEL=info
FEATURE_NEW_CHECKOUT=true
Protect:
DATABASE_PASSWORD=
JWT_SECRET=
STRIPE_SECRET_KEY=
AWS_SECRET_ACCESS_KEY=
Secrets should have:
Do not wait until a user hits an endpoint and discovers a missing key.
Example (Node/TypeScript):
const requiredEnv = [
"DATABASE_URL",
"JWT_SECRET",
"STRIPE_SECRET_KEY",
];
for (const key of requiredEnv) {
if (!process.env[key]) {
throw new Error(`Missing environment variable: ${key}`);
}
}
Even better, use a schema validator:
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
});
export const env = envSchema.parse(process.env);
Now bad configuration fails during deployment instead of at runtime.
A common pattern:
feature branch
|
v
development
|
v
staging
|
v
production
Each deployment target gets its own secrets:
GitHub Actions
|
+-- development secrets
|
+-- staging secrets
|
+-- production secrets
Never have developers manually copy production credentials into their machines.
For full-stack apps, remember:
Backend variables:
DATABASE_URL=
PRIVATE_API_KEY=
JWT_SECRET=
Stay server-side.
Browser variables:
NEXT_PUBLIC_API_URL=
VITE_PUBLIC_MAP_KEY=
Anything exposed to the frontend should be assumed public.
A database password or private API key should never be prefixed as a client-side variable.
Useful safeguards:
For most full-stack apps:
Repository
│
├── .env.example # committed
├── .env.local # developer machine only
│
├── Application code
│
└── CI/CD
│
├── Development secrets
├── Staging secrets
└── Production secrets
For production:
App
|
v
Secret Manager
|
+-- DATABASE_URL
+-- API keys
+-- Signing keys
The main principles are:
That approach scales from a personal project to a multi-service production system.
DATABASE_URL=
REDIS_URL=
JWT_SECRET=
STRIPE_SECRET_KEY=
NEXT_PUBLIC_API_URL=
Do not commit:
.env
.env.local
.env.production
Your .gitignore:
.env
.env.*
!.env.example
The repository should describe what variables exist, but never contain real credentials.
Local:
.env.local
Production:
Examples:
zod
zod.dev
The best way for a full-stack developer to manage environment variables across multiple environments is to **decouple configuration from code by using local `.env` files for development and platform-native secret managers or CI/CD dashboards for staging and production** . This adheres to the **Twelve-Factor App**…
The best way for a full-stack developer to manage environment variables across multiple environments is to decouple configuration from code by using local .env files for development and platform-native secret managers or CI/CD dashboards for staging and production . This adheres to the Twelve-Factor App methodology, ensuring your application remains portable and secure.
Here is a structured, highly scannable breakdown of how to implement this workflow across your entire stack.
dev)The goal locally is simplicity, developer onboarding speed, and preventing data leaks.
.env files locally: Store your local variables in a .env or .env.development file..env file to your .gitignore to avoid accidentally pushing secrets to version control..env.example file in your repository. Populate it with keys but leave the values blank or set to dummy defaults so new developers know what to configure.stg & prod)Never use .env files in production or staging. They are security liabilities and make scaling across multiple server instances difficult.
How you handle these variables inside your frontend and backend code determines your application's reliability.
process.env . If a required variable is missing, block the app from booting.NEXT_PUBLIC_ or VITE_ ) and never include backend secrets in these builds.process.env.MY_VAR randomly throughout your codebase, import them from a single config.ts or env.js file. This provides a single source of truth and IDE autocomplete.Comparison Matrix
| Environment | Storage Mechanism | Access Strategy | Risk Level |
|---|---|---|---|
| Development | .env file (local only) | Loaded via tools like dotenv | Low (Dummy/local data) |
| Staging | Platform Dashboard / Secret Manager | Injected via CI/CD / Runtime | Medium (Test data mimic) |
| Production | Encrypted Secret Manager (Vault/AWS/Doppler) | Real-time injection / Restricted access | Critical (Live user data) |
If you'd like to tailor this workflow to your specific stack, let me know:
The best approach is to treat environment variables as **configuration inputs** and manage them with clear separation between **code**, **environment-specific values**, and **secrets**. The goal is: **the same application artifact should run in development, staging, and production; only the configuration changes.**…
The best approach is to treat environment variables as configuration inputs and manage them with clear separation between code, environment-specific values, and secrets. The goal is: the same application artifact should run in development, staging, and production; only the configuration changes.
A practical full-stack setup looks like this:
Avoid environment-specific names like:
DATABASE_URL_DEV=
DATABASE_URL_STAGING=
DATABASE_URL_PROD=
Prefer:
DATABASE_URL=
REDIS_URL=
STRIPE_API_KEY=
NEXT_PUBLIC_API_URL=
JWT_SECRET=
Then each environment supplies different values:
| Variable | Development | Staging | Production |
|---|---|---|---|
DATABASE_URL | Local Postgres | Staging DB | Production DB |
LOG_LEVEL | debug | info | warn |
API_URL | localhost | staging API | production API |
Your code stays simple:
const db = connect(process.env.DATABASE_URL);
not:
if (process.env.NODE_ENV === "production") {
connect(process.env.DATABASE_URL_PROD);
}
Your repository should contain something like:
.env.example
Example:
NODE_ENV=development
DATABASE_URL=
REDIS_URL=
JWT_SECRET=
STRIPE_API_KEY=
NEXT_PUBLIC_API_URL=http://localhost:3000
Developers create:
.env.local
with their own values:
DATABASE_URL=postgres://localhost/myapp
JWT_SECRET=my-local-secret
Add this to .gitignore:
.env
.env.*
!.env.example
Real credentials should never be committed to Git.
Not every environment variable needs the same protection.
Usually safe to keep in code or templates:
PORT=3000
LOG_LEVEL=debug
FEATURE_NEW_CHECKOUT=true
Should live outside your repository:
DATABASE_PASSWORD=
JWT_SECRET=
AWS_SECRET_ACCESS_KEY=
STRIPE_SECRET_KEY=
For staging and production, use a secrets manager rather than .env files checked out on servers. Common options include:
These provide access control, auditing, and rotation capabilities.
A good deployment flow:
Developer laptop
|
| .env.local
v
Development
|
| CI/CD pipeline
v
Staging
|
| production approval
v
Production
The pipeline should pull secrets from its environment:
Example:
deploy:
environment: production
variables:
NODE_ENV: production
The deployment system supplies:
DATABASE_URL
JWT_SECRET
PAYMENT_API_KEY
without those values existing in your repository.
A missing variable should fail immediately, not after a user hits an endpoint.
Example with Node.js:
const required = [
"DATABASE_URL",
"JWT_SECRET",
];
for (const key of required) {
if (!process.env[key]) {
throw new Error(`Missing environment variable: ${key}`);
}
}
Even better, use a schema validator such as zod, joi, or envalid:
const config = envSchema({
DATABASE_URL: str(),
JWT_SECRET: str(),
});
Benefits:
Development, staging, and production should have the same shape:
Good:
.env.example
DATABASE_URL
REDIS_URL
JWT_SECRET
API_URL
Bad:
Development:
DATABASE_URL
REDIS_URL
Production:
DATABASE_URL
REDIS_URL
PAYMENT_SECRET
NEW_FEATURE_FLAG
THIRD_PARTY_TOKEN
The production-only variable will eventually cause surprises.
A useful practice is to check that every deployment environment has all required keys.
For full-stack apps, remember:
Safe:
DATABASE_PASSWORD
JWT_SECRET
STRIPE_SECRET_KEY
Only available server-side.
Often exposed to users:
NEXT_PUBLIC_API_URL=https://api.example.com
VITE_API_URL=https://api.example.com
Never put secrets behind frontend prefixes:
Bad:
NEXT_PUBLIC_STRIPE_SECRET_KEY=secret123
Anything shipped to the browser is public.
A solid production workflow:
repo/
├── .env.example # committed documentation
├── .env.local # developer machine only
├── src/
└── deployment/
CI/CD:
├── dev environment secrets
├── staging environment secrets
└── production environment secrets
Cloud:
└── centralized secret manager
.env.example.envAdd:
Add:
The core rule: store variable names with your code, store secrets with your infrastructure, and make every environment use the same configuration contract. This prevents most "works locally, fails in staging" and accidental credential-leak problems.
Add:
Add:
Managing environment variables effectively across multiple environments requires a balance of developer convenience, strict security, and deployment automation. Here is the best approach for a full-stack developer, broken down by strategy and tooling. 1. Adopt a Standard Configuration Structure - **Configuration as…
Managing environment variables effectively across multiple environments requires a balance of developer convenience, strict security, and deployment automation. Here is the best approach for a full-stack developer, broken down by strategy and tooling.
.env.example file to your Git repository. This file acts as a living template listing all required variables (with empty or dummy values) so new developers or CI/CD pipelines know what keys are expected..env, .env.local, .env.development, .env.production , and any files containing actual credentials to your .gitignore immediately..env file loaded at runtime by your framework (e.g., dotenv in Node.js, Next.js built-in runtime config, or Python's python-dotenv ). Developers can copy .env.example to .env and populate it with local database URLs or mock API keys.docker run -e , Docker Compose env_file , or Kubernetes Secrets and ConfigMaps. Avoid baking secrets into the Docker image itself during the docker build phase.DATABASE_URL or JWT_SECRET ) is missing.envalid allow you to parse and validate process.env on startup, throwing clear errors if anything is misconfigured.If you'd like, tell me:
I can give you a tailored setup guide or a sample validation schema.
zod
zod.dev
No sources cited
The best approach is to treat environment variables as **deployment configuration**, not as files that developers manually shuffle between environments. A good full-stack setup looks like this: ```
The best approach is to treat environment variables as deployment configuration, not as files that developers manually shuffle between environments.
A good full-stack setup looks like this:
Git repository
│
.env.example / schema
│
┌──────────────┼──────────────┐
▼ ▼ ▼
Local Staging Production
.env CI/CD vars Secret manager
│ │ │
└──────────────┼──────────────┘
▼
Application
The core principle is to keep configuration separate from code and use the same variable names across deployments. This is essentially the Twelve-Factor App config model.
.env for local developmentFor example:
.env
.env.example
.gitignore
Your actual .env:
DATABASE_URL=postgresql://localhost:5432/myapp_dev
REDIS_URL=redis://localhost:6379
STRIPE_SECRET_KEY=sk_test_...
JWT_SECRET=local-development-secret
NEXT_PUBLIC_API_URL=http://localhost:3000
Commit only .env.example:
DATABASE_URL=
REDIS_URL=
STRIPE_SECRET_KEY=
JWT_SECRET=
NEXT_PUBLIC_API_URL=
This gives every developer a clear contract without putting credentials in Git.
.env
.env.*
!.env.example
I'd also add secret scanning to your repository/CI pipeline. OWASP specifically recommends preventing secrets from entering repositories and continuously scanning for accidental exposure.
Don't do this:
# development
DEV_DATABASE_URL=...
# staging
STAGING_DATABASE_URL=...
# production
PROD_DATABASE_URL=...
Instead:
DATABASE_URL=...
with a different value in each deployment.
Your application should simply do:
const databaseUrl = process.env.DATABASE_URL;
rather than:
const databaseUrl =
process.env.NODE_ENV === "production"
? process.env.PROD_DATABASE_URL
: process.env.DEV_DATABASE_URL;
That keeps environment-specific logic out of the application. The Twelve-Factor guidance specifically recommends treating individual variables as independent configuration rather than creating increasingly complex environment-specific groups.
Not everything in an environment is equally sensitive.
NODE_ENV=production
LOG_LEVEL=info
API_URL=https://api.example.com
MAX_UPLOAD_SIZE=10485760
FEATURE_NEW_DASHBOARD=true
DATABASE_PASSWORD=...
STRIPE_SECRET_KEY=...
JWT_SECRET=...
AWS_SECRET_ACCESS_KEY=...
Both can be environment-specific, but secrets deserve substantially stronger handling.
For staging/production, I'd use your cloud provider's secret manager or a dedicated solution rather than maintaining production .env files. OWASP recommends centralized secret management, access controls, auditing and rotation; examples include AWS Secrets Manager, Google Secret Manager, Azure Key Vault and Vault.
For example:
Development
DATABASE_URL → local database
STRIPE_KEY → Stripe test key
Staging
DATABASE_URL → staging database
STRIPE_KEY → Stripe test key
Production
DATABASE_URL → production database
STRIPE_KEY → Stripe live key
Never let staging share production credentials.
This is particularly important for databases, payment providers, cloud credentials and signing keys. Separate environments reduce the blast radius if a credential is compromised. OWASP also recommends separating production and development secret stores/access where appropriate.
This is one of the biggest improvements you can make.
Instead of discovering five minutes into a request that:
DATABASE_URL is undefined
make the application refuse to start.
For a TypeScript application, something like:
import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
API_URL: z.string().url(),
});
export const env = envSchema.parse(process.env);
Then use:
env.DATABASE_URL
rather than scattering:
process.env.DATABASE_URL
throughout the codebase.
This gives you a useful contract:
If the application starts, its required configuration is valid.
A typical pipeline becomes:
git push
│
▼
CI
│
├── tests
├── lint
├── build
└── security scanning
│
▼
deploy staging
│
▼
staging secrets/config
│
▼
approval
│
▼
deploy production
│
▼
production secrets/config
Ideally, you build one artifact and promote it through environments rather than rebuilding different application versions for staging and production. The environment supplies the configuration; the application artifact remains the same.
Also make sure CI logs never print environment variables. OWASP explicitly warns against secrets appearing in pipeline output, logs, command history or build artifacts.
This is especially important for full-stack developers.
Anything bundled into browser JavaScript is effectively public.
For example, in Next.js:
NEXT_PUBLIC_API_URL=https://api.example.com
is fine.
But this is not:
NEXT_PUBLIC_STRIPE_SECRET_KEY=...
NEXT_PUBLIC_DATABASE_PASSWORD=...
NEXT_PUBLIC_JWT_SECRET=...
The PUBLIC/client-exposed prefix should be treated as:
"This value is safe for every user of my application to see." Secret credentials should remain on the server.
I'd maintain something like:
.env.example
and perhaps documentation:
| Variable | Required | Secret | Dev | Staging | Prod |
|---|---|---|---|---|---|
DATABASE_URL | Yes | Yes | ✓ | ✓ | ✓ |
REDIS_URL | Yes | No* | ✓ | ✓ | ✓ |
JWT_SECRET | Yes | Yes | ✓ | ✓ | ✓ |
API_URL | Yes | No | ✓ | ✓ | ✓ |
STRIPE_SECRET_KEY | Yes | Yes | Test | Test | Live |
LOG_LEVEL | No | No | debug | info | warn |
This makes configuration drift much easier to detect.
For most full-stack teams, I'd recommend:
Git
│
┌─────────┴─────────┐
│ │
.env.example env schema
│ │
▼ ▼
Developer CI checks
│
.env
│
▼
Local application
Staging:
Git → CI/CD → staging platform variables/secrets
│
▼
staging app
Production:
Git → CI/CD → production secret manager
│
▼
production app
For a small project, your hosting provider's encrypted environment-variable facility may be sufficient. As the application/team grows, move sensitive credentials toward a proper secrets manager.
.env.example, not .env..env files for developer machines.The key mental model is:
Git contains the application's code and the configuration schema. The deployment environment contains the actual values. That scales much better than maintaining
dev.env,staging.env, andproduction.envfiles and passing them around manually.
.env
.env.example
.gitignore
Your actual .env:
DATABASE_URL=postgresql://localhost:5432/myapp_dev
REDIS_URL=redis://localhost:6379
STRIPE_SECRET_KEY=sk_test_...
JWT_SECRET=local-development-secret
NEXT_PUBLIC_API_URL=http://localhost:3000
Commit only .env.example:
DATABASE_URL=
REDIS_URL=
STRIPE_SECRET_KEY=
JWT_SECRET=
NEXT_PUBLIC_API_URL=
This gives every developer a clear contract without putting credentials in Git.
.env
.env.*
!.env.example
I'd also add secret scanning to your repository/CI pipeline. OWASP specifically recommends preventing secrets from entering repositories and continuously scanning for accidental exposure.
Don't do this:
# development
DEV_DATABASE_URL=...
# staging
STAGING_DATABASE_URL=...
# production
PROD_DATABASE_URL=...
Instead:
DATABASE_URL=...
with a different value in each deployment.
Your application should simply do:
const databaseUrl = process.env.DATABASE_URL;
rather than:
const databaseUrl =
process.env.NODE_ENV === "production"
? process.env.PROD_DATABASE_URL
: process.env.DEV_DATABASE_URL;
That keeps environment-specific logic out of the application. The Twelve-Factor guidance specifically recommends treating individual variables as independent configuration rather than creating increasingly complex environment-specific groups.
Not everything in an environment is equally sensitive.
NODE_ENV=production
LOG_LEVEL=info
API_URL=https://api.example.com
MAX_UPLOAD_SIZE=10485760
FEATURE_NEW_DASHBOARD=true
DATABASE_PASSWORD=...
STRIPE_SECRET_KEY=...
JWT_SECRET=...
AWS_SECRET_ACCESS_KEY=...
Both can be environment-specific, but secrets deserve substantially stronger handling.
For staging/production, I'd use your cloud provider's secret manager or a dedicated solution rather than maintaining production .env files. OWASP recommends centralized secret management, access controls, auditing and rotation; examples include AWS Secrets Manager, Google Secret Manager, Azure Key Vault and Vault.
For example:
Development
DATABASE_URL → local database
STRIPE_KEY → Stripe test key
Staging
DATABASE_URL → staging database
STRIPE_KEY → Stripe test key
Production
DATABASE_URL → production database
STRIPE_KEY → Stripe live key
Never let staging share production credentials.
This is particularly important for databases, payment providers, cloud credentials and signing keys. Separate environments reduce the blast radius if a credential is compromised. OWASP also recommends separating production and development secret stores/access where appropriate.
This is one of the biggest improvements you can make.
Instead of discovering five minutes into a request that:
DATABASE_URL is undefined
make the application refuse to start.
For a TypeScript application, something like:
import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
API_URL: z.string().url(),
});
export const env = envSchema.parse(process.env);
Then use:
env.DATABASE_URL
rather than scattering:
process.env.DATABASE_URL
throughout the codebase.
This gives you a useful contract:
If the application starts, its required configuration is valid.
A typical pipeline becomes:
git push
│
▼
CI
│
├── tests
├── lint
├── build
└── security scanning
│
▼
deploy staging
│
▼
staging secrets/config
│
▼
approval
│
▼
deploy production
│
▼
production secrets/config
Ideally, you build one artifact and promote it through environments rather than rebuilding different application versions for staging and production. The environment supplies the configuration; the application artifact remains the same.
Also make sure CI logs never print environment variables. OWASP explicitly warns against secrets appearing in pipeline output, logs, command history or build artifacts.
This is especially important for full-stack developers.
Anything bundled into browser JavaScript is effectively public.
For example, in Next.js:
NEXT_PUBLIC_API_URL=https://api.example.com
is fine.
But this is not:
NEXT_PUBLIC_STRIPE_SECRET_KEY=...
NEXT_PUBLIC_DATABASE_PASSWORD=...
NEXT_PUBLIC_JWT_SECRET=...
The PUBLIC/client-exposed prefix should be treated as:
"This value is safe for every user of my application to see." Secret credentials should remain on the server.
I'd maintain something like:
.env.example
and perhaps documentation:
| Variable | Required | Secret | Dev | Staging | Prod |
|---|---|---|---|---|---|
DATABASE_URL | Yes | Yes | ✓ | ✓ | ✓ |
REDIS_URL | Yes | No* | ✓ | ✓ | ✓ |
JWT_SECRET | Yes | Yes | ✓ | ✓ | ✓ |
API_URL | Yes | No | ✓ | ✓ | ✓ |
STRIPE_SECRET_KEY | Yes | Yes | Test | Test | Live |
LOG_LEVEL | No | No | debug | info | warn |
This makes configuration drift much easier to detect.
For most full-stack teams, I'd recommend:
Git
│
┌─────────┴─────────┐
│ │
.env.example env schema
│ │
▼ ▼
Developer CI checks
│
.env
│
▼
Local application
Staging:
Git → CI/CD → staging platform variables/secrets
│
▼
staging app
Production:
Git → CI/CD → production secret manager
│
▼
production app
For a small project, your hosting provider's encrypted environment-variable facility may be sufficient. As the application/team grows, move sensitive credentials toward a proper secrets manager.
.env.example, not .env..env files for developer machines.The key mental model is:
Git contains the application's code and the configuration schema. The deployment environment contains the actual values.
Managing environment variables across multiple environments as a full-stack developer requires balancing **security**, **developer experience** , and **deployment automation**.[1][2][3] Here is the best, industry-standard approach broken down by the operational lifecycle: - Config storage: Store configuration strictly…
Managing environment variables across multiple environments as a full-stack developer requires balancing security, developer experience , and deployment automation.[1][2][3]
Here is the best, industry-standard approach broken down by the operational lifecycle:
- Config storage: Store configuration strictly in environment variables, never hardcoding secrets or configurations into your application code or committing them to version control.
- Separation of code and config: Treat environment-specific values as runtime inputs that change independently of the deployment bundle.[1][2][3][4][5]
- Template usage: Maintain a version-controlled `.env.example` file in your repository listing required keys (with blank or dummy values) so new developers know what variables are needed.
- Local safety: Use a git-ignored `.env` file for actual local development secrets, and tools like `dotenv` in Node.js/Python or built-in framework features (like Next.js or Vite prefixes) to load them locally.[1][2][3][4][5]
- Dedicated platforms: Move away from raw plain-text environment files in production and leverage cloud-native secret vaults like AWS Secrets Manager, Google Cloud Secret Manager , or Azure Key Vault.
- Alternative all-in-one tools: Utilize developer-focused configuration managers like Infisical or Doppler to synchronize secrets safely across teams and platforms.[1][2][3][4][5]
- Injection at build/runtime: Configure your hosting provider (e.g., Vercel Environment Variables or Render Environment Variables ) to securely pull secrets from your secret manager during deployment.
- Strict separation: Ensure staging secrets and production secrets live in completely isolated projects, organizations, or access-controlled namespaces to prevent accidental data leaks or cross-contamination.[1][2][3][4][5]
- Startup checks: Use validation libraries like `@t3-oss/env-nextjs` or `Zod` to parse and validate process variables at application startup.
- Fail fast: If a critical environment variable is missing or malformed, the application should crash immediately upon boot rather than failing unexpectedly mid-request in production.[1][2][3][4][5]
If you'd like, let me know:
I can tailor a more specific configuration setup or tool recommendation for your project.
No sources cited
For a full-stack developer, the best approach is to treat environment variables as **configuration that changes by deployment environment**, not as files that get copied around. The goal is: - Same codebase everywhere - Different configuration values per environment
For a full-stack developer, the best approach is to treat environment variables as configuration that changes by deployment environment, not as files that get copied around. The goal is:
A solid setup looks like this:
Avoid this:
DATABASE_URL_DEV=...
DATABASE_URL_STAGING=...
DATABASE_URL_PROD=...
Prefer:
DATABASE_URL=...
Then each environment supplies its own value:
| Environment | DATABASE_URL |
|---|---|
| Local development | Local database |
| Staging | Staging database |
| Production | Production database |
Your application code stays identical:
const db = connect(process.env.DATABASE_URL);
This follows the principle of separating configuration from code.
.env.example, never a real .envRepository:
.env.example ✅ committed
.env ❌ ignored
.env.local ❌ ignored
.env.production ❌ ignored
Example:
NODE_ENV=
DATABASE_URL=
REDIS_URL=
JWT_SECRET=
STRIPE_SECRET_KEY=
NEXT_PUBLIC_API_URL=
A new developer does:
cp .env.example .env
and fills in local values.
Never commit:
DATABASE_URL=postgres://admin:[email protected]
Secrets should not live in source control.
Good options:
.env.local filesExample:
.env.local
DATABASE_URL=postgres://localhost/myapp
STRIPE_SECRET_KEY=sk_test_xxx
Local secrets should be developer-specific and use non-production resources.
Treat staging like production, but isolated:
DATABASE_URL=staging-db
STRIPE_SECRET_KEY=test-key
AWS_BUCKET=myapp-staging
Staging should have:
This catches configuration problems before release.
Do not keep production secrets in the repo or developer machines.
Use:
Examples:
Production deployment:
GitHub Actions
|
v
Secrets Manager
|
v
Application container
Centralized secret management helps with access control, rotation, and auditing.
Avoid discovering missing variables after deployment.
Bad:
const stripe = new Stripe(process.env.STRIPE_KEY);
If STRIPE_KEY is missing, your app may fail later.
Better:
const requiredEnv = [
"DATABASE_URL",
"JWT_SECRET",
"STRIPE_KEY"
];
for (const key of requiredEnv) {
if (!process.env[key]) {
throw new Error(`Missing environment variable: ${key}`);
}
}
For TypeScript apps, many teams use schema validation libraries such as Zod to fail fast during startup/build.
Frontend frameworks often expose environment variables differently.
Safe:
NEXT_PUBLIC_API_URL=https://api.example.com
Unsafe:
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_xxx
Anything exposed to the browser should be considered public.
A common pattern:
Frontend
|
| public config only
v
Backend API
|
| secrets stay here
v
Database / services
Your deployment pipeline should decide which environment gets which secrets.
Example:
main branch
|
v
production deployment
|
v
production secrets
develop branch
|
v
staging deployment
|
v
staging secrets
Avoid manually copying .env files between machines.
Useful practices:
.env* to .gitignoreFor a small-to-medium team:
Repository
├── .env.example
├── src/
└── docker-compose.yml
Developer machine
└── .env.local
CI/CD
└── environment secrets
Staging
└── staging environment variables
Production
└── production secrets manager
A good rule of thumb:
.env.local.env.exampleThat gives developers a smooth workflow without turning configuration management into a security risk.
Bad:
const stripe = new Stripe(process.env.STRIPE_KEY);
If STRIPE_KEY is missing, your app may fail later.
Better:
const requiredEnv = [
"DATABASE_URL",
"JWT_SECRET",
"STRIPE_KEY"
];
for (const key of requiredEnv) {
if (!process.env[key]) {
throw new Error(`Missing environment variable: ${key}`);
}
}
For TypeScript apps, many teams use schema validation libraries such as Zod to fail fast during startup/build.
Frontend frameworks often expose environment variables differently.
Safe:
NEXT_PUBLIC_API_URL=https://api.example.com
Unsafe:
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_xxx
Anything exposed to the browser should be considered public.
A common pattern:
Frontend
|
| public config only
v
Backend API
|
| secrets stay here
v
Database / services
Your deployment pipeline should decide which environment gets which secrets.
Example:
main branch
|
v
production deployment
|
v
production secrets
develop branch
|
v
staging deployment
|
v
staging secrets
Avoid manually copying .env files between machines.
Useful practices:
.env* to .gitignoreFor a small-to-medium team:
Repository
├── .env.example
├── src/
└── docker-compose.yml
Developer machine
└── .env.local
CI/CD
└── environment secrets
Staging
└── staging environment variables
Production
└── production secrets manager
A good rule of thumb:
Managing environment variables effectively across multiple environments requires a balance of developer convenience, strict security, and deployment automation. Here is the modern blueprint for full-stack…
Managing environment variables effectively across multiple environments requires a balance of developer convenience, strict security, and deployment automation. Here is the modern blueprint for full-stack developers:
.env ) to your version control system. Use a .env.example file with dummy keys to document required variables for your team..env files loaded via runtime configuration (e.g., dotenv in Node.js/Python or built-in Next.js/Vite environment handling). For teams sharing local dev setups, tools like Infisical or Doppler sync encrypted local values securely without passing around raw files.Would you like me to share a code example using Zod to validate your environment variables at startup, or discuss how to handle secret management in CI/CD pipelines like GitHub Actions?
The best approach is to treat environment variables as **runtime configuration**, keep the **variable names consistent across environments**, and use a **secret manager for anything sensitive**. The core principle comes from the Twelve-Factor App: configuration that varies between deployments should be separated from…
The best approach is to treat environment variables as runtime configuration, keep the variable names consistent across environments, and use a secret manager for anything sensitive.
The core principle comes from the Twelve-Factor App: configuration that varies between deployments should be separated from application code.
I’d structure a full-stack project like this:
.env.example # committed; names + safe example values
.env.local # developer's actual values; gitignored
src/
config/
env.ts # validates/loads configuration
Then use the same variable names everywhere:
DATABASE_URL
REDIS_URL
STRIPE_SECRET_KEY
JWT_SECRET
API_BASE_URL
LOG_LEVEL
but different values:
| Variable | Development | Staging | Production |
|---|---|---|---|
DATABASE_URL | local DB | staging DB | production DB |
API_BASE_URL | localhost | staging API | production API |
STRIPE_SECRET_KEY | test key | test key | live key |
LOG_LEVEL | debug | info | warn |
Avoid names like DATABASE_URL_PROD or API_KEY_STAGING. Your application should generally not need to know which environment's value it's reading—it should simply read DATABASE_URL. This keeps configuration orthogonal and avoids environment-specific branching.
Use a local .env/.env.local file:
DATABASE_URL=postgres://localhost:5432/myapp
REDIS_URL=redis://localhost:6379
STRIPE_SECRET_KEY=sk_test_...
LOG_LEVEL=debug
And commit an .env.example:
DATABASE_URL=
REDIS_URL=
STRIPE_SECRET_KEY=
LOG_LEVEL=debug
The .env.example tells a new developer what they need, without giving them your actual credentials.
Make sure the real files are ignored:
.env
.env.local
.env.*.local
Never put production credentials in the repository.
Don't rely on copying .env files around manually.
Instead, have your deployment platform or CI/CD system inject the variables at runtime:
Git commit
↓
CI builds application
↓
same artifact
↓
staging deployment → staging environment variables
↓
production deployment → production environment variables
Ideally, build once and promote the same artifact rather than rebuilding your application separately for staging and production.
For secrets, use a proper secret manager such as your cloud provider's secret-management service or a dedicated vault. Production credentials should be isolated from development/staging credentials, with access restricted according to who or what actually needs them.
One of the most valuable practices is to validate your environment variables when the application starts—not when a request eventually needs them.
For example, conceptually:
const config = {
databaseUrl: required("DATABASE_URL"),
stripeSecretKey: required("STRIPE_SECRET_KEY"),
logLevel: enumValue("LOG_LEVEL", ["debug", "info", "warn", "error"]),
};
Then:
// Good
db.connect(config.databaseUrl);
// Avoid scattering this throughout your application
db.connect(process.env.DATABASE_URL);
This gives you an immediate error such as:
Configuration error:
DATABASE_URL is required
instead of discovering the problem 20 minutes into a deployment.
Not every environment variable is necessarily a secret.
Secrets:
Non-secrets:
LOG_LEVELPORTThis distinction makes it possible to keep harmless configuration in deployment configuration while giving highly sensitive values stronger access controls.
Also remember that environment variables aren't magically encrypted once they're inside a running process; they can potentially be exposed if the machine/process is compromised.
There's one especially important trap:
Never assume an environment variable is private just because it's called an environment variable.
Frameworks such as Next.js, Vite, React tooling, etc. can deliberately expose certain variables to browser code.
For example, conceptually:
DATABASE_URL → server only
STRIPE_SECRET_KEY → server only
NEXT_PUBLIC_API_URL → browser-visible
Anything sent to the browser should be treated as public.
So I'd enforce a rule:
Server secrets may only be accessed by server-side code.
For most teams, I'd use:
┌────────────────────┐
│ Git repository │
│ │
│ .env.example │
│ application code │
└─────────┬──────────┘
│
CI/CD pipeline
│
┌─────────────┴─────────────┐
│ │
Staging secrets Production secrets
│ │
Staging deployment Production deployment
And locally:
Developer machine
│
└── .env.local
│
└── local development
For a small project, platform-provided environment variables may be completely sufficient. As the team grows, move secrets into a dedicated secret manager rather than creating an increasingly complicated collection of .env.staging, .env.production, shared drives, Slack messages, etc.
There are also tools specifically designed to synchronize environment configuration across development, CI, staging, and production; for example, dotenv's current tooling supports separate environments such as .env.ci, .env.staging, and .env.production.
.env.example, not .env.If you're building a typical TypeScript/Node + React/Next.js + PostgreSQL + GitHub Actions stack, this translates into a particularly clean setup with .env.local locally, GitHub Actions for deployment, and a cloud secret manager for staging/production.
There's one especially important trap:
Never assume an environment variable is private just because it's called an environment variable.
Frameworks such as Next.js, Vite, React tooling, etc. can deliberately expose certain variables to browser code.
For example, conceptually:
DATABASE_URL → server only
STRIPE_SECRET_KEY → server only
NEXT_PUBLIC_API_URL → browser-visible
Anything sent to the browser should be treated as public.
So I'd enforce a rule:
Server secrets may only be accessed by server-side code.
For most teams, I'd use:
┌────────────────────┐
│ Git repository │
│ │
│ .env.example │
│ application code │
└─────────┬──────────┘
│
CI/CD pipeline
│
┌─────────────┴─────────────┐
│ │
Staging secrets Production secrets
│ │
Staging deployment Production deployment
And locally:
Developer machine
│
└── .env.local
│
└── local development
For a small project, platform-provided environment variables may be completely sufficient. As the team grows, move secrets into a dedicated secret manager rather than creating an increasingly complicated collection of .env.staging, .env.production, shared drives, Slack messages, etc.
There are also tools specifically designed to synchronize environment configuration across development, CI, staging, and production; for example, dotenv's current tooling supports separate environments such as .env.ci, .env.staging, and .env.production.