Self-hosting
Switching providers
Move a running install between providers with usertasks-admin: status, doctor, switch per area (database, auth, storage, scheduler, realtime, email, secrets) with backups and rollback, downtime expectations, and the read-only Infrastructure page.
usertasks-admin moves a running install from one provider to another, one area at a time: the database, auth, file storage, the scheduler, realtime, email, secrets, the cache store and the KMS. Every switch checks the target first, takes a backup, runs its steps in order, records what it did, and can be rolled back. Run it where the app runs, with the app's environment (DATABASE_URL, the provider variables, ENCRYPTION_KEY):
# from a checkout
npm run admin -- status
# in the Docker image
docker compose exec app node admin.cjs statusThe provider choices themselves are described in Infrastructure and providers. The default (Supabase) stack needs none of this: an install that never runs switch behaves exactly as before.
Before you start
- Look at the current state.
usertasks-admin status: the provider per area, where each choice comes from (env,settingsordefault), PostgreSQL and migration versions, and a health summary.usertasks-admin doctor: every check in detail: database roles, privileges, extensions and migrations; a storage write / read / delete probe (--read-onlylists instead); Supabase Auth health or the built-in store; OIDC discovery for each identity provider; email configuration (--email-to you@example.comsends a test); pg_cron jobs, the worker's leader lock and the last scheduler tick; a LISTEN/NOTIFY round trip for the SSE realtime provider; resolution of everysecret://reference. Secret values are never printed. Exit code 1 when a check fails.usertasks-admin providers list [--on-prem] [--aws] [--azure] [--gcp]: the catalogue, with capability flags and whatswitchdoes for each provider (switch:datacopies or converts data,switch:configonly changes configuration).
- Apply the migrations (
usertasks-admin db migrate): the switch log lives ininstance_provider_switches(migration20261107000000). - Plan a dry run.
--dry-runruns the pre-flight checks and shows each step with what it would do (objects to copy, URLs to rewrite, accounts to migrate) and the settings it would write. Without--dry-runand without--yes,switchprints the plan and refuses to start.
How a switch runs
usertasks-admin switch <area> --to <provider> [--dry-run] [--yes]
[--set <option>=<value>]... [--secret <option>=<value>]...
[--backup-dir <dir>] [--no-dump] [--force] [--actor <name>]- Pre-flight. The target is checked before anything changes (reachable, writable, configured). Any failed check stops the switch.
- Backup, in
<backup dir>/<switch id>/(default./backups/switches, orBACKUP_DIR; created with mode 0700):config.json: theinstance_settingsrows (secret rows stay encrypted) and the<AREA>_PROVIDERenvironment variables;- row snapshots of what the switch modifies (
url-rewrite.jsonlfor storage,app_auth.*.jsonlfor auth — these contain password hashes); database.dump:pg_dump -Fcof the app database for database, auth and storage switches, when apg_dumpat least as new as the server is available (PG_DUMP, else onPATH). A missing dump is a warning, not an error;--no-dumpskips it.switch.json: the switch record, updated after every step.
- Steps, each recorded in the switch log as it starts and finishes. Steps are idempotent: if one fails, the provider setting has not been touched (it is written last), the record says which step failed, and you fix the cause and run the same command again.
- Config.
providers.<area>and the--set/--secretoptions are written toinstance_settings(--secretvalues are encrypted withENCRYPTION_KEY; credentials such assecret_access_keyare refused as plain--setvalues — use--secretor asecret://reference). The app picks them up within a minute, without a restart. When the area's provider is set in the environment (STORAGE_PROVIDER=…), the environment wins over settings, so the switch prints the variable to change instead; set it and restart the app. - Record. The switch log row (
usertasks-admin history, and Settings → Infrastructure) gets the steps, the config before and after, and the environment changes to make.
usertasks-admin rollback <switch id> --yes undoes a switch: it restores the settings the switch changed (warning about any changed since), asks the area to undo its data changes where it can (below), prints the environment changes to revert, and records the rollback. --dry-run shows what it would do. A switch that failed half-way can be rolled back too (only its data changes, as it never wrote config).
Database
Copies the app database into another PostgreSQL 15+ (plain Postgres, RDS, Azure Database for PostgreSQL, Cloud SQL, AlloyDB, Neon, CloudNativePG, …) and points the app at it.
usertasks-admin switch database --to postgres \
--target-url 'postgres://usertasks:…@db.internal:5432/usertasks' --dry-runRequirements: an empty target database (rows already there are replaced); a target role that may set session_replication_role (a superuser, or rds_superuser / azure_pg_admin / cloudsqlsuperuser); and, when the target isn't Supabase, no Supabase-only provider left: switch auth to builtin / oidc / saml, realtime to sse and the scheduler to worker or external first (the pre-flight refuses otherwise). Storage can stay on Supabase Storage while the project exists.
Steps: migrate the target to head (the compat bootstrap creates the API roles and the auth.uid() shim); copy every table of the public and app_auth schemas in batches with triggers off (each target table is emptied first, so a re-run starts clean); carry sequence positions over; compare row counts table by table; an RLS smoke test (the rows a few real users see as authenticated must match on both sides); record the switch in the target's log too. Nothing is written to instance_settings — the database is bootstrap configuration. The switch prints the environment to set:
DB_PROVIDER=postgres
DATABASE_URL=<the --target-url, password masked>Downtime: the copy is a snapshot. Writes made to the source after it are not in the target. Stop writes (maintenance page, or stop the app and the worker), run the switch again for the final copy (a re-run replaces the target's rows), then restart the app with the new environment. Expect a few minutes for a small install; the copy runs at roughly bulk-insert speed.
Rollback: point the app back at the source (the switch never writes to it); the printed DB_PROVIDER / DATABASE_URL are the previous values. Writes made to the target since the switch are not copied back.
Auth
usertasks-admin switch auth --to builtin --dry-run
usertasks-admin switch auth --to oidc --map google=oidc:google,azure=oidc:entra --yes- From Supabase Auth to
builtin,oidcorsaml: every liveauth.usersaccount is copied intoapp_auth.credentialswith the same id, email, bcrypt hash, confirmation time and metadata — people keep their passwords — andauth.identities(Google, Microsoft, SAML logins) intoapp_auth.identities(--maplinks them to your configured identity providers by subject; unmapped ones link by verified email). Foroidc/samlthe pre-flight checks thatAUTH_SSO_PROVIDERShas an identity provider of that type and fetches its OIDC discovery document. Supabase Auth's tables are only read, so switching back stays possible. - Back to Supabase Auth: every built-in session is revoked. The pre-flight counts built-in accounts that have no Supabase Auth account (created after the first switch): they can't sign in on Supabase.
- Between
builtin,oidcandsaml: config only (same store).
After the switch it prints the redirect URI (OIDC) or ACS URL and entity id (SAML) to register at each identity provider.
Downtime: none for the switch itself, but everyone signs in again (sessions don't carry over between auth providers). Run it once ahead of time (it's idempotent) and once more just before switching, to pick up accounts created meanwhile.
Rollback: restores the settings, removes the accounts the switch copied, restores app_auth accounts that existed before, and revokes built-in sessions.
Storage
usertasks-admin switch storage --to local --set local_root=/var/lib/usertasks/files --dry-run
usertasks-admin switch storage --to s3 --set endpoint=http://minio:9000 --set force_path_style=true \
--set access_key_id=usertasks --secret secret_access_key=… --yesSteps: copy every object of the app's buckets to the target, reading each copy back and comparing SHA-256 (re-runs skip unchanged objects; objects that already exist with different content are reported as conflicts — --overwrite replaces them); rewrite stored file URLs (logos, theme images and fonts, integration and node icons become storage:// references that follow the provider; file objects in data-table rows and workflow data keep a URL, re-pointed at the target); verify every source object is in the target. --bucket (repeatable) and --prefix limit the copy.
Downtime: none needed. Uploads made between the copy and the config change stay in the old store: re-run the switch (only new objects are copied), or schedule it in a quiet period. File URLs follow the new provider within a minute.
Rollback: restores the settings and every rewritten value whose current value is still the one the switch wrote (later edits are kept and listed). Objects copied to the target are left there.
Scheduler
usertasks-admin switch scheduler --to worker --yes
usertasks-admin switch scheduler --to pg_cron --base-url https://tasks.example.com --yes- To
workerorexternal: unschedules theusertasks-*pg_cron jobs (Vault secrets are kept). The in-process worker starts when the app starts, so restart the app — or run the worker as its own process (node worker.cjsin the Docker image,npm run workerfrom a checkout) withSCHEDULER_IN_PROCESS=falseon web replicas. Forexternal, point your cron atPOST <APP_URL>/api/cron/tickevery minute. - To
pg_cron: (re)installs the tick and housekeeping jobs and stores the base URL andCRON_SECRETin Vault (--no-secretskeeps the existing ones).
Downtime: at most a minute of missed ticks (every job catches up on the next tick). Rollback: restores the setting and the pg_cron job set as it was.
KMS
usertasks-admin switch kms --to vault_transit|aws_kms|azure_key_vault|gcp_kms|env --set <option>=… moves the data keys to another key-encryption key: a probe key round trip through the target, then every data key is unwrapped with the KMS that wrapped it, wrapped by the target and verified before its row changes (one row at a time; re-running skips rows already moved). With no data key yet, the first one is created under the target. Stored secrets are not re-encrypted by the switch; restart the app, then run usertasks-admin kms rewrap --yes (resumable) to move enc:v1 values and older data keys onto the newest key. Rollback re-wraps the data keys with the previous KMS. Details: KMS.
Realtime, email, secrets and cache
Configuration-only switches, validated first:
- realtime
--to sse: a LISTEN/NOTIFY round trip on the realtime connection (REALTIME_DATABASE_URL/DATABASE_URL, session mode — a transaction-mode pooler doesn't deliver notifications). Open browser tabs keep the old provider until they reload. - email
--to smtp|ses|…: the provider's options (--set smtp_host=…,--secret smtp_password=…);--test-to you@example.comsends a test message before switching. - secrets
--to hashicorp_vault|azure_key_vault|…: everysecret://reference in every area's options must resolve in the new store. - cache
--to postgres|redis(--set redis_url=rediss://…): a probe key written, read back and deleted, and a rate-limit window counted across two calls. Rate-limit windows start empty on the new store; running instances are told through the old and new stores and switch within 30 s.
No downtime; rollback restores the settings.
Moving a whole install off Supabase
You switch one area at a time, in this order. The database goes last because its pre-flight check refuses while any Supabase-only provider is still configured.
usertasks-admin switch auth --to builtin --yes # everyone signs in again afterwards
usertasks-admin switch realtime --to sse --yes
usertasks-admin switch scheduler --to worker --yes # then run the worker (or restart the app)
usertasks-admin switch storage --to s3 --set endpoint=… --set access_key_id=… --secret secret_access_key=… --yes
usertasks-admin switch email --to smtp --set smtp_host=… --set from='UserTasks <noreply@example.com>' --test-to you@example.com --yes
usertasks-admin switch secrets --to db --yes # or hashicorp_vault, azure_key_vault, …
usertasks-admin switch cache --to postgres --yes # or redis --set redis_url=redis://valkey:6379
# stop writes (stop the app and the worker), then:
usertasks-admin switch database --to postgres --target-url 'postgres://…' --yesThen restart the app with the printed DB_PROVIDER=postgres and DATABASE_URL. Remove every SUPABASE_* / NEXT_PUBLIC_SUPABASE_* variable: the provider settings travel with the copied instance_settings rows, so nothing else is needed. Keep the same ENCRYPTION_KEY, because stored secrets and the --secret options are encrypted with it. Run usertasks-admin doctor against the new database.
Every switch before the database leaves the app running on Supabase's database, and each can be checked on its own. Dry-run each one first (--dry-run).
Rolling back a full migration
Undo in the reverse order.
- Database: point the app back at the source. Use the previous
DB_PROVIDER/DATABASE_URLand the Supabase variables. The switch never wrote to the source, so it is exactly as it was before the final copy. Writes made to the new database since then are not copied back. - Every other area: run
usertasks-admin rollback <switch id> --yesagainst the source database, newest switch first (usertasks-admin historylists them):- storage restores the stored URLs;
- auth removes the copied accounts and revokes built-in sessions;
- scheduler reinstalls the pg_cron jobs;
- kms re-wraps the data keys with the previous KMS;
- realtime, email, secrets and cache restore the settings.
Testing a full migration
scripts/e2e-instance-migration.ts rehearses all of this. It takes a disposable Supabase stack seeded with the Northwind demo, runs every switch above in order, restarts the app without Supabase, and checks the result: the surface E2E, sign-in with a migrated password, files, worker jobs, log export, the doctor, and the RLS snapshot. See Deployment profiles.
Settings → Infrastructure
A page for instance administrators: the people listed in INSTANCE_ADMIN_EMAILS or added with usertasks-admin instance-admins add ops@example.com, matched against the signed-in account's verified email. Workspace roles never grant it (a workspace owner is not an instance admin); everyone else gets a 404. It shows the provider per area with on-prem / Azure / Google Cloud badges, health from the doctor checks (cached for five minutes; Re-run checks refreshes), the recent switch log, and links to these docs. Switching is only done with the CLI. The one editable part is the Housekeeping card (settings, table sizes, recent passes, Run now; the same as usertasks-admin housekeeping) — see Infrastructure: Housekeeping.
Testing switches
Rehearse on a copy, never on the live database. A disposable copy of a Supabase database can run in a separate supabase/postgres container:
docker run -d --name ut-scratch -p 55434:5432 -e POSTGRES_PASSWORD=postgres public.ecr.aws/supabase/postgres:17.6.1.084
psql postgresql://supabase_admin:postgres@127.0.0.1:55434/postgres -c 'create extension pg_cron; create extension pg_net'
pg_dump -Fc -n public -n auth -n app_auth -n app_cron -n app_events -n app_realtime -n supabase_migrations "$SOURCE_URL" > copy.dump
pg_restore --clean --if-exists -d postgresql://supabase_admin:postgres@127.0.0.1:55434/postgres copy.dumpFor the whole sequence against a running app, see Testing a full migration. The repository's end-to-end switch test (packages/admin/test/switch-e2e.test.ts) runs storage supabase → local → MinIO and back, auth supabase → builtin (a migrated user signs in with their old password) and back, the scheduler pg_cron → worker and back, and the database from such a copy into a fresh postgres:16, with ADMIN_E2E_SUPABASE_DB_URL, ADMIN_E2E_PLAIN_ADMIN_URL and ADMIN_E2E_S3_ENDPOINT set.