A seeded sandbox beats a copy of production
Our whole local environment is an in-process Postgres that boots from empty, applies real migrations, and fills itself with a convincing fake salon. No production credentials on laptops, no real client data in screenshots, and a demo that never goes stale.
There is a moment in every small team's life when someone suggests pointing local development at a copy of the production database, because the fake data feels thin and the real bugs only happen with real records. It is a tempting idea with a long tail of regret: credentials spread across laptops, client names in screenshots, a test email job that one day finds real addresses. We went the other way and never looked back.
An entire Postgres inside the process
Embedded Postgres builds have gotten genuinely good. Our app decides at startup: if real database credentials exist in the environment, use the hosted database; if they are blank, spin up an in-process Postgres that lives in a local folder. Development machines simply never have the credentials, so the failure mode of a misconfigured laptop is a fresh empty database, not a production incident.
// Choose the database at runtime. Blank env = embedded, local, harmless.function makeDb() { const url = process.env.DATABASE_URL; if (url) return connectHosted(url); // In-process Postgres persisted to a local folder const client = new EmbeddedPostgres('./.pgdata'); return drizzle(client, { schema });}The rebuild path matters as much as the engine. We recreate the local database by applying the same migration files that production ran, in order, from the first one. That single decision has caught a family of bugs for free: a migration that assumed a column from a later file, a schema change that worked against a stale snapshot but not from scratch. If the laptop cannot build the schema from history, the next deploy could not either.
Seed a business, not rows
The difference between useless and useful fake data is narrative. Our seed script does not insert ten users named test. It creates a salon with a name, staff with specialties, services with honest prices and durations, clients with visit histories, upcoming appointments, retail products with stock levels and a ledger behind them. The pages look inhabited, which means layout bugs, empty states, and awkward truncation show up the way they would for a real customer.

That last property is easy to underrate. Every screenshot in our docs, our blog, and our bug reports comes from the seeded sandbox. Nobody has to squint at an image asking whether a client name slipped through, because there are no client names to slip. The safest redaction process is having nothing to redact.
What to get right
- Make the safe path the default path. Developers should have to go out of their way to reach real data, not out of their way to avoid it.
- Neutralize side effects too: email, payments and storage keys stay blank locally, so a loop over seeded clients can never message a real person.
- Seed the states you dread: the brand-new account, the fully booked day, the out-of-stock product, the client with one visit years ago.
- Keep the seed in code review. When a feature adds a table, the seed grows with it, or the sandbox quietly rots.
The payoff compounds. New teammates run two commands and have a working business to poke at. Visual QA can photograph every page without a privacy review. And the demo environment is always five minutes from pristine, because pristine is just a rebuild away.
Production data answers questions. Seeded data lets you ask them safely.