Deploys that can't hang and can't lose data: a safer Prisma db push entrypoint
A container that loops forever on "DB not ready" while the real problem is a refused schema change. A deploy that could silently drop a column. Here is how I rewrote a Docker entrypoint to wait with a limit, print the exact SQL before applying it, refuse destructive changes, and follow one golden rule: additive changes only.
The problem
Deploys could hang forever behind a misleading "DB not ready" message, and nothing stopped a schema change that loses production data.
The result
Every deploy waits for the database with a limit, logs the exact SQL it will run, refuses destructive statements, and fails with a clear message instead of looping.
Stack
The problem
The data source service deploys in the simplest possible way: a script on the server watches the main branch, and on every new commit it rebuilds and restarts the containers. A push to main is a production deploy.
The database schema isn't managed with migrations. This service has always used prisma db push, which compares the schema file with the live database and applies whatever is needed to make them match. The container's start command looked like this:
until yarn prisma db push; do
echo 'DB not ready...'
sleep 3
done
yarn start:prod
The idea was harmless: if PostgreSQL isn't up yet, wait and try again. But that loop treated every failure as "database not ready". And db push fails for a much more important reason too: when it detects a change that could lose data (dropping a column, changing a type, making a column required), it refuses to apply it without an explicit flag.
So when a schema change was refused, this is what happened:
- the container looped forever,
- the app never started, so production was down,
- the only log line, repeated every 3 seconds, was
DB not ready...: a lie that sent everyone to check the database, which was perfectly fine.
And the opposite risk was just as bad. Nothing stopped someone from "fixing" the loop by adding --accept-data-loss, which would make the next destructive change apply silently, with real data gone.
Before a project that added several new columns and a new table to this database, I wanted both risks gone.
The details
What db push is (and isn't)
prisma db push is great for prototypes and simple services: no migration files, the schema is the source of truth. But it has no history, no review step and no record of what changed. Whatever SQL it decides to run, runs. That makes visibility and guardrails the deploy's responsibility.
Three different failures, one message
The old loop mixed three very different situations:
| Situation | Right reaction |
|---|---|
| Database still starting | wait a bit, retry, but not forever |
| Database unreachable for real | stop with a clear message |
| Schema change refused (possible data loss) | stop immediately, explain, never retry |
Retrying the third one is pointless: it will be refused every time.
The challenges
- Wait, but with a limit. The database container can take a few seconds to start; that's normal. Waiting forever isn't.
- Know what will change before it changes. The deploy log should show the exact SQL, every time.
- Our own guardrail, on top of Prisma's. Prisma's data-loss check is good, but some dangerous changes (like a rename) deserve a stop even when Prisma would accept them.
- Fail visibly. A failed deploy must say why, and must leave the database untouched.
- Keep it a shell script. It runs inside a small container before the app exists; no extra tools.
The solution
1. A new entrypoint with four steps
#!/bin/sh
set -e
SCHEMA="${PRISMA_SCHEMA:-prisma/schema.prisma}"
DB_WAIT_ATTEMPTS="${DB_WAIT_ATTEMPTS:-40}"
DB_WAIT_SECONDS="${DB_WAIT_SECONDS:-3}"
DIFF_FILE="/tmp/schema-diff.sql"
fail() {
echo "=================================================================="
echo " [entrypoint] $1"
echo " The app was NOT started. The database was NOT changed."
echo "=================================================================="
exit 1
}
Every failure goes through fail(), which always prints the same two promises: the app didn't start, the database didn't change. Whoever reads the log at 2 a.m. knows the state immediately.
2. Step 1: wait for the database, with a limit
echo "[entrypoint] 1/4 waiting for the database..."
attempt=1
until yarn prisma migrate diff \
--from-config-datasource --to-schema "$SCHEMA" \
--script --output "$DIFF_FILE" > /tmp/schema-diff.log 2>&1; do
if [ "$attempt" -ge "$DB_WAIT_ATTEMPTS" ]; then
cat /tmp/schema-diff.log
fail "Database unreachable after $attempt attempts (or the schema diff failed - see the error above)."
fi
echo "[entrypoint] not ready (attempt $attempt/$DB_WAIT_ATTEMPTS), retrying in ${DB_WAIT_SECONDS}s..."
attempt=$((attempt + 1))
sleep "$DB_WAIT_SECONDS"
done
One command does two jobs. prisma migrate diff compares the live database with the schema file and writes the SQL needed to go from one to the other, without applying it. If it succeeds, the database is reachable and we have the exact SQL. If it keeps failing, we stop after 40 × 3 seconds (2 minutes) with the real error printed, not a fake "not ready".
3. Step 2: print the exact SQL
echo "[entrypoint] 2/4 schema changes to apply:"
if grep -q ';' "$DIFF_FILE"; then
sed 's/^/[entrypoint] /' "$DIFF_FILE"
else
echo "[entrypoint] none - database already matches the schema."
fi
Every deploy log now contains either "none" or the precise statements, for example:
[entrypoint] 2/4 schema changes to apply:
[entrypoint] ALTER TABLE "sources" ADD COLUMN "cached_result" JSONB;
[entrypoint] ALTER TABLE "sources" ADD COLUMN "is_broken" BOOLEAN NOT NULL DEFAULT false;
[entrypoint] CREATE TABLE "source_syncs" ( … );
db push keeps no history, so this log becomes the history. (When nothing changed, Prisma writes only a comment, -- This is an empty migration.; real statements always end with ;, which is what the grep checks.)
4. Step 3: refuse destructive changes
echo "[entrypoint] 3/4 checking the changes are additive only..."
DESTRUCTIVE='DROP TABLE|DROP COLUMN|DROP TYPE|DROP SCHEMA|TRUNCATE|DELETE FROM|ALTER COLUMN .* TYPE|ALTER COLUMN .* SET NOT NULL|RENAME'
if grep -Eiq "$DESTRUCTIVE" "$DIFF_FILE"; then
echo "[entrypoint] destructive statements found:"
grep -Ei "$DESTRUCTIVE" "$DIFF_FILE" | sed 's/^/[entrypoint] /'
fail "Refusing to apply a destructive schema change automatically."
fi
This is our own guardrail, on top of Prisma's. It blocks anything that can lose or rewrite existing rows:
- dropping tables, columns, types or schemas,
- truncating or deleting rows,
- changing a column's type,
- making an existing column NOT NULL,
- renames. To a diff tool, a renamed column often looks like "drop the old one, add a new one", so a rename is one of the easiest ways to lose a column's data without meaning to.
When it triggers, the offending lines are printed, and the deploy stops with the database untouched. Destructive changes are still possible, but by hand, on purpose, with a backup, never as a side effect of a push.
5. Step 4: apply once, never forced
echo "[entrypoint] 4/4 applying the schema (prisma db push)..."
if ! yarn prisma db push --schema "$SCHEMA"; then
fail "prisma db push was refused (see the message above). Most likely a required column without a default, or a change Prisma considers data loss."
fi
echo "[entrypoint] starting the app..."
exec yarn start:prod
db pushruns once. If it refuses, the deploy stops with an explanation that points at the most likely cause.- It never uses
--accept-data-lossor--force-reset. execreplaces the shell with the app process, so Docker's signals (stop, restart) reach the app directly.
With restart: unless-stopped, Docker may retry a failed start, and each retry prints the same clear message. There's no misleading loop anymore.
6. The golden rule: additive changes only
The script enforces it, but the rule lives in the team's habits first:
- New column → nullable, or with a default.
- New table, new enum, new index → fine.
- Never drop, rename or change the type of an existing column in a normal deploy.
- Never add a required column without a default.
That's why the new columns for the source cache project looked like this:
cachedResult Json?
isBroken Boolean @default(false) // default is mandatory: the table already has rows
brokenSince DateTime?
A required column without a default on a table with 177 rows would fail (what value should existing rows get?). With the rule, every schema change in that project was additive, and every deploy was safe by construction.
7. Preview before pushing
A small script lets developers see, locally, exactly what the deploy will run:
"db:preview": "prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script"
The checklist before any schema change: preview the SQL, confirm it's additive, take a backup (pg_dump), then push. The new entrypoint was tested against six situations in Docker (database slow to start, unreachable, no change, additive change, destructive change, refused push) before it ever reached production.
Lessons learned
- Never retry what can't succeed. "Not ready" and "refused" need opposite reactions; one loop for both hides the real problem.
- Logs must tell the truth. A message that blames the wrong component costs hours of debugging.
- Wait with a limit. Patience for a starting database is good; infinite patience is an outage.
- Show the SQL on every deploy. Without migration files, the deploy log is your schema history.
- Add your own guardrails. Tool defaults are a floor, not a ceiling; block renames and type changes explicitly.
- Fail closed and say so. "App not started, database not changed" is the most reassuring error message you can write.
- Make additive the default. Nullable or defaulted columns make deploys safe by construction.
- Destructive changes are deliberate acts. Do them by hand, with a backup, never as a side effect of a push.
FAQ
What is the difference between prisma db push and prisma migrate?
prisma db push makes the database match the schema directly, without creating migration files or keeping a history. prisma migrate generates versioned SQL migration files that are reviewed, committed and applied in order. Migrate is usually preferred for production; db push is simpler for prototypes and small services.
How can I see what SQL Prisma will run before applying it?
Use prisma migrate diff with --from-config-datasource (or a database URL) and --to-schema, plus --script. It prints the SQL needed to move the database to the schema without applying anything.
Is it safe to use --accept-data-loss in production?
Generally no. It lets Prisma apply changes that can delete data, like dropping columns or changing types. Destructive changes should be planned, reviewed and run deliberately with a backup.
How do I add a column to a table that already has data?
Make the new column nullable, or give it a default value. Adding a required column without a default fails on existing rows, because the database doesn't know what value to put in them.
Facing a similar problem?
I help teams fix performance, backend and reliability issues in production.
Let's talk