Start with one table: every variable the app reads down the side, and development, staging and production across the top. The environment variables security risk that hurts small apps is rarely the mechanism. It is a cell in that table holding the wrong value: a live Stripe key in a preview deploy, or one production database URL on three laptops.

What environment configuration separation is

Environment configuration separation means development, staging and production each have their own value for every secret, their own database and their own account or mode at each third party. The proof is an inventory that shows where every value lives, and never the value itself.

The rule I work to is that nothing done in one environment can change another, so payments, email, storage and the AI provider each get a separate account or mode per environment, not just a second key. What each environment is for, and what staging has to isolate, is worked through in one database and no staging; this page covers the variables, databases and keys that make the split real.

The inventory is one table with a row per variable. Here is my template with four rows filled in:

VariableWhat it is forSecret?Development value fromStaging value fromProduction value fromWhere it is setOwnerLast rotated
DATABASE_URLThe app’s database connectionYesA local database on the laptopThe staging database, its own roleThe production database, its own roleHost dashboard per environment; laptop .env for development onlyTech leadAt launch
STRIPE_SECRET_KEYServer-side payment callsYesA sandbox keyA second sandbox’s keyA live restricted keyHost dashboard for staging and production; laptop .env for developmentFounder (owns the Stripe account)YYYY-MM-DD
EMAIL_API_KEYSending transactional emailYesA key that only reaches a catch-all inboxA catch-all inbox, or the provider’s test mode if it has oneThe production sending keyHost dashboard; CI secret for the email preview jobTech leadWhen the last contractor left
AI_API_KEYModel calls from the serverYesEach developer’s own low-limit keyA staging project keyA production project keyHost dashboard; developers’ .envFounderNever: flag it
The inventory never holds a value, only where the value lives.

Two columns do most of the work. The three value columns should never show the same secret twice, and the “where it is set” column lists every screen and file a rotation has to touch. If you want the basics of what a variable and a .env file are before you fill it in, see environment variables and .env files explained. The other controls for keys and tokens in this area are in secrets management for an AI-built app.

Environment variables security risk: are they safe, and where they leak

Environment variables usually hold a server’s secrets, and anything that runs as the app can read them. They leak 8 ways: a committed file, a browser prefix, logs, child processes, container images, preview deployments, copies on laptops and a breach at the host. For containers, OWASP prefers a mounted secret or secret store where possible; otherwise, handling decides the risk.

My honest answer to “are environment variables secure”: on a managed host they are the normal place for a server secret and safer than a key written into source code, but every process running as the app can read them. OWASP’s secrets management cheat sheet is the reference for storing secrets, and its container section carries the warning that belongs next to that answer: environment variables “are generally accessible to all processes and may be included in logs or system dumps. Using environment variables is therefore not recommended unless the other methods are not possible.” The other methods it lists are a secret file mounted by the orchestrator and a secret fetched from a secret store into memory.

So where the host or orchestrator offers a secret store or a mounted secret, use it. Stripe’s own key guidance takes the same order: a secrets vault provided by the hosting platform first, and “If you can’t use a secrets vault, use environment variables to provide keys to your backend applications.” Where a dashboard variable is the only option, the eight paths below decide the risk. They are also the answer to “why shouldn’t you use env variables for secret data”: each row is a way the value gets out, with its fix.

Leak pathHow the value gets outThe fix
A committed .env fileThe file lands in git history, so every clone and fork carries the valuesCommit only the example file, keep .env ignored, rotate anything that was ever committed
A browser prefixA name the framework treats as public is bundled into JavaScript anyone can downloadSecrets on server-only names; the browser gets publishable values only
Logs, error pages and crash reportsA debug page, error reporter or startup log prints the whole environment or a connection URLNever log the environment; redact connection strings in error output
Child processes and build scriptsTools, scripts and CI steps the app starts can read the same values, and a step that echoes them writes them into CI logsGive each step only the names it needs; mask secrets in CI output
Container imagesA secret set with ENV or a build ARG travels with the imageSecret mounts at build time; run-time values injected at start
Preview deploymentsA preview scope that holds production values connects every branch to live dataPreview-only values and a separate database
Copies on laptopsA production .env shared once stays on every machine and backup it touchedDevelopers hold development values only
The host that stores themEveryone with access to the project, or anyone who breaches the host, can reach the valuesFew people on the project, the host’s write-only secret type where it has one, and a rotation plan

