← All posts
DevOps9 min readProject: Data source service

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.

#Docker#Prisma#PostgreSQL#Deployment#Database Migrations#DevOps

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

DockerDocker ComposePrismaPostgreSQLShell

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:

SituationRight reaction
Database still startingwait a bit, retry, but not forever
Database unreachable for realstop 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 push runs once. If it refuses, the deploy stops with an explanation that points at the most likely cause.
  • It never uses --accept-data-loss or --force-reset.
  • exec replaces 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
Welcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have fun

Componentity

Thanks for visiting my portfolio. Every project here reflects my passion for building and learning. If you’re interested in working together, have a question, or just want to connect—don’t hesitate to reach out. I'm always open to meaningful conversations and new ideas.

© 2026 Zakariya Zouazou. All Rights Reserved.

Contact Us