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.
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 -cwithset -ein 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 Nline 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:
- Expand. Add
handleas a nullable column. The new code writes both columns and readshandle, falling back tousernamewhen it is empty. The old code keeps working, sinceusernameis untouched. - Migrate the data. Copy
usernameintohandlefor existing rows, then makehandleNOT NULL. The code readshandleonly, and still writes both columns, since the version from step 1 is still serving during this deploy. - Contract. Stop writing
usernameand 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:
- Remove the step from the pre-deploy script and deploy. The new code must already work without it.
- 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
- Does the version currently running still work with the new schema?
- Does the migration finish well within 510 seconds on production-sized data?
- Is every step in the script safe to run twice?
- 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.