Five rows have their own pages. The committed file is covered in the explainer linked above. How a public prefix ships a key to the browser is in API keys exposed on the frontend, and pipeline secrets and CI logs are in CI/CD security best practices. To check a preview, start from what a preview isolates, and what it does not. The host row has a real case behind it, told in whether the Vercel breach exposed your environment variables.

In the 21 third-party apps I audited in June and July 2026, 6 shipped a real secret, and Secrets & Credentials was still the best-scoring of my 12 pillars, averaging 84.4 out of 100 across all 21. Those 21 are two groups: 11 public apps audited exhaustively across all 12 pillars and 10 disjoint apps audited blind, chosen for audit rather than drawn at random, so the counts describe that set and are not a rate for AI-built apps in general.

Docker environment variables security

Docker’s build docs say a secret passed as an ENV value or a build ARG persists in the final image, where anyone who can pull the image can read it. Pass secrets at run time from a secret store instead, and promote the same image, unchanged, from staging to production.

Docker’s build secrets docs call build arguments and environment variables “inappropriate for passing secrets to your build, because they persist in the final image”, and point to secret mounts or SSH mounts instead: docker build --secret id=aws,src=$HOME/.aws/credentials . on the command line and RUN --mount=type=secret,id=aws in the Dockerfile. The Dockerfile reference says where the values show up: ENV values can be viewed with docker inspect, and build arguments “are visible in the docker history command”.

At run time, OWASP’s advice is to let the orchestrator overwrite the variable with the actual secret, never to hardcode it with ENV or ARG. Whether a value passed with docker run appears when someone inspects the running container is not stated in Docker’s docs, so I treat anyone with access to the Docker host as able to read it.

My two rules: nothing secret in a Dockerfile, and one image promoted unchanged from staging to production, with only the run-time values differing. If staging needs its own build to work, it is not testing the image production will run.

What goes wrong without it

Testing with live credentials can affect real customer data and transactions. Each row below traces a failure back to one wrong cell of the inventory; the cost column is my reading of what it does to a small app.

What happenedWhich cell of the inventory was wrongWhat it cost
Staging writes to the production databaseStaging’s DATABASE_URL holds the production URLA test migration, or the “delete all” at the top of a seed script, runs against customers’ rows
Live Stripe keys used in stagingStaging’s STRIPE_SECRET_KEY holds a live keyA test checkout charges a real card, or a staging webhook handler marks a real invoice paid
Staging emails real customersStaging holds a copy of the user table and the production email keyPassword resets and receipts from a test run land in real inboxes
A preview deployment has production secretsThe preview scope holds production valuesEvery pull request gets a URL wired to live data
A command writes to the wrong environmentThe target environment itself: a secret or deploy sent without its environment flagThe value lands in the tool’s default environment while production keeps the old one
A laptop is lost or a contractor leavesProduction values sit in a laptop .envProduction credentials leave with it, and every key needs rotating at once

The staging article linked at the top covers the first row in depth. The Stripe side of going live, test objects to live objects, is in moving Stripe from test mode to live. For a Supabase app, the retrofit of a second project is in a Supabase staging environment. When a laptop or a contractor goes, the order of work is in how to rotate API keys safely.

