Choose and pin a release, back up the database, and rehearse the migration before upgrading. Schema migrations are forward-only, so some rollbacks require a database restore and lose writes made after the backup.
Pin your version
Sim publishes images to GHCR, tagged by release alongside latest:
When image.tag is unset it uses the selected chart's appVersion. Published chart 1.11.8 uses v0.9.17; local checkout metadata can differ. Setting tags explicitly decouples image selection from chart upgrades. For maximum determinism, pin image.digest instead:
Images track latest by default. To pin, set SIM_VERSION in .env to a tag from the releases page:
One variable drives all three schema-coupled images, so they cannot drift apart. The cron service is deliberately excluded — it only makes HTTP calls and shares no schema, so it tracks latest unless you pin SIM_CRON_VERSION.
Upgrade procedure
Before applying an upgrade, configure a direct, non-pooled MIGRATION_DATABASE_URL on the migrator if the app uses a pooler. Session advisory locks do not survive transaction pooling. Pass the URL through the migrator's database Secret on Helm or its explicit environment on Compose; app.env or .env alone does not reach it. See Migration environment for the exact setup.
Read the release notes
Check the releases page for new required environment variables and breaking changes. When the chart's minor version moves, also read its
README.mdupgrade notes — chart upgrades occasionally rename or remove values keys.Apply
Code Preview changed chart values before applying. The following command requires the
helm-diffplugin:Code Then watch the rollout:
Code Code bunx sim-setup updateobtains the new images, then brings the stack up withdocker compose up -d, keeping data volumes. What it runs depends on which Compose file the install uses:Inspect the result with
bunx sim-setup logs, which targets whichever Compose file the install uses.docker-compose.prod.ymlrequiresPOSTGRES_PASSWORD. If you manage the file yourself and Compose stops withrequired variable POSTGRES_PASSWORD is missing a value, confirm which password initialized the existing database volume. If it was never set, the old default waspostgres; restore that value first, then see Postgres on Compose to rotate it.Before updating a pinned production install, replace
SIM_VERSIONin.envwith the chosen release tag. The update command pulls that configured tag; it does not automatically advance a pin.The CLI detects only those two files. An install started from
docker-compose.ollama.ymlis invisible to it:update,logs, andstatusreport no install, or — in a source checkout that also carries per-application env files — report that checkout's dev install instead. Upgrade that stack directly:Code Compose does not provide rolling updates. Plan a maintenance window while containers restart. Kubernetes supports rolling deployments, but database compatibility and in-flight execution behavior still need a rehearsal.
Verify
Run the verification checklist. At minimum: sign in, open a workflow, execute it, upload a file, and confirm the background jobs are still firing.
Rolling back
Application-only rollback is possible when no migrations ran, or when you have verified the previous app is compatible with the migrated schema. Additive migrations alone do not prove compatibility:
The local and Ollama stacks build the app from source and ignore SIM_VERSION, so roll those back by checking out the previous tag and rebuilding with the file — and, for the Ollama stack, the profile — that started the install:
Never roll back below v0.9.33 once account tokens are sealed: those releases cannot read them, and every connected account would have to be reconnected. See Account token sealing.
Rollback after an incompatible schema change requires restoring the pre-upgrade database backup. This loses writes made since the snapshot. Stop all writers before restoring:
- Stop traffic and all processes that can write to the database, including the app, realtime service, scheduled jobs, and any background workers.
- Restore the pre-upgrade database snapshot.
- Redeploy the previous image tag on the app, realtime, and migrations services, then resume the matching background workers and scheduler.
- Verify.
Run the verification checklist before reopening traffic. Preserve the original encryption secrets with the restored database.
When a migration fails
The app pods will not become ready — this is by design.
Common causes:
Do not manually edit the migrations table to skip a failed migration — the schema and Sim's expectations will diverge in ways that surface much later.
Migration reference
Migrations are Drizzle SQL files plus registered script (data) migrations, applied by a dedicated image in one locked session. A failure in either stage fails the migration container.
- Kubernetes — an init container on the app Deployment. Every app pod waits for migrations to complete before it starts, so a failed migration blocks the rollout instead of starting an app against a mismatched schema.
- Docker Compose — a one-shot
migrationsservice withrestart: nothat runs before the app.
Migrations are forward-only. Take the backup described in the upgrade procedure before applying them; there are no down-migrations.
Migrations apply in sequence, so skipping several releases at once generally works mechanically. It is riskier — those combinations get less testing, and you take several releases' worth of behavior changes in one step. Upgrade in smaller increments where you can.
How concurrent replicas are handled
Every app replica runs the full migrator — there is no leader. They are serialized by a Postgres session advisory lock, taken with a bounded try-lock loop rather than a blocking wait:
- One runner takes the lock and applies the pending files. The rest retry every 5 seconds.
- The applied files are journaled, so the runners that follow find nothing to do and exit.
- A runner that cannot get the lock within 30 minutes fails with a message naming the deadline.
A stuck runner holds up later replicas until their 30-minute lock-acquisition deadline. Inspect the earliest migration container's logs to find the holder; a later timeout may come from a runner that was only waiting.
Migration environment
These control the migrator and are set on the migrations container, not the app.
Supply the migration URL separately from the app URL:
Behavior that is fixed in the migrator and not configurable, but that explains what you see in the logs:
The lock_timeout deliberately trades a table-wide stall for a failed migration: without it, DDL waiting on an AccessExclusiveLock queues every other query on that table behind it for the whole wait.