Data as of Aug 25, 2026 · Based on 323 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
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.
Managing environment variables across multiple environments as a full-stack developer requires balancing security, developer experience , and deployment automation.
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]](https://google.com/goto?url=CAESfAHrOzAVabh8ezO8IpdaoPkRoHr5hwpFB11E-1nBIzyS3atGBFwWKE6I2D7zszAHJxktBftwQkm9Yqc0i1gLGbac6e66I33k7KMrhck_pC6cu3KRp7uy53WlHn2UO3CY1EratPxy_7IJGYp6sgyuD7uX3l5V_tVrGZ6Xrko)[[2]](https://google.com/goto?url=CAEScQHrOzAVXMMVrrE285UprZVUiuyp1vyFT-V6Wm3SMWVvSN0cyFlieS-qBLZ1B6aW4LEGOD8PnIsYjZ_pgA68XyPETsuT2Rq-KTDfcUJcxpeHmSE7tjmVEg93Irh4qLjknA5FBxu3rTm6VfwTgkXeMYA3)[[3]](https://google.com/goto?url=CAEShQEB6zswFU27t8pQrIzDkN8-wBrDUNzwbxX4e042Ccjz3PmdX4AfBa01bRJb7_9sT169Af-psjqIWHpqobyWQVgKaEKa3emJWtRII3yLzoOtU8nkJyMhPjJ7e4UzUDD-LDbKDS0WSudzUM4N0ws3ZEf_7iXYeSAU4EZIi-k8KDgwrxF8gcnn)[[4]](https://google.com/goto?url=CAESXQHrOzAVWOxhgCTpPT78Lr_UiswBwfUx5IMRIy1K5BKY5rohhhODZJLtwGv1hV44sCuSdjW5hKaW2yJDCICJkv_IhpZPOVTG1RqaQOfUS8riWYxBU-qxSjFj45GRBQ)[[5]](https://google.com/goto?url=CAESbgHrOzAVuMO6Qr0zH-Mr5nOR6S895m7pE7WmkafVFjbpMXXpOL8m-In8SNw0LTd-Od4oEjJzNARnQMj1F9IGp1xG9kwOyI3LDXr6XE0YXVgHTmj4WTGl78jZrSLIdyzQ2QfYJnCx__d67FWrX9AH)
- 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]](https://google.com/goto?url=CAESkQEB6zswFdXXrGpqu1KLKI8apiHXJ27yjSU8i5UmDCPi8H1SijR2JlRXlZlRjG0u3sFBJgJbBPBpIfTuDBZTWpZC3_ACXAI8mLfw200jXJYW-DcNECxuFKdKxWlvN9rGAtohXL-uyLcvQwNU1JDqPu1jnwP1b8TieQb18W-7bFUhZNqFLADIb_CBRyUfq9phS_8j)[[2]](https://google.com/goto?url=CAESSwHrOzAVTgucZtnsLbRp2ga5CvrJJ8L-e3_O6DEk7fVnJqdGe1mzMA-vxwXuxygZwGfDI2_OcYjuS1GHG66Suidcyrw1Fq2_6M6dsw)[[3]](https://google.com/goto?url=CAESjAEB6zswFe2cYoKCa38WO_Ty3xHxT8g5bZH_Sjr7KeDD8o4BNdpbcz84-O-gVMHpdkzLqbYB5lObnFpPh8F1Xt78eRB0JDRpHlKq3Nbza0oa4t2AabPGfyAgKsiZUfagBYxvStLjOWwDHN42kRgcfkg59nuEVfYXxIjI6weunvpA18FIZR1WVLat5tdw_Q)[[4]](https://google.com/goto?url=CAEScAHrOzAVPgTm4GTtinQOwkEElAGt8iqb-RiubpkZ0Bx4kHlrMR9EMPuDwChT9bIthJJh6kcZY_dazwFj_U-1Y1OfYFtiS00ESLgbM1SJhyxqn8qTimMSGc8VGgGGUYbUjA7dE32uHFHYB0iRCIU4AmY)[[5]](https://google.com/goto?url=CAEShAEB6zswFbbGgOrrtvFVqFpXb3JtS-OkP0-Fo26CrN-3wfvj78H3fg1yFkty9baDU0ThbTIP_h2xImAe6bhfG4B5gNs6RIPFPpcI8G9csER6ePFza13I3GlnfHITTckuac4M-5qgpM3WaY8HtXGA-ZNpnTE5IQUgSDzqQPsE6OI3CVhe5YY)
- 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]](https://google.com/goto?url=CAESfAHrOzAVJyVEgc2INRhuWrQUDW4u6RBbA2dRvwR7TenYgjXL2BB0qWcBj8S25pptiB11W_DZk_fsZc8YQ3rA6PwS8IwdTKPHzIVSCIoW235274enQUuT7BLGI1qrAp9SBfFIyKJni_fFNM8gYt_s3uq_fdXxJEox9sA1WsA)[[2]](https://google.com/goto?url=CAESigEB6zswFeGtKBMglaLA1SKvF730EVnTIo9YCU2yShGNmkQOfHsqfDQAhssdMrX9YE11hryS6n-yWKE3v_E1_qD7nY9m0L4W18nr2dlkKsSrcOMZQUcAnFPKLLeMi1mv5bIZgKXhWCmJW9v_isBnQtaHsClzhr4j-jX5YraXwNuyFCowLk-lUaSbEQU)[[3]](https://google.com/goto?url=CAESrwEB6zswFTNUMy9uwYrChd3nXsW7Blu2mInlWSNjTtblAHx3SHriWiavHremfqDaCvu8gsBmFrYbyPr4rQQJR-sqmDxtqbAxQT8d__C9on3tgH52pnlOCFYH4D8Ft8giCTkXX4LwHhAxWMD55elt4PKMYMGkauPfDzz19YFZ5xrFdps2OBaGEyqSpp-BW6xqvtDTnURjuJv7-n74xhAEPKhH8zBGvh6xzjpwmGoN2aQN)[[4]](https://google.com/goto?url=CAESfQHrOzAVGqCQ_2rdViYWdVdXfve0G0q4R-xvcfcR5LsVyg_ze7huVmgWgbgSLjYVzU5Srm7MgvQsZujO30MXxMkxVt3TBdhSH6KEw15ZV4g-gdwilBY2f-lUW5dwTT88YzaZpiJOHeFWwonTG5A5Y2-p8RSRgu3wNAsttzoE)[[5]](https://google.com/goto?url=CAESbgHrOzAVAo1QIsPBuHB9TWitHLWSMpmkPra45WwYnYLZMj1HTbyaBKP1qwGoIYqNK4LzvzeqoEm7NRcudb0jwb_FRJhdxLiQPW8TtnqSZlaVRqdo1wZ46eheoFPI2wuzwmZABhkL5QOPvq64A27F)
- 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]](https://google.com/goto?url=CAESagHrOzAV1Zvd08NThiBsuwh157Iw9NCezokJViX567LYOns3C4x5ZnFQuQQlhiEmf6Yp85R8bwVrL2GBFM_VCpBxe88cAZtoA1Rfxcuy0t4qB4dCdzyQPfHCeNmhlKSN-rEsLre6jkC_Ky8)[[2]](https://google.com/goto?url=CAEShQEB6zswFdqYzEF91AHIm8bLQ5_L3WNcqMZfDp0kWBC6B3w0cSW4vsKL7wzdCPSlXgVOG6K7jluXkHW9ezZcDaX6Qr4QtUz6DdFvDPjXpLOyZtYl-qK8cjJuc5qNXuMznkq7ZPQMQhNfPaYFWU6cSsame6wt3X3hn750fqV8HqWcQdxkiwSe)[[3]](https://google.com/goto?url=CAESbgHrOzAV6OTPnuz8pdrrGm6pv0P6nqNcyAJQVbRll6-3G0FcEH0wF5H1hUC1YwtGqk6NgzaVUL-9fp9ITAKEY-qlGkWYVaOHjZzu3FdjtcfGruMDtTIYRn9vyJBj1Gw2j8lZCAcNBusx2VDwP_Dt)[[4]](https://google.com/goto?url=CAESbAHrOzAVn7lFmetmJOvEGHFRC-XZNtUZZJIr2oZjhsnZwKkVTRrZ8onzh19cZFbzo75qio51uvQWdKQ3uau8phaAUODR-L135vPImY_A5LisB8fKlogb3ry7p4RbCsiPjS0s4_9P8ZIRdGT77Q)[[5]](https://google.com/goto?url=CAESbwHrOzAVa5YbFQ8DVrA853TsyEpYmGgvW2V6fyMoTA-5LG-u1QhCq-kgUmwz2oNM5V3J4XaXFdGTYPySmI3EnSRrwihCo2orCU7JppUz9POcydePrI62CPt-8cKAmefLa5RUlIKMyWyfxdx0QaRCNA)
- 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]](https://google.com/goto?url=CAESVAHrOzAVkrJBzOoJD2-8McK36gIYYEnee6MzB9uOf8d2eZjx3OFsK4s_80HCvJiJKVor1mmiUr_6EXmcPjaNKZem9KIOP36p7_wDTw7Ine7YSDWkGg)[[2]](https://google.com/goto?url=CAESYAHrOzAVuOzTHF9q8FKGt7JPS6TzR-bdmWrz9LSeeL-_vW-mLTm21Kei4eIFMWas2aT6HNy0HI0iE7cxVnfSlHMgPvef5mXeMupyQHg0Dp9jO94qNhn6Tw7sYw9ZcxD6pg)[[3]](https://google.com/goto?url=CAESggEB6zswFQpZmXsvforXtv8jlUH9yWYdyPSFmzcSzBVoypI2RsRgfz6wIQuiIjS7oA1ZaO1QGWpD2Y4oTkZRO6IdmdqS0HBT4YsBRYEkMP5DPZS-ZGTH7uxZ2fJRqELK1HNPYRqYlvXY2uBT39YL_GceeFSvwZip6lLZ6C4PQNx1hGuR)[[4]](https://google.com/goto?url=CAESZwHrOzAVbB81weKF5pZ-vlkpiIDhc6JZ0NdGenJIeiYW3ZA0mxRuFdgowzpJQAzE-ttK9nZtpvD3KW1Nu0_QmTeWynHhAGqN4K_dIDUT_VYU2xcRl_XWSHEPBRroMccxjDYNFqMwSb8)[[5]](https://google.com/goto?url=CAESngEB6zswFVy-eJuwe41EaOOj3sbE3Ho32bE6lahcu2XmXCUoeF1VP_nDAJKCtqOU9A_uxy94zlHEm6ktur31dsWpoWdIda1ebD52BY17MZmr5pVqnp0pjm9G5--W-WpxrVYnHCQpRVWDljMZZpl32KbE9P6XpkPYBqkUwUFYue0A5_Kbv4Wa8UiKrRMOQafYISppLWXKsdvGdYFkzXgbdA)
If you'd like, let me know:
I can tailor a more specific configuration setup or tool recommendation for your project.
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 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 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.
The best approach is: one codebase, one build artifact, environment-specific configuration, and a dedicated secret store for sensitive values. This follows the core idea of the Twelve-Factor App: configuration that varies between deploys should be separated from code. www.12factor.net
| Environment | Non-secret config | Secrets |
|---|---|---|
| Local | .env | .env or local secret manager |
| CI/tests | CI variables | CI secret store |
| Staging | Deployment/platform config | Secret manager |
| Production | Deployment/platform config | Secret manager |
For example:
DATABASE_URL
REDIS_URL
API_BASE_URL
LOG_LEVEL
STRIPE_SECRET_KEY
JWT_SECRET
Your application should read the same variable names everywhere:
const config = {
databaseUrl: process.env.DATABASE_URL,
apiBaseUrl: process.env.API_BASE_URL,
stripeSecretKey: process.env.STRIPE_SECRET_KEY,
};
The values change; the code does not.
Keep something like:
.env
.env.example
.env contains your actual local values and goes in .gitignore.
.env.example is committed and contains only the required variable names:
DATABASE_URL=
REDIS_URL=
API_BASE_URL=http://localhost:3000
STRIPE_SECRET_KEY=
This gives new developers an obvious contract without putting credentials in Git.
Don't commit:
.env.staging
.env.production
with real credentials.
Instead, inject configuration through your deployment platform. For example:
GitHub Actions
↓
deployment
↓
staging/prod secret manager
↓
application process
↓
process.env
For production secrets, use something like your cloud provider's secret manager or a dedicated secrets-management system. OWASP recommends centralized storage, controlled access, encryption, auditing, and rotation of secrets.
Importantly, staging and production should have completely different credentials. A staging database should never use the production database password, and developers shouldn't routinely have access to production secrets.
This is one of the biggest quality-of-life improvements.
Instead of discovering three hours into debugging that DATABASE_URL is missing, validate configuration when the application starts:
const envSchema = z.object({
NODE_ENV: z.enum(["development", "staging", "production"]),
DATABASE_URL: z.string().url(),
API_BASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().min(1),
});
export const env = envSchema.parse(process.env);
Then use:
env.DATABASE_URL
throughout the application rather than repeatedly accessing process.env.
That gives you fail-fast behavior and a typed configuration contract.
Be very careful about client-side environment variables.
Anything bundled into a browser application is effectively public. For example:
VITE_API_URL=https://api.example.com
is fine if it's intended to be public.
But:
VITE_STRIPE_SECRET_KEY=...
VITE_DATABASE_PASSWORD=...
VITE_JWT_SECRET=...
is a serious mistake. A browser user can inspect the resulting JavaScript and retrieve it.
So I generally divide variables into:
PUBLIC_* → safe to expose to browser
SERVER_* → server only
SECRET_* → server only, managed as secrets
The exact prefixes depend on your framework (Next.js, Vite, Nuxt, etc.).
For a typical full-stack application:
┌──────────────────┐
│ Git repository │
│ │
│ .env.example │
│ application code │
└────────┬─────────┘
│
same build/artifact
│
┌────────────────┼────────────────┐
↓ ↓ ↓
LOCAL STAGING PRODUCTION
.env platform platform
/local config config
+ secrets + secrets
The key principle is "build once, configure at runtime." You don't want one Docker image built for staging and another subtly different image built for production. The same artifact should be promoted through environments while its configuration changes.
.env.example.process.env or configuration objects containing secrets.OWASP specifically recommends preventing secrets from entering repositories and build artifacts, limiting access according to least privilege, and ensuring CI/CD output doesn't leak credentials.
In short: .env is excellent for local developer convenience, but it shouldn't become your production secret-management system. For a serious full-stack project, I'd use .env.example + ignored local .env + typed validation + CI/CD variables + a proper production secret manager.
Managing environment variables effectively across multiple environments requires a balance of security, developer ergonomics , and automation . Here is the best approach for a full-stack developer to handle secrets and configuration safely and cleanly.
.env file for local overrides. Never commit this file to version control; instead, commit a .env.example file containing placeholder keys (no actual values) so the team knows what variables are required.If you'd like, let me know:
I can give you a tailored setup guide or a sample Zod validation schema.
The best approach is one codebase + one consistent variable schema + environment-specific values injected at runtime.
That follows the core “config lives outside code” principle: the same application/build should work in development, staging, and production without changing source code.
1. Keep the variable names identical everywhere
DATABASE_URL
REDIS_URL
API_BASE_URL
STRIPE_SECRET_KEY
JWT_SECRET
LOG_LEVEL
Only the values change:
| Variable | Development | Staging | Production |
|---|---|---|---|
DATABASE_URL | local DB | staging DB | prod DB |
API_BASE_URL | localhost | staging API | production API |
LOG_LEVEL | debug | info | warn |
STRIPE_SECRET_KEY | test key | test key | live key |
Don't create separate code paths like if (ENV === "production") just to select configuration. Environment variables should be independently managed per deployment.
.env locally, but never commit itA common structure:
.env # your actual local values — gitignored
.env.example # variable names/placeholders — committed
For example:
# .env.example
DATABASE_URL=
REDIS_URL=
API_BASE_URL=http://localhost:3000
STRIPE_SECRET_KEY=
JWT_SECRET=
.env.example becomes the contract for developers: if a new variable is required, add it there.
There are really two categories:
Configuration
LOG_LEVELAPI_BASE_URLPORTSecrets
For staging/production, secrets should preferably live in a dedicated secrets manager rather than in Git or baked into Docker images. OWASP specifically recommends centralized secrets management, access controls, rotation, and auditing.
Examples include your cloud provider's secret manager or a dedicated system such as Vault.
A good deployment flow looks like:
Git repository
↓
CI build
↓
Immutable artifact / Docker image
↓
┌──────────┬──────────┬────────────┐
↓ ↓ ↓
Dev Staging Production
↓ ↓ ↓
env vars env vars env vars
Ideally, build once and promote the same artifact rather than rebuilding the application separately for staging and production. This prevents subtle differences between environments.
Your CI/CD system or deployment platform supplies the appropriate values at deployment time. OWASP recommends limiting which CI/CD identities can access which secrets and avoiding secrets appearing in build output or logs.
This is one of the highest-value practices that developers often skip.
Instead of letting this happen:
connectToDatabase(process.env.DATABASE_URL);
and discovering 20 minutes later that DATABASE_URL is missing, validate configuration when the application starts.
Conceptually:
const config = {
databaseUrl: required("DATABASE_URL"),
jwtSecret: required("JWT_SECRET"),
port: number("PORT", 3000),
};
Then the rest of your application consumes config, rather than accessing process.env everywhere.
This gives you fail-fast behavior and makes configuration typed and explicit.
A useful permission model is:
Developer
├── local secrets
└── maybe staging secrets
CI/CD
├── staging deployment credentials
└── production deployment credentials
Production application
└── only the production secrets it actually needs
Don't give every developer access to every production secret. OWASP recommends least-privilege access and separating production secrets from development secrets when appropriate.
I'd make these non-negotiable:
.env is in .gitignore.ARG/ENV instructions during image builds. .env.example.For a typical full-stack app, I'd use:
Git
│
┌────────┴────────┐
│ │
.env.example Application code
(committed) │
│
reads config
│
┌───────────────┼───────────────┐
│ │ │
Local Staging Production
│ │ │
.env CI/CD + secrets Secret manager
│ │ │
└───────────────┴───────────────┘
In short: use .env files for convenience on developer machines, a committed .env.example as the schema, CI/CD/environment configuration for deployment-specific values, and a proper secrets manager for sensitive staging/production credentials. The application should see the same variable names everywhere and fail immediately if its required configuration isn't present. This combines the simplicity of environment-based configuration with the security controls that plain environment variables alone don't provide.
Managing environment variables effectively across development, staging , and production is crucial for security, stability, and developer velocity. The "best" approach combines modern tooling, strict separation of concerns, and secure storage.
Here is the best-practice blueprint for a full-stack developer:
.env, .env.* (except .env.example ), and local secret files to your .gitignore immediately..env.example (containing only variable names with empty or dummy values) and commit it to Git so other developers know what variables are required..env files works, but keeping them synchronized across a team is painful.doppler run -- npm run dev).process.env against a strict schema the moment the server boots. If a variable is missing or invalid, the app should fail fast and refuse to start.NEXT_PUBLIC_ or VITE_).If you'd like, let me know:
I can give you a tailored configuration setup or a Zod validation example for your project.
Managing environment variables cleanly across multiple environments is critical for security and smooth deployments. The best approach combines centralized management, strict separation , and automation.
Here is a breakdown of the industry-standard way to handle this as a full-stack developer:
.env Files for Local Development : For local development, keep variables in a .env file at the root of your frontend and backend projects.
.env.example file to Git (containing only the variable names with empty or dummy values) so other developers know what variables are required (PORT=,𝐷𝐴𝑇𝐴𝐵𝐴𝑆𝐸_𝑈𝑅𝐿=)..env, .env.development , and .env.production to your .gitignore immediately.If you want, let me know:
I can give you a more tailored setup guide or a sample validation schema.
The best approach is to treat configuration as deployment-time data, not application code, and to distinguish ordinary configuration from secrets.
A good full-stack setup looks like this:
Your application should know which variables exist and what types they have, but not hard-code their values.
For example:
DATABASE_URL
REDIS_URL
API_BASE_URL
STRIPE_SECRET_KEY
JWT_SECRET
LOG_LEVEL
FEATURE_X_ENABLED
The same application artifact should be able to run in development, staging, and production simply by receiving different values. This follows the Twelve-Factor principle of separating config from code.
.envA common structure is:
.env # developer's actual values — never commit
.env.example # variable names/documentation — commit
.env.test # optional test-specific defaults
For example:
# .env.example
DATABASE_URL=
API_BASE_URL=http://localhost:3000
STRIPE_SECRET_KEY=
LOG_LEVEL=debug
Then each developer has their own .env.
Never commit real secrets. .env.example should contain names and safe defaults, not credentials.
Don't let your application discover a missing variable three hours later when a user hits a particular endpoint.
Have one configuration module that parses and validates everything:
const config = {
databaseUrl: requiredEnv("DATABASE_URL"),
apiBaseUrl: requiredEnv("API_BASE_URL"),
stripeSecretKey: requiredEnv("STRIPE_SECRET_KEY"),
logLevel: env("LOG_LEVEL", "info"),
};
Even better, use a schema validator such as Zod in a TypeScript project:
const envSchema = z.object({
DATABASE_URL: z.string().url(),
API_BASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().min(1),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});
export const env = envSchema.parse(process.env);
This turns configuration mistakes into immediate deployment/startup failures.
Don't maintain production .env files on your laptop or in Git.
Instead, use your hosting platform's secret/configuration system or a dedicated secrets manager. Examples include AWS Secrets Manager, Azure Key Vault, Google Secret Manager, HashiCorp Vault, etc.
For example:
Git
│
application code
│
┌──────▼──────┐
│ Same Docker │
│ image │
└──────┬──────┘
│
┌──────────┼──────────┐
▼ ▼ ▼
Dev Staging Prod
│ │ │
.env secrets secrets
AWS, for example, recommends centralized secret storage, access control, monitoring, and rotation rather than putting credentials directly into source code.
Not everything needs a secrets manager.
Usually fine as ordinary environment configuration:
LOG_LEVEL=info
API_BASE_URL=https://api.example.com
PORT=8080
FEATURE_X_ENABLED=true
Should be treated as secrets:
DATABASE_PASSWORD
JWT_SECRET
STRIPE_SECRET_KEY
AWS_SECRET_ACCESS_KEY
PRIVATE_KEY
OWASP specifically recommends centralized management, restricted access, auditing, and rotation for secrets. It also cautions that environment variables themselves aren't a perfect secret-storage mechanism because processes and diagnostics can potentially expose them.
This is especially important for full-stack development.
Anything bundled into browser JavaScript is effectively public.
So:
NEXT_PUBLIC_API_URL → okay to expose
NEXT_PUBLIC_STRIPE_KEY → okay if it's a publishable key
STRIPE_SECRET_KEY → NEVER expose
DATABASE_URL → NEVER expose
JWT_SECRET → NEVER expose
A frontend environment variable isn't automatically secret just because it came from .env.
A useful permission model is:
Pull request → test secrets
Staging deploy → staging secrets
Production deploy → production secrets
Don't give every developer or every CI job access to production credentials.
For production especially, use least privilege and preferably short-lived credentials where your infrastructure supports them. OWASP recommends limiting who can access secrets and regularly rotating them.
I'd generally commit something like:
.env.example
docs/configuration.md
src/config/
The repository should answer:
"What configuration does this application require?"
But not:
"What are the production credentials?"
For a larger application, you can make the configuration schema itself the source of truth and generate .env.example from it.
For a typical TypeScript/Node full-stack application:
┌──────────────────┐
│ Git repository │
│ │
│ config schema │
│ .env.example │
│ application code │
└────────┬─────────┘
│
same build artifact
│
┌──────────────────┼──────────────────┐
│ │ │
Local Staging Production
│ │ │
.env secret store secret store
│ │ │
└──────────────────┼──────────────────┘
│
validated config
│
Application
The key principle: environment-specific values change; the application build doesn't.
That gives you reproducible deployments, makes staging much closer to production, and avoids the common anti-pattern of having config.dev.ts, config.staging.ts, and config.prod.ts full of different hard-coded values. The Twelve-Factor methodology explicitly favors independent, deployment-specific configuration rather than proliferating environment-specific configuration bundles.
One final rule I'd strongly enforce: if a secret ever gets committed to Git, assume it is compromised and rotate it—even if the commit is later deleted. Secret-scanning in CI/pre-commit hooks is a useful additional safety net.