Cloudflare’s own postmortem shows the fifth row at full size. On 21 March 2025, while rotating the credentials its R2 Gateway service uses, engineers ran wrangler secret put and wrangler deploy without the --env parameter, and the post explains that “both wrangler secret put and wrangler deploy commands default to the default environment if the --env command line parameter is not included”, so the new credentials went to a development instance of the service instead of production. The old credentials were deleted at 20:37 UTC, which left the production R2 Gateway service without access to the new credentials and unable to authenticate with its storage backend; because the deletion propagated with a delay, the impact only began at 21:38. For 1 hour and 7 minutes, until 22:45, 100% of write operations and approximately 35% of read operations to R2 failed globally and other Cloudflare services that depend on R2 were affected; the post reports no data loss or corruption. Among its next steps, Cloudflare added logging of the credential ID’s suffix, now requires explicit confirmation that the new token ID’s suffix matches its storage logs before the previous token is deleted, and requires key rotation to go through its hotfix release tooling, which enforces the environment configuration. The lesson I take from it: the environment a command writes to is a value like any other, so check it, then confirm the new credential is the one in use before the old one is deleted.

How to do it: variables, databases and keys per environment

Separating environments is 7 pieces of work in order: the variable list, the example file, the platform scopes, any move between hosts, the database credentials, the payment keys with a startup assertion, and the AI tool config files.

Every platform and vendor behavior below is taken from that vendor’s own documentation, named where it is used; the order and the rules around it are mine.

The environment variables setup checklist

An environment variables setup checklist has 8 steps: list every variable the code reads, mark the secrets, create staging and production resources, set values per environment on the host, give developers development values only, add the startup assertion, fill the inventory, verify.

  1. 01 List every variable the code reads. Search the codebase for process.env, import.meta.env and os.environ, then read the framework config file for any names it loads on its own.
  2. 02 Mark each variable secret or not. A secret is anything that grants access or spends money: keys, tokens, passwords, connection strings, signing secrets.
  3. 03 Create the staging and production resources: a second database, a test-mode or sandbox account at each third party, and separate API keys named for their environment.
  4. 04 Set values per environment in the host dashboard scopes, never in the repository.
  5. 05 Give developers development values only. Nobody needs a production key to run the app on a laptop.
  6. 06 Add the startup assertion that refuses a live payment key outside production. The Stripe section below has the code.
  7. 07 Fill the inventory: one row per variable, one column per environment, and where each value is set.
  8. 08 Run the verification checks further down and keep their output with the inventory.

Platform settings that quietly undo this work after you finish are covered in what are insecure defaults on managed platforms.

What goes in an env example file

An env example file is the committed list of every variable name the app reads, each with a comment and a harmless placeholder. It holds no real values and no production hostnames, and a CI check can fail when the code reads a name the file does not list.

The naming rules and the every-name-no-values pattern are in the .env.example section of the explainer linked earlier. What I add is a comment on each name saying what it is, where a developer gets a development value and whether it is secret, which turns the file into the inventory’s development column:

# .env.example: names and placeholders only, never a real value

# Postgres connection. SECRET. Dev: your local database, see the README.
DATABASE_URL=postgres://app:changeme@localhost/app_dev

# Stripe server key. SECRET. Dev: a sandbox key from the Stripe Dashboard.
STRIPE_SECRET_KEY=sk_test_replace_me

# Email provider key. SECRET. Dev: a key that only reaches the catch-all inbox.
EMAIL_API_KEY=replace_me

# Which environment this is. Not secret. development, staging or production.
APP_ENV=development

A short CI step that compares the names the code reads with the names in this file, and fails when one is missing, keeps the file honest. The new developer’s side of it is in when you cannot run the project locally.

Platform environment variables: AWS, EC2, Vercel and Render

Every host sets variables per environment on a different screen, and every host has a deploy credential that can change all of them: an AWS role or access key, a Vercel token, a Render API key. Both the screens and the credentials belong in the inventory.

On AWS, the AWS Command Line Interface (CLI) and the SDKs read a documented set of environment variables: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY for an access key, AWS_SESSION_TOKEN for temporary credentials, AWS_REGION or AWS_DEFAULT_REGION for the region, and AWS_PROFILE for a named profile. According to the AWS CLI’s environment variables page, AWS env variables override the matching settings from a profile in the config file, and a command-line parameter overrides both.

