Database migrations in the pre-deploy script without downtime

June 19, 2026
Guide

The pre-deploy script is where database migrations run on JetDeploy: after the build, before the new version receives traffic. That order protects you from one failure, a new version starting against an old schema. It does not protect you from the opposite one: the old version keeps serving while the migration runs, and while the new version rolls out. A migration that the old code cannot live with causes errors during every deploy, however short.

schemaold schemanew schemaold codestill serving on the new schemanew coderolling outpre-deploy: migratenew version starts
From the migration until the rollout ends, the old code runs against the new schema.

How the pre-deploy script runs

  • It runs in the freshly built image, with the App's environment variables, in a container of its own.
  • It runs under /bin/sh -c with set -e in front, so a multi-line script stops at the first failing command. The image needs /bin/sh.
  • It has 510 seconds, restarts included. The script and the start of the new version together have 600.
  • A script that fails is not run again. If it fails or times out, the deploy fails and the previous version keeps serving.
  • It is started again in a new container only if the platform interrupts it, for example during a node drain. Every attempt begins with a Pre-deploy attempt N line in the deploy logs.

A typical script per framework:

python manage.py migrate --noinput     # Django
alembic upgrade head                   # SQLAlchemy / FastAPI
bin/rails db:migrate                   # Rails
php artisan migrate --force            # Laravel
npx prisma migrate deploy              # Prisma

All of these skip migrations that are already applied, so running them twice is safe. Anything else you add to the script must be safe to run twice as well.

Which changes are safe in one deploy

The test is simple: can the version currently running work with the new schema?

Change One deploy? Why
Add a table yes old code never touches it
Add a nullable column, or one with a database default yes old inserts leave it empty or get the default
Add a NOT NULL column without a default no old inserts fail
Rename a column or table no old queries still use the old name
Drop a column the old code reads no old queries fail until the rollout ends
Add an index on a large table depends a normal index build locks writes, and may not finish in 510 s

A default in Django is not a database default. A field with default="free" adds the column with that default and then drops it, so an insert from the old code fails with a NOT NULL violation. Use db_default="free", which stays in the schema. Rails, Laravel, Prisma and Alembic's server_default set a database default already.

Everything marked "no" can still be done without errors. It takes more than one deploy.

Expand, migrate, contract

Say a users.username column has to become users.handle. Instead of one rename, ship three deploys:

  1. Expand. Add handle as a nullable column. The new code writes both columns and reads handle, falling back to username when it is empty. The old code keeps working, since username is untouched.
  2. Migrate the data. Copy username into handle for existing rows, then make handle NOT NULL. The code reads handle only, and still writes both columns, since the version from step 1 is still serving during this deploy.
  3. Contract. Stop writing username and drop it. By now no running version reads it.

In Django, step 2's data copy is a RunPython migration:

from django.db import migrations, models


def copy_handles(apps, schema_editor):
    User = apps.get_model("accounts", "User")
    User.objects.filter(handle__isnull=True).update(handle=models.F("username"))


class Migration(migrations.Migration):
    dependencies = [("accounts", "0014_user_handle")]
    operations = [migrations.RunPython(copy_handles, migrations.RunPython.noop)]

The filter(handle__isnull=True) makes it safe to run twice.

Rollbacks never undo migrations. Rolling an App back puts an earlier image live with the current configuration. It does not run the pre-deploy script and does not reverse schema changes. Expand-and-contract is also what keeps a rollback safe: the previous version still works with the expanded schema.

Large tables and long migrations

On PostgreSQL, build indexes without locking writes:

from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations, models


class Migration(migrations.Migration):
    atomic = False
    dependencies = [("orders", "0031_previous")]
    operations = [
        AddIndexConcurrently("order", models.Index(fields=["created_at"], name="order_created_idx")),
    ]

This one is not safe to run twice. If the pre-deploy script is stopped while the index is being built, the migration is not recorded but a leftover index, possibly invalid, stays behind, and the next attempt fails with already exists. Drop it first with DROP INDEX CONCURRENTLY IF EXISTS order_created_idx;, then deploy again.

When a step will take longer than the 510 seconds of the pre-deploy script, run it outside:

  1. Remove the step from the pre-deploy script and deploy. The new code must already work without it.
  2. Run the step once in the new version, from the shell on the App page or through the API:

bash curl -sS -N -X POST -H "Authorization: Bearer $JD_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"command": ["python", "manage.py", "migrate", "--noinput"], "timeout": 600}' \ https://jetdeploy.com/api/v1/apps/42/pods/7/exec

A command through the API runs for at most 600 seconds. The shell on the App page runs longer, but it closes after 30 minutes without typing and after 4 hours in any case, and with the browser tab. Keep it active, or split the step into shorter parts. 3. Put the step back in the pre-deploy script for the next deploys.

MariaDB: no transactional DDL

On PostgreSQL, Django, Rails and most other tools run each migration inside a transaction: if the script is stopped halfway, the migration in progress rolls back and the ones before it stay applied. MariaDB commits every DDL statement on its own. A migration stopped by the time limit, or by a Cancel while the script runs, can leave a MariaDB schema half migrated.

On MariaDB, keep each migration small, with one schema change per migration where you can, so an interrupted run leaves a state you can resume from.

A checklist before pushing a migration

  1. Does the version currently running still work with the new schema?
  2. Does the migration finish well within 510 seconds on production-sized data?
  3. Is every step in the script safe to run twice?
  4. If the deploy has to be rolled back, does the previous version work with this schema?

Next step

To see what a failed pre-deploy looks like, and what the error message tells you, read When a deploy fails in the docs. For the full Django setup that this builds on, see Deploy Django with PostgreSQL.

Related Articles

All posts