Skip to main content
Upgrading is a tag change plus a migration run. A new image can carry pending schema migrations; a one-shot runner applies the chain before the app services start, and each container refuses to serve on an out-of-date database (the boot gate). The image carries no state, so the data volumes are never touched — only the app containers are replaced.

Upgrade the Compose bundle

1

Snapshot the database first

A migration rolls forward only — there are no down-migrations. Before an upgrade that carries schema changes, take a Postgres snapshot (a managed provider’s point-in-time restore, or pg_dump) so a bad release is a restore away. Recovery is restore-then-re-migrate, never a rollback.
2

Pin the new tag

Set TAI_VERSION in .env to the release you are moving to.
TAI_VERSION in .env
3

Pull it

4

Run the migrations

The one-shot db-migrate service runs tai db migrate, which applies every pending migration across every component — the core chain and each installed plugin that owns one — in order, recording each in the tai_schema_history table. It is idempotent: a run with nothing pending reports so and exits 0. Preview a run first with docker compose run --rm db-migrate db migrate --plan, which lists the pending files without touching the database.
5

Recreate the app services only

--no-deps recreates the three app services against the new image without restarting Postgres or Redis, so the datastores — and their volumes — stay up.
Read the release notes before pinning. A release that needs a step beyond the migration run — a data backfill, a manual expand/contract cutover — names it.

The boot gate

Each serve and backend container checks, once at startup, that every schema chain its configured features and plugins own is fully applied. A pending migration or a rewritten (checksum-mismatched) chain is a loud refusal, not a half-migrated serve:
The fix is the message: run the migration step above, then let the container restart. If you recreate the app services before running db-migrate, they refuse to boot with exactly this message — run the migrations and they come up. Running db-migrate before the app services (the step order above) avoids the refusal entirely.

Runtime-installed plugins

An image upgrade replaces the core only. Plugins added at runtime through the marketplace live in the server’s plugin prefix (TAI_PLUGINS_PREFIX), not in the image, so they do not update with a new image tag — a freshly pinned image boots with the same plugin versions the prefix already holds. Update them as a step of their own, after the image upgrade:
This resolves each installed plugin to the newest version compatible with the core you are now running and installs it into the same persistent prefix. A router or middleware plugin — and a channel’s inbound webhook route — comes live on the next reload / config-apply, not on a container restart: the epoch rebuild that a reload runs re-imports the plugin package and its route-sibling modules and re-registers the declared routes, so an upgraded router or middleware is serving once that reload lands. The image pins its own core versions as a pip/uv constraints file at /opt/tai/constraints.txt and sets PIP_CONSTRAINT/UV_CONSTRAINT to it, so any install — a marketplace install, python -m pip install by hand, or a derived image’s build step — that would move a core package (tai42-contract, tai42-kit, tai42-skeleton, tai42-cli, or any other package the image ships) off the image’s version is refused by the resolver, never silently downgraded or upgraded. An incompatible plugin fails loudly at install:
The venv is root-owned, so a direct install into it runs as root — a derived image’s USER root build step, or docker exec -u 0 into a running container. The marketplace installer writes to its own --prefix and is unaffected. After a manual or derived-image install, run python -m pip check to confirm the environment is consistent.
A manifest-declared plugin that is incompatible with the new core — or otherwise cannot load — aborts boot loudly: the process exits naming the module, its kind, and the reason, rather than coming up missing a plugin the manifest asked for. Fix or update the plugin, point the manifest at a working one, or remove it from the manifest, then boot again. A plugin that cannot load during a live config reload aborts that reload instead: the previous generation keeps serving and the reload call returns the failure (a non-2xx naming the module, which tai config reload prints before exiting non-zero). Keep the plugin prefix on persistent storage so the installed set survives the container recreation an upgrade performs.

See also