My working rule is that a long-lived access key in a variable is the pattern to move away from. AWS calls access keys long-term credentials and recommends temporary ones such as IAM roles, and with IAM roles for Amazon EC2 the SDKs and CLI fetch temporary credentials from the instance on their own. For EC2 environment variables, that means the AWS credentials never go in them at all. The split itself is a separate AWS account per environment, or at least a separate role for each.

Vercel’s environment variables docs apply each variable to one or more of Production, Preview, Development and custom environments, and each variable is a Config or a Secret; in the words of the sensitive-variables page, “Secret values are write-only after saving.” For staging, Vercel lists a custom staging environment on Pro and Enterprise, and a preview branch with its own domain and branch-specific variables on all plans, including Hobby. The same guide warns: “Staged production deployments use production environment variables, so testing can access production services and data.” The Hobby plan page says Hobby “restricts users to non-commercial, personal use only”, so a paying product’s staging and production sit on a paid plan either way.

Vercel can also enforce the no-shared-secret rule for you. A team owner can turn on Require Separate Values, after which “each Secret must use a different value in Production than in Preview, Development, and custom environments.” The Vercel API token the CLI and REST API use is scoped to your full account, one team or one project, with an expiration you pick when you create it, so it belongs in the inventory like any key.

Render’s environment variables docs set values on each service’s Environment page, with an Add from .env option for bulk entry, and an environment group can be scoped to a single project environment so no service outside it can link to it. Render projects hold the environments, and “Hobby workspaces can have up to two environments per project”, which is enough for staging and production. A Render API key is created on the Account Settings page, and the API “supports almost all of the same functionality available in the Render Dashboard”, including environment groups and projects; a key limited to one environment is not stated in Render’s docs.

PlatformWhere per-environment values are setThe deploy credential and what it can reach
AWS, including EC2Per account or role; on EC2 the app’s AWS credentials come from the instance role, not a variableAn access key (long-term) or a role (temporary credentials); it reaches what its IAM policy allows
VercelProject or team variables, each applied to one or more of Production, Preview, Development and custom environmentsAn access token scoped to the full account, one team or one project, with an expiration chosen at creation
RenderA service’s Environment page, or an environment group scoped to one project environmentAn API key from Account Settings; the API covers almost all dashboard functions; a per-environment key is not stated in Render’s docs

For each platform, the row I write down is the same: which screen sets production, which sets everything else, and which token can change both.

How to move environment variables from Vercel to Railway

Vercel keeps a Secret’s value hidden once it is saved (the platform section above), so my reading is that secrets do not come back out of Vercel, and each one gets a new value on Railway. The rotation step below does that anyway.

  1. 01 Pull the readable Config variables for one environment into a local file with the Vercel CLI, for example vercel env pull --environment=preview, which writes the Preview values to .env.local.
  2. 02 In Railway, open the matching environment, open each service's Variables tab and paste the file into the RAW Editor, then review and deploy the staged changes.
  3. 03 Repeat for every environment. Never move values through chat, email or a shared document.
  4. 04 Treat the move as a rotation: issue a new value for every secret on Railway, and revoke the old ones at each provider once traffic runs on Railway.
  5. 05 Delete the local file.
  6. 06 Update the "where it is set" column of the inventory for every row.

Railway has its own write-only option: a sealed variable’s value “is never visible in the UI nor can it be retrieved via the API”, and each environment is an isolated copy of the project’s services, as Railway’s variables docs and its environments page describe. The rotation order is the one in the rotation guide linked earlier. Leaving an AI builder’s own hosting is a bigger job, covered in how to self host an exported app.

Database connection strings are secrets too

A database connection string carries the DB password in plain text, so DATABASE_URL is a secret everywhere it appears, logs included. Development, staging and production each get their own database and their own role, and the app never connects as the owner.

