Migrate from PostgreSQL to Databricks Lakebase
This tutorial moves an existing Aidbox database from a self-hosted PostgreSQL to a Databricks Lakebase Autoscaling project. You copy the data with pg_dump and pg_restore, then point Aidbox at Lakebase with Databricks OAuth authentication. The Aidbox image, its settings and FHIR packages stay the same.
Aidbox 2608 and later fail to start with BOX_DB_AUTH_METHOD=databricks-autoscale or databricks-provisioned: the main connection pool sends BOX_DB_PASSWORD instead of the OAuth token, and Lakebase rejects it with Provided authentication token is not a valid JWT encoding. Use Aidbox 2607 or earlier until a release with the fix is available.
Objectives
- Provision a Lakebase project and a service principal role that owns the Aidbox schema.
- Copy the Aidbox database to Lakebase and verify the copy.
- Run Aidbox against Lakebase with OAuth tokens that Aidbox refreshes on its own.
Before you begin
- A Databricks workspace with Lakebase enabled and permission to create Lakebase projects and service principals.
- Databricks CLI 0.285 or later, logged in with
databricks auth login --host https://<workspace>.cloud.databricks.com. pg_dump,pg_restoreandpsqlwith the same major version as the source and the target. Without a local install, run them from the official image, for exampledocker run --rm postgres:17 pg_dump ....jq.- A maintenance window: Aidbox must stay stopped while
pg_dumpandpg_restorerun.
Read Run Aidbox on managed PostgreSQL for the Lakebase settings reference.
Compatibility
| Area | Lakebase behavior | What to do |
|---|---|---|
| PostgreSQL version | 16, 17 (default) and 18; no in-place major upgrade | Create the project on the same major version as the source. |
| Required extensions | pg_trgm and unaccent are available | Pre-create them as the Aidbox role. |
| Optional extensions | pgcrypto, fuzzystrmatch, postgis, pg_stat_statements are available; jsonknife and jsquery are not | Find objects that depend on missing extensions before the move. |
| Superuser | None; members of databricks_superuser hold elevated privileges | Set BOX_DB_INSTALL_PG_EXTENSIONS=false. |
| Authentication | OAuth tokens with a 1-hour lifetime; password login is off by default | Use a service principal with BOX_DB_AUTH_METHOD=databricks-autoscale. |
| Collation | C.UTF-8, fixed at database creation | Expect a different _sort order on strings with accented letters. |
| Idle compute | Scale-to-zero suspends the compute and drops connections | Disable scale-to-zero for the endpoint Aidbox uses. |
| Connection pooler | PgBouncer in transaction mode, password roles only | Connect Aidbox to the direct endpoint host, not the -pooler host. |
| Backups | No access to WAL or physical backups | Use Lakebase point-in-time restore and branches. |
Lakebase Provisioned instances have moved to Autoscaling, so this tutorial uses Autoscaling projects and the databricks postgres CLI commands. See Upgrade to Autoscaling.
Provision Lakebase
Create the project
Check the source version with psql "$SRC_URL" -c 'show server_version' and create the project on the same major version:
export SRC_URL="postgresql://aidbox:<password>@localhost:5432/aidbox"
export DATABRICKS_WS="https://<workspace>.cloud.databricks.com"
export PROJECT_ID="aidbox"
databricks postgres create-project "$PROJECT_ID" \
--json '{"spec": {"display_name": "Aidbox", "pg_version": "17"}}'
# A new project has the "production" branch with the read-write endpoint "primary"
export BRANCH="projects/$PROJECT_ID/branches/production"
export ENDPOINT="$BRANCH/endpoints/primary"
export LB_HOST=$(databricks postgres get-endpoint "$ENDPOINT" -o json | jq -r .status.hosts.host)
LB_HOST looks like ep-<name>.database.<region>.cloud.databricks.com.
Disable scale-to-zero and size the compute
A suspended compute closes the Aidbox connection pool. Pick the CU range from the load on the source database. The spread between minimum and maximum can't exceed 16 CU.
databricks postgres update-endpoint "$ENDPOINT" spec.suspension \
--json '{"spec": {"no_suspension": true}}'
databricks postgres update-endpoint "$ENDPOINT" \
"spec.autoscaling_limit_min_cu,spec.autoscaling_limit_max_cu" \
--json '{"spec": {"autoscaling_limit_min_cu": 1.0, "autoscaling_limit_max_cu": 8.0}}'
Create a service principal
databricks service-principals create --display-name aidbox-lakebase -o json
# Note "id" and "applicationId" from the output
databricks service-principal-secrets-proxy create <id> -o json
# Store "secret" in your secret manager; Databricks shows it once
export SP_CLIENT_ID=<applicationId>
export SP_CLIENT_SECRET=<secret>
Create a Postgres role for the service principal
The role name is the application ID.
databricks postgres create-role "$BRANCH" --role-id aidbox-sp \
--json "{\"spec\": {\"identity_type\": \"SERVICE_PRINCIPAL\", \"postgres_role\": \"$SP_CLIENT_ID\"}}"
Grant the role access to the database
Connect as the project owner with a short-lived token. Aidbox creates and alters its own tables, so the role needs CREATE on the database and on the public schema.
export PGPASSWORD=$(databricks postgres generate-database-credential "$ENDPOINT" -o json | jq -r .token)
psql "postgresql://<your-email>@$LB_HOST:5432/databricks_postgres?sslmode=require" <<SQL
GRANT CONNECT, CREATE, TEMPORARY ON DATABASE databricks_postgres TO "$SP_CLIENT_ID";
GRANT ALL ON SCHEMA public TO "$SP_CLIENT_ID";
SQL
URL-encode the @ in your email as %40.
Create extensions as the service principal
The Databricks CLI authenticates as the service principal when you set DATABRICKS_HOST, DATABRICKS_CLIENT_ID and DATABRICKS_CLIENT_SECRET. Create every extension the source database has that Lakebase supports, so the service principal owns them.
sp_token() {
DATABRICKS_HOST=$DATABRICKS_WS DATABRICKS_CLIENT_ID=$SP_CLIENT_ID DATABRICKS_CLIENT_SECRET=$SP_CLIENT_SECRET \
databricks postgres generate-database-credential "$ENDPOINT" -o json | jq -r .token
}
export LB_SP_URL="postgresql://$SP_CLIENT_ID@$LB_HOST:5432/databricks_postgres?sslmode=require"
export PGPASSWORD=$(sp_token)
psql "$LB_SP_URL" <<'SQL'
CREATE EXTENSION IF NOT EXISTS pg_trgm SCHEMA public;
CREATE EXTENSION IF NOT EXISTS unaccent SCHEMA public;
CREATE EXTENSION IF NOT EXISTS pgcrypto SCHEMA public;
CREATE EXTENSION IF NOT EXISTS fuzzystrmatch SCHEMA public;
CREATE EXTENSION IF NOT EXISTS pg_stat_statements SCHEMA public;
CREATE EXTENSION IF NOT EXISTS postgis SCHEMA public; -- only if the source has it
SQL
Check: psql "$LB_SP_URL" -c 'select current_user' returns the application ID, and \dx lists the extensions.
Inspect the source database
Run these queries before the maintenance window:
# Version, size, extensions, collation
psql "$SRC_URL" -c 'show server_version' \
-c 'select pg_size_pretty(pg_database_size(current_database()))' \
-c 'select extname, extversion from pg_extension order by 1' \
-c 'select datcollate from pg_database where datname = current_database()'
# Indexes that depend on jsonknife or jsquery
psql "$SRC_URL" -c "select indexrelid::regclass, pg_get_indexdef(indexrelid)
from pg_index where pg_get_indexdef(indexrelid) ~* 'knife|jsquery'"
# Exact row counts per table, kept for the comparison after the restore
psql "$SRC_URL" -At -c "select format('select %L, count(*) from %I.%I;', table_name, table_schema, table_name)
from information_schema.tables where table_schema = 'public' and table_type = 'BASE TABLE' order by 1" \
| psql "$SRC_URL" -At > counts_source.txt
If the index query returns rows, drop those indexes and recreate them without jsonknife functions before the move. When the jsonknife extension is missing, Aidbox installs a jsonpath-based shim of the same functions, so FHIR search keeps working.
Copy the data
Stop Aidbox and dump the database
docker compose stop aidbox
pg_dump -Fc -v -d "$SRC_URL" -f aidbox.dump
Build the restore list
The target already has the extensions. spatial_ref_sys belongs to PostGIS on the target, and the service principal can't write to it.
pg_restore -l aidbox.dump \
| grep -v -E ' EXTENSION |TABLE DATA public spatial_ref_sys' > restore.list
Restore as the service principal
--no-owner --no-acl makes the service principal the owner of every restored object. Each -j worker opens its own connection with the token from PGPASSWORD, and Lakebase checks the token at login, so generate a fresh one right before the restore.
export PGPASSWORD=$(sp_token)
pg_restore -v --no-owner --no-acl -j 4 -L restore.list \
-d "$LB_SP_URL" aidbox.dump 2> restore.log
grep -E '^pg_restore: error' restore.log
psql "$LB_SP_URL" -c 'ANALYZE'
ANALYZE prints permission denied to analyze "pg_..." warnings for system catalogs. You can ignore them.
Compare row counts
psql "$LB_SP_URL" -At -c "select format('select %L, count(*) from %I.%I;', table_name, table_schema, table_name)
from information_schema.tables where table_schema = 'public' and table_type = 'BASE TABLE' order by 1" \
| psql "$LB_SP_URL" -At > counts_target.txt
diff counts_source.txt counts_target.txt && echo "counts match"
Point Aidbox at Lakebase
Replace the database settings of the Aidbox service. Keep every other setting as it is.
services:
aidbox:
environment:
BOX_DB_HOST: <LB_HOST>
BOX_DB_PORT: '5432'
BOX_DB_DATABASE: databricks_postgres
BOX_DB_USER: <SP_CLIENT_ID>
BOX_DB_PASSWORD: placeholder
BOX_DB_AUTH_METHOD: databricks-autoscale
BOX_DB_DATABRICKS_HOST: https://<workspace>.cloud.databricks.com
BOX_DB_DATABRICKS_AUTOSCALE_ENDPOINT: projects/<project-id>/branches/production/endpoints/primary
BOX_DB_DATABRICKS_CLIENT_ID: <SP_CLIENT_ID>
BOX_DB_DATABRICKS_CLIENT_SECRET: <SP_CLIENT_SECRET>
BOX_DB_INSTALL_PG_EXTENSIONS: 'false'
BOX_DB_EXTENSION_SCHEMA: public
Remove the local database service from depends_on, then start Aidbox and wait for the health check:
docker compose up -d aidbox
curl -fsS http://localhost:8080/health
Aidbox fetches a token for the service principal, caches it for 45 minutes and rotates pool connections before the token expires. Read replicas take the same settings with the BOX_DB_RO_REPLICA_ prefix.
Run Aidbox in the same cloud region as the Lakebase project. Each FHIR request makes several SQL round trips, and with a cross-region network every round trip adds the full network latency to the response time.
Verify the migration
Before you switch traffic, check that:
- Aidbox starts without extension or permission errors in the logs.
- Resources created before the move read back by id, and
_historyreturns their earlier versions. - Create, update with
If-Match, conditional create and update, and transaction bundles return the same status codes as on the source. - Saved search requests return the same totals as on the source. Results sorted by string fields can differ because of the
C.UTF-8collation. - Traffic keeps flowing for more than one hour, past the first token expiry.
Roll back
Until Aidbox writes to Lakebase, rolling back means restoring the old BOX_DB_* settings and starting Aidbox against the source database. Keep the source database read-only for a period you agree on with your team. After Aidbox has written to Lakebase, dump Lakebase with the same commands in the reverse direction. To undo a mistake made on Lakebase, restore to a point in time or create a branch from before the incident.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Provided authentication token is not a valid JWT encoding at startup | Aidbox 2608 or later | Use Aidbox 2607 or earlier until the fix is released. |
| Aidbox refuses to start: missing extension | pg_trgm or unaccent isn't in BOX_DB_EXTENSION_SCHEMA | Create the extension as the service principal in public. |
permission denied for schema public | The service principal lacks CREATE on public | Run the grants from the provisioning steps again. |
must be owner of table ... | Another role restored the tables | Restore again as the service principal with --no-owner. |
password authentication failed during pg_restore | The token expired before a worker connected | Generate a new token and lower -j. |
| Connections drop after idle periods | Scale-to-zero is on | Run update-endpoint with no_suspension: true. |
prepared statement does not exist | Aidbox connects through the -pooler host | Use the direct endpoint host. |