On a Friday evening, in the middle of dinner service, I emptied the production database of the restaurant whose entire ordering operation runs on software I wrote. Orders, menu, customers, the lot.
It came back. This is what happened, why the thing I thought was protecting me wasn't, and the one question I now ask before typing any database URL anywhere.
The command
I was adding a migration. Prisma needs a second, throwaway database to diff schemas against — the shadow database. It creates it, uses it, and drops it. Normally it provisions one for you; in this setup it needed to be passed explicitly, so I passed one:
npx prisma migrate dev \
--shadow-database-url "postgres://…" # ← I put the production URL hereThe URL I pasted was production. There was no confirmation prompt, and there shouldn't have been — from the tool's point of view I had just told it where its scratch space lives, and it did exactly what it is documented to do with scratch space.
That is what makes this class of mistake dangerous. Nothing malfunctioned. Every piece of software behaved correctly. The damage was entirely in which string went in which slot.
Why the habit that usually saves me didn't
I have a rule about never pointing anything at production. What I actually had was a mental blacklist of the commands I consider dangerous — drop, reset, truncate, --force. I check those.
--shadow-database-url was not on that list, because it doesn't look like a destructive flag. It looks like configuration. It reads like the sort of parameter that tells a tool where to find something, not what to flatten.
A blacklist can only stop the entries that are on it. The one that gets you is by definition the one you never thought to add — and the more innocuous a flag looks, the more likely it is to be missing.
So I replaced the blacklist with a question, asked of every parameter that takes a database URL, no exceptions:
Will this parameter write to, or clear, the database I'm about to name? If I can't answer that from memory, I go and read the docs before pressing enter.
It is a slower rule and a much better one. It applies to flags I have never seen before, which is precisely where the blacklist failed.
There was a second trap underneath
This wasn't purely carelessness. The production URL was the easiest string in the project to reach for, because it's the default value of DATABASE_URL in the API's own env file:
# apps/api/.env
DATABASE_URL="postgres://…prod…" # this is production. it is the default.When production is what your shell already has loaded, one careless paste is all it takes. The mistake was mine; the loaded gun on the table was a design decision I had made months earlier and stopped noticing.
The twenty minutes after
The database is on Neon, which keeps a continuous write-ahead log and can restore to any point in time within the retention window. I did not have to find a backup file, or hope a nightly dump had run, or know when the last one was.
- Stop writing immediately. Every order that lands after the wipe is one you will have to reconcile by hand after the restore.
- Pick a timestamp before the command, not before the alert. I knew the exact minute I ran it, which made this trivial. If you're guessing, guess earlier — you lose a few minutes of data instead of restoring into the middle of the damage.
- Restore into a branch first, then verify, then swap. Restoring straight over the live database means a bad restore leaves you with nothing to try again from.
- Then go and look at the actual rows — count orders for the day, open the most recent one. "The restore succeeded" is a claim by the tool, not evidence.
Total downtime was about twenty minutes, during service, on the busiest evening of the week. Nothing was permanently lost.
What I changed
The honest ranking, most useful first:
- Point-in-time recovery, verified. I had it by luck — it came with the managed database. Everything else here is prevention; this is the only thing that turns a catastrophe into an inconvenience. Restore from it once on purpose, so you know it works and how long it takes.
- Local development no longer defaults to production. The env file now points at a local database, and reaching production takes a deliberate act.
- The question, not the blacklist. "Will this parameter write or clear what I'm naming?"
- A guard in the scripts that wrap destructive tooling — a backstop, not a strategy, since it's still a blacklist:
# Refuse to run destructive tooling against anything that smells like prod.
case "$SHADOW_DATABASE_URL" in
*prod*|*neon.tech*) echo "refusing: shadow db points at production"; exit 1 ;;
esacThe part worth keeping
I ran the command that did it. But the setup made it a one-paste mistake instead of a several-step one, and it stayed that way for months because nothing had gone wrong yet. Absence of an incident is not evidence that a setup is safe — it's the state every unsafe setup is in right up until it isn't.