The right place to store the database password is the host’s secret store or a dashboard variable set separately for each environment, never the repository and never the example file, which holds a placeholder. Check what your logs print when a connection fails: if the full URL appears, redact it before it reaches a log service.

Each environment’s role should hold only the grants the app needs; the grants, and why the owner and superuser roles stay out of the app, are in how to harden managed database settings. To change the DB password, my working rule is an overlap: add a second role with the new password, move the app to it, confirm it connects, then drop the first. Pooled and direct connection strings get two rows in the inventory, because each one is set, and can leak, on its own.

This is the app’s own database credential; how an app stores its users’ passwords is a different subject. Staging data comes from a seed script, never a copy of production, and building one is a separate task: how to generate realistic fake data for staging.

Stripe API key for testing: sandbox keys in staging, live keys only in production

Stripe test and live keys carry different prefixes, which makes a cheap safeguard possible: at startup, refuse to run when the environment is not production and the server key has a live prefix. Staging uses test-mode or sandbox keys and its own webhook signing secret.

Stripe’s API keys page gives the prefixes: sandbox keys “start with pk_test_ for publishable keys, rk_test_ for restricted keys, and sk_test_ for secret keys”, and live keys start with pk_live_, rk_live_ and sk_live_. The Stripe test public key, the publishable one, can sit in front-end code; the secret and restricted keys cannot, and what is an API key explains the difference.

A restricted key carries only the permissions you give it, and Stripe recommends restricted keys for server-side code. To get a Stripe restricted API key, open the API keys page in the Stripe Dashboard for the sandbox or account you are working in and create it there. Webhook signing secrets are separate from API keys and belong to each webhook endpoint, so staging’s endpoint has its own, and going live means copying the new signing secret for each live endpoint.

My working rule is a startup assertion: if the environment is not production and the server key has a live prefix, exit with an error that names the variable, and run the reverse check in production. It has to cover both server-key prefixes, because Stripe’s live server keys “start with rk_live_ or sk_live_”, and a check for sk_live_ alone passes a live restricted key.

// Startup: refuse a live Stripe key outside production, and a test key in production.
const env = process.env.APP_ENV; // development, staging or production, set per environment
const key = process.env.STRIPE_SECRET_KEY ?? '';
const isLive = key.startsWith('sk_live_') || key.startsWith('rk_live_');
if (env !== 'production' && isLive) throw new Error(`STRIPE_SECRET_KEY is a live key in ${env}`);
if (env === 'production' && !isLive) throw new Error('STRIPE_SECRET_KEY is not a live key in production');

APP_ENV is a name you set yourself in each environment’s scope, so staging can say staging even when it runs a production build. Checks for missing values in general are the explainer’s section on validating configuration before serving traffic. The payment side of launch is a separate subject: the payment go live checklist.

AI tool config files hold keys too

An MCP server entry can carry an API key or a database URL: Claude Code’s MCP docs pass keys with --env when a server is added, and a project-scoped server is written to a file at the project root that is meant to be committed and shared with everyone on the project. That makes it one more place a production credential can end up by accident. My rule: MCP servers used while coding get development credentials only, and the file gets its own row in the inventory. Where the Claude Code MCP config lives for each scope, and how to reference a key without writing it into the file, are in keep MCP credentials out of the config file.

How to verify it

Environment separation is verified by 7 checks. The two that matter most: compare a short fingerprint of each secret across environments and confirm none match, then create a record, a payment and an email in staging and confirm each stayed inside staging services.

