Lindsay Edwards

A migration ships everywhere: the admin account I baked into the build

Here is a mistake that looked like a convenience right up until I said it out loud. An early database migration seeded an initial owner account, so a fresh install came up with someone already able to log in. Email and password, set right there in the migration via the framework’s seed-data mechanism. Handy for getting started.

Now add the other half: the app ran its migrations automatically on startup. Put those two together and what you have is a confirmed admin account, with credentials that live in the source history, being created on every single deployment. A backdoor, baked into the build, shipped everywhere the code goes. Anyone who read the repo could log into any instance. It is the software version of a lock company shipping every door with the same key and printing that key in the instruction booklet.

What a migration actually is#

The root confusion is about what seed data in a migration is. It is not setup. It is permanent, versioned, and shipped to every environment that ever runs the migration chain. That is exactly the right home for structural facts, a lookup table of country codes, an enum’s backing rows, the reference data the schema does not make sense without. It is exactly the wrong home for anything environment specific, and credentials are the most environment specific thing there is.

So the fix was to pull the demo account out of the migration entirely and into a development-only runtime seeder, the kind of thing that only runs when you are sitting at your own machine and never in production. And because the bad rows were already in the migration history and therefore already deployed, removing the seed was not enough. It needed a follow-up migration to delete the baked-in account, with a carefully reviewed reverse step, so existing databases got cleaned up too.

Anything you put in a migration, you are committing to shipping to every environment, forever, in the open. Structural data, yes. Secrets or accounts, never.

And “migrate on startup” has its own tail#

The related lesson sits next to it: running migrations automatically when the app boots is a lovely convenience for a single instance and a race the moment you run two. Two replicas starting at once both try to apply the same migration against the same database, and now you are hoping the framework’s locking saves you. It usually does, until the once it does not.

For anything that scales past one instance, migrations want to be their own deploy step, run once, deliberately, before the new app version comes up, not a side effect of the first pod to boot. The convenient version is fine right up to the scale where it quietly is not, which is a sentence I could staple to half the bugs I have written.

Keep reading