Seed scripts that run against production
A seed script is usually written for an empty local database and then, one day, pointed at production. Everything that made it convenient becomes a liability.
A seed script starts life as a convenience: a fast way to get a working local database. It is written against an empty schema, run constantly, and nobody thinks hard about it.
Then production needs a content change that is awkward to make by hand, and the seed is the obvious tool. From that moment it is production tooling, and every assumption baked in when it was a local convenience becomes a hazard.
Four rules make that transition safe.
1. Decide what a re-run is allowed to overwrite
The most consequential line in a seed is the update clause of an upsert.
await prisma.contentBlock.upsert({
where: { page_slot: { page, slot } },
update: {}, // existing rows are never touched
create: { page, slot, ...fields },
});update: {} means create-if-absent and otherwise leave alone. For anything a human can edit through an admin console, this is the only safe choice — the alternative is a seed run silently reverting somebody's work.
The exception is content you deliberately own in version control, such as legal documents, where the repository should be the source of truth. Make that an explicit opt-in rather than the default:
SYNC_LEGAL_CONTENT=true npm run db:seedNow a normal seed can never clobber editorial changes, and publishing a reviewed legal correction is one flag.
2. Never write a counter you did not earn
Demo data exists to make a local environment feel real. View counts, follower numbers, ratings — all of it is useful in development and none of it belongs in production.
Two failures follow from getting this wrong. The obvious one is publishing numbers you cannot substantiate. The subtler one is that if the counter is in the upsert's update clause, every future seed run resets real traffic to the fabricated baseline — and you will not notice, because the numbers still look plausible.
Gate demo data behind a flag that defaults to off, and apply it only on create:
const SEED_DEMO_CONTENT = process.env.SEED_DEMO_CONTENT === "true";
create: {
...fields,
...(SEED_DEMO_CONTENT ? { viewCount: spec.demoViewCount } : {}),
},Default-off is the whole point. A flag that must be remembered to prevent something will eventually be forgotten by the one person running the one command that mattered.
3. No credentials in the file
A seed that creates an administrator needs a password, and the convenient thing is a default so local setup is one command.
Do not. The moment that script runs against a shared environment without the environment variable set, it creates a real privileged account whose password is in your repository — readable by every contributor, and preserved in git history long after the line is deleted.
Generate one instead:
function generatedPassword(): string {
return `Nv-${randomBytes(12).toString("base64url")}!1`;
}
// Blank counts as absent — see below for why this is not `process.env.X ?? …`
const readEnv = (name: string) => process.env[name]?.trim() || undefined;
const ADMIN_PASSWORD = readEnv("ADMIN_PASSWORD") ?? generatedPassword();That helper is doing more than it looks like. The obvious spelling is process.env.ADMIN_PASSWORD ?? generatedPassword(), and it has a hole: ?? substitutes only for null and undefined. If your .env.example ships ADMIN_PASSWORD="" — and most do, as a prompt to fill in — then dotenv sets an empty string, ?? returns it happily, and the fallback never fires. You have seeded an administrator with an empty password rather than a generated one.
It is a good bug in the worst way: the seed prints success, the account exists, and the failure surfaces later as an unexplained login problem. Treat blank as absent everywhere you read configuration, and the defaults you wrote actually run.
Print it only when you generated it. A password supplied through the environment should never be echoed — seed runs get piped into log files, and that is a leak with a long tail.
One knock-on effect worth planning for: once the fallback is random, any hardcoded copy of it in your test suite is wrong by construction. Have the tests read the same variable and fail immediately with the fix, rather than an authentication error the reader has to decode.
4. Say which database you are about to change
Every seed should print its target before it writes anything:
console.log(`Seeding ${connectionString.replace(/:[^:@/]+@/, ":****@")}`);Redact the password, print the host. It costs one line and it is the difference between noticing you are pointed at the wrong database and finding out afterwards.
It is worth knowing how the wrong target happens, because it is rarely carelessness. A multi-line command with \ continuations gets pasted into a shell that breaks the continuation; the environment assignments become no-ops; the script falls back to the .env in the working directory. The command looks right in the terminal and ran against the wrong database.
export the variables on their own lines, then run the command. And read the first line of output before trusting the rest.