Skip to content

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.

  • Phases 1–5 complete: Docker host provisioned, PostgreSQL reachable, web/.env populated, secrets generated, DNS pointed.
  • The Postgres role named in DATABASE_URL exists and can create tables, indexes, and extensions in its target database.
  • You can run docker compose from the project directory.
  • A point-in-time backup of the target database, if it is not empty.
  1. Confirm the database connection

    From the host, verify the web container 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.

  2. 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 drizzle

    Each .sql file in that directory corresponds to one migration. The meta/ subdirectory contains the Drizzle journal that tracks which migrations have already been applied.

  3. Run the migrations

    Execute the migration runner:

    Terminal window
    docker compose run --rm web pnpm db:migrate

    The runner connects using DATABASE_URL , reads drizzle/meta/_journal.json, and applies every .sql file that has not yet been recorded in the __drizzle_migrations table.

    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
  4. 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"

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.

NameDefaultRequiredSource file
DATABASE_URL(none)yesweb/.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.

Confirm the schema is in place before moving to Phase 7.

  1. 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 .sql files in web/drizzle/.

  2. 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.

  3. Check enum types loaded:

    Terminal window
    docker compose run --rm web psql "$DATABASE_URL" -c "\dT"

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(&lt;pid&gt;) and re-run the migration.

With the schema in place, continue to Phase 7 — Start the application stack to bring up web, SteVe, and the email worker.