Dev, staging, production: three environments and how not to mix them up
How to keep config apart, stop a test key reaching production and a live key reaching a laptop, and where staging data should come from.
A project started on a platform like Lovable or Replit usually lives in a single environment. Code gets written there, demoed there, and the first users sign up there. Before there are users, that’s convenient. Once there are, every edit becomes an experiment on real people.
Three environments, development, staging and production, exist so that a mistake has a limited blast radius. Break something locally and it’s your problem. On staging, it’s the team’s. In production, it’s your customers'.
How they differ
- Development is a developer’s laptop or a branch preview. Fake data, external services in test mode, anything goes.
- Staging is a structural copy of production: same database version, same host, same settings. Only the data and keys differ.
- Production is what people use. Few people have access, and changes arrive only through the pipeline.
The main requirement for staging is resemblance. If production runs Postgres and staging runs SQLite, staging is testing something else.
Config lives outside the code
The code is identical in every environment; only environment variables differ. Two habits make this reliable.
The first is a single place where config is read. On startup, one module reads the variables, checks that every required one is present, and fails with a clear message if something’s missing. The rest of the code takes settings from there, not from process.env scattered around.
The second is no “if this is production then…” checks throughout the code. If behaviour has to differ, make it a named setting, such as EMAIL_DELIVERY=log, not an if (isProd) inside the email handler. Those checks multiply, and six months later nobody knows what else behaves differently in production.
The repository contains an .env.example with every variable name and safe values. The real files with keys stay out of it.
Test keys and live keys
Most payment providers have test and live modes with separate keys, which is the best protection you can get, provided you use it. Make the app refuse to start with the wrong combination:
const env = process.env.APP_ENV;
const key = process.env.STRIPE_SECRET_KEY ?? "";
if (env === "production" && !key.startsWith("sk_live_")) {
throw new Error("production needs a live Stripe key");
}
if (env !== "production" && key.startsWith("sk_live_")) {
throw new Error("live Stripe key outside production");
}
Same with email. Outside production, messages either go to the log or into a mail trap that accepts everything and delivers nothing. For SMS, use the provider’s test numbers. A real email to a real customer from staging isn’t a bug, it’s an awkward conversation.
Where staging data comes from
Copying the production database to staging is tempting, because that’s the “real” data. But it brings customers’ emails, phone numbers and addresses along, and staging access is usually broader than production access. Copied addresses also turn any email misconfiguration into a mailout to actual people.
A seed script is better. It creates a plausible dataset: users of different types, orders in different states, edge cases like empty profiles and very long names. Make it idempotent, so running it twice doesn’t create duplicates. Then any new environment, branch previews included, comes up in a minute.
If you really do need production data, say to reproduce a performance problem, anonymise it before copying, not after.
So nobody mixes up the tabs
It sounds trivial, but it works: staging shows a banner with the environment name across the top, has a different favicon, and uses a different colour in the admin panel. Someone with five tabs open at the end of the day shouldn’t have to guess where they’re about to delete a record.
SUBXCAT can split the environments in an existing project and write the seed script, so a new staging comes up without manual work.