ShipKit docs
Guides

Database

Drizzle against Postgres, push against migrate, and the two extensions the schema assumes.

The schema lives in two files and is loaded as one:

schema.ts

drizzle.config.ts points at both ./drizzle/schema.ts and ../auth/src/auth-schema.ts, because Better Auth owns its own tables and we do not copy them. Edit the auth tables in the auth package, never in drizzle/schema.ts.

Two extensions, created before anything else

CREATE EXTENSION IF NOT EXISTS vector;   -- embeddings, packages/ai
CREATE EXTENSION IF NOT EXISTS pg_trgm;  -- trigram search, packages/db/src/search.ts

docker-compose.yml handles this locally: the image is pgvector/pgvector:pg16 and scripts/init-db.sql is mounted into docker-entrypoint-initdb.d, so both extensions exist on the container's first boot. That is why bun run setup works on a clean clone.

The moment you point at a Postgres you provisioned yourself, this step is yours. drizzle-kit push does not create extensions and does not explain itself; it fails with type "vector" does not exist and leaves you looking at your schema file. Extensions require a superuser, so run the two statements as postgres, not as the application role.

The init script only runs on an empty data directory. Adding it to a compose stack whose volume already exists does nothing; drop the volume or run the statements by hand.

Push or migrate

Both are wired. They are for different phases and mixing them is how a database ends up disagreeing with its own migration history.

bun run db:push

Diffs the schema against the live database and applies the difference immediately. No files, no history. Use it while the shape is still moving and the data is disposable, which is most of the first week.

It will happily drop a column to make the database match the file. On a database with real rows, that is the whole risk.

bun run db:generate   # writes a timestamped SQL file under drizzle/migrations
bun run db:migrate    # applies pending files

Generate, read the SQL, commit it, apply it. Use this the moment the database holds data somebody would miss, and in every environment you cannot recreate.

The generated file is editable. Drizzle writes a mechanical diff; a rename arrives as a drop plus an add, which loses the column's data. Rewrite it as ALTER TABLE ... RENAME COLUMN before applying.

Seeding

bun run db:seed

Creates the demo admin through Better Auth's own sign-up API rather than by inserting a row, so the password is hashed the way the login path expects, then promotes the account to admin and marks the email verified. It also inserts three sample projects, so the dashboard, the projects page and the empty state are three distinguishable screens on a fresh clone rather than three copies of "nothing here yet".

It is idempotent: run it twice and it ensures rather than duplicates.

The seed refuses to plant its default credentials when NODE_ENV=production unless SEED_ADMIN_PASSWORD is set explicitly. A boilerplate that ships a known admin password into production is a boilerplate with a CVE, so this one stops instead.

Connecting

packages/db exports a single pooled client. Import it rather than constructing your own:

import { drizzleDb, eq, projects } from "@workspace/db"

const rows = await drizzleDb
  .select()
  .from(projects)
  .where(eq(projects.ownerId, user.id))

Pool sizing and timeouts are environment variables, all optional, all read in packages/env: DB_POOL_MAX, DB_POOL_MIN, DB_IDLE_TIMEOUT, DB_CONNECT_TIMEOUT, DB_MAX_LIFETIME, DB_STATEMENT_TIMEOUT, DB_IDLE_IN_TRANSACTION_TIMEOUT, DB_QUERY_TIMEOUT. Leave them alone until a number tells you to change one.

Ownership is the security boundary

Every domain table carries an owner column and every query scopes to it. The API context hands each route a user, and a query that reads an id from the request body instead of from user.id is the bug this convention exists to prevent. Cross-user reads go through the admin RBAC macro, never through a query parameter.

Inspecting

bun run db:studio     # Drizzle Studio against the configured database

drizzle.config.ts imports @workspace/env/load-root-env first, because drizzle-kit runs with its cwd set to packages/db and would otherwise fall back to the compose defaults even when the root .env points somewhere else. If Studio opens the wrong database, that import is the first thing to check.

On this page