Phase 6 — Run database migrations
Phase 6 applies the Drizzle migrations bundled with the web image
to your PostgreSQL database. Once this completes, the schema is in
place and the web service can boot cleanly. This is the last step
before you start the application stack in Phase 7.
Prerequisites
Section titled “Prerequisites”- Phases 1–5 complete: Docker host provisioned, PostgreSQL reachable,
web/.envpopulated, secrets generated, DNS pointed. - The Postgres role named in
DATABASE_URLexists and can create tables, indexes, and extensions in its target database. - You can run
docker composefrom the project directory. - A point-in-time backup of the target database, if it is not empty.
-
Confirm the database connection
From the host, verify the
webcontainer can reach Postgres using the credentials in your env file:Terminal window docker compose run --rm web node -e "import('pg').then(async ({default: pg}) => { const c = new pg.Client(process.env.DATABASE_URL); await c.connect(); const r = await c.query('select current_database(), current_user, version()'); console.log(r.rows[0]); await c.end(); })"You should see the database name, role, and Postgres version. If this fails, stop and fix connectivity before continuing — running migrations against the wrong database is the most common self-host incident.
-
Inspect the pending migrations
Migrations live inside the image under
web/drizzle/. List them to confirm the image you pulled matches the version you expect:Terminal window docker compose run --rm web ls -1 drizzleEach
.sqlfile in that directory corresponds to one migration. Themeta/subdirectory contains the Drizzle journal that tracks which migrations have already been applied. -
Run the migrations
Execute the migration runner:
Terminal window docker compose run --rm web pnpm db:migrateThe runner connects using
DATABASE_URL, readsdrizzle/meta/_journal.json, and applies every.sqlfile that has not yet been recorded in the__drizzle_migrationstable.Expect output similar to:
[migrate] Reading config file '/app/drizzle.config.ts'[migrate] Using "postgres" driver[migrate] applying migration 0000_initial...[migrate] applying migration 0001_add_chargebox...[migrate] done -
Record what you applied
Capture the final migration name and the timestamp. You will need this if you ever roll back the image tag or restore the database from a snapshot.
Terminal window docker compose run --rm web psql "$DATABASE_URL" -c \"select hash, created_at from drizzle.__drizzle_migrations order by created_at desc limit 5"
Configure environment variables
Section titled “Configure environment variables”Migrations only consume one variable, but it must be the same value
the running web service will use at boot — mismatches are the
second-most-common self-host incident.
| Name | Default | Required | Source file |
|---|---|---|---|
DATABASE_URL | (none) | yes | web/.env |
The connection string must include the database name, not just the
host. The role must have privileges to create tables and the
pgcrypto and uuid-ossp extensions.
Verify
Section titled “Verify”Confirm the schema is in place before moving to Phase 7.
-
Check the migrations table exists and has rows:
Terminal window docker compose run --rm web psql "$DATABASE_URL" -c \"select count(*) from drizzle.__drizzle_migrations"The count should equal the number of
.sqlfiles inweb/drizzle/. -
Check a core table exists:
Terminal window docker compose run --rm web psql "$DATABASE_URL" -c "\dt"You should see tables for users, ChargeBoxes, sessions, and the BetterAuth tables.
-
Check enum types loaded:
Terminal window docker compose run --rm web psql "$DATABASE_URL" -c "\dT"
If something goes wrong
Section titled “If something goes wrong”relation "__drizzle_migrations" already exists
A previous run partially applied. Inspect the table; if the last
hash matches the last file in drizzle/meta/_journal.json, you are
already migrated and can proceed to Phase 7. If hashes diverge, you
are pointed at a database from a different deployment — stop and
verify DATABASE_URL .
permission denied to create extension
Your application role cannot create extensions. Pre-create them as
superuser (see the tip above), then re-run the migration.
column ... contains null values
A migration that adds a NOT NULL column failed because existing
rows do not satisfy the constraint. This only happens on databases
that already hold data from a much older schema. Restore the
snapshot, upgrade through intermediate versions, or contact support
with the migration filename.
Migration runner hangs Another connection holds a lock on a table being altered. Find it:
select pid, query, state from pg_stat_activity where state <> 'idle';Terminate the offender with select pg_terminate_backend(<pid>)
and re-run the migration.
Next phase
Section titled “Next phase”With the schema in place, continue to Phase 7 — Start the
application stack to bring up
web, SteVe, and the email worker.