Each check has something you can see pass or fail, and the first needs only a browser.

  1. 01 Compare the mappings, dashboard first. Open each host's variable screen for each environment and compare the names with the inventory. A name missing on either side fails.
  2. 02 Compare fingerprints. Inside each running environment, print a short hash of every secret, never the value, from a one-off admin-only script, and confirm no two environments match. Differing fingerprints pass.
  3. 03 Prove staging stays in staging. Create a record in staging and find it in the staging database, not production. Make a staging payment and find it only in the Stripe sandbox. Trigger a staging email and see it land in a sandbox or catch-all inbox, not a customer's.
  4. 04 Check that a preview deployment holds no production secret: its fingerprints differ from production's. The full preview isolation check is in the preview article linked earlier.
  5. 05 Set a fake value with a live prefix, never a real live key, in a local run and confirm the app refuses to start with an error naming the variable.
  6. 06 Check each developer's laptop: every fingerprint from a local .env matches development only.
  7. 07 After any credential change, confirm from the running service's own logs or response that the new credential is in use in the environment you meant, before the old one is deleted. The new fingerprint shows up where you expect it.

The fingerprints have to be computed inside each environment, where the value is readable, because a host can keep secrets write-only (the Vercel note above). A few lines are enough:

import { createHash } from 'node:crypto';
for (const name of ['DATABASE_URL', 'STRIPE_SECRET_KEY', 'EMAIL_API_KEY']) {
  const value = process.env[name] ?? '';
  console.log(name, createHash('sha256').update(value).digest('hex').slice(0, 8));
}
// staging, fake output: DATABASE_URL 0f3a9c1e  STRIPE_SECRET_KEY 7b20d4aa  EMAIL_API_KEY c41e88d2

An unset variable prints the same fingerprint in every environment, so a match can also mean a value is missing; either way the check fails. Keep the evidence: the dated inventory, the fingerprint output and the three staging traces. The promotion from staging to production that comes next is in the release readiness checklist.

In the Production Hardening Sprint, deliverable 2.4 is verified this way: we inspect environment mappings and confirm staging actions stay within staging services.

Where the sprint does this

Deliverable 2.4 of the Production Hardening Sprint, Environment configuration separation, is this page’s topic: we separate development, staging, and production variables, databases, and keys, and document the configuration inventory, and it is checked the way the section above describes. The rotation runbook is deliverable 2.6, where we document how to rotate each key, update its dependents, verify the change, and recover from a failed rotation. Both are recorded in the production readiness report, deliverable 13.1, which accounts for all 123 IDs, keeps failures visible until resolved and explains genuine non-applicable items. Your app’s current framework and hosting setup are our starting point. The fee covers our engineering work; hosting, paid tools, and API usage remain in your accounts, and we explain any required third-party costs before enabling them. Every item is listed in the published scope.

Common questions about environment separation

How do I set environment variables in an EC2 instance?

For AWS credentials, don’t: attach an IAM role to the instance, and the SDKs and CLI fetch temporary credentials from the instance metadata on their own, so no key sits in a variable. For app settings, AWS Systems Manager Parameter Store is AWS’s store for named configuration values, AWS recommends Secrets Manager for secrets such as database credentials and API keys, and the app reads what it needs when it starts.

Do AWS credentials expire?

AWS access keys are long-term credentials, and AWS recommends temporary credentials such as IAM roles instead; role credentials on EC2 are temporary and rotated automatically, with new ones available at least five minutes before the old ones expire. Temporary credentials retrieved directly from AWS STS and passed through environment variables also need AWS_SESSION_TOKEN set alongside the key pair.

How can I verify my AWS credentials?

Run aws sts get-caller-identity. It returns the account ID, the ARN and a user ID for whichever credentials the shell is using, and it needs no permissions to run. Run it in a staging shell before any command that writes, and check that the account is staging’s, not production’s. The full output is described on the STS get-caller-identity command page.

Can I get a free API key for testing?

For Stripe, every sandbox comes with its own test keys, and in a sandbox card networks and payment providers don’t process payments, so a test charge moves no real money. Whether another provider offers a test mode or sandbox is in that provider’s own docs; where one exists, staging should use it.

How can I test API keys?

My answer: make one read-only call to the provider with the key from each environment and confirm the account or mode it reports matches that environment. Render’s API docs test a new key the same way, with a request that lists your services. Never paste a key into a third-party key-testing site, because that site then holds your key.