Upgrading
Move a deployment to a new release, handle one-time migrations, and let the updater sidecar do it for you.
Upgrade
A Compose deployment on a published release tag upgrades by moving that tag:
docker compose --env-file .env -f infra/compose/docker-compose.prod.yml pull api worker web
docker compose --env-file .env -f infra/compose/docker-compose.prod.yml \
up -d --wait --pull never api worker web
A deployment on the default local tag has no registry to pull from, so it upgrades by rebuilding
the checkout instead:
git pull
GIT_SHA=$(git rev-parse HEAD) docker compose --env-file .env -f infra/compose/docker-compose.prod.yml \
up -d --wait --pull never --build api worker web
up --wait does not report success until the new API is healthy and the worker and web containers
are running. The API starts only after the migrate container finishes, so a migration failure keeps the new API
from starting. A failed CLI recreate does not auto-roll back; recover with the previous
RAKAZO_IMAGE_TAG (or rebuild local) and up -d --wait --pull never.
The updater sidecar has its own image and tag so an update never recreates the process performing
it. Move it deliberately by setting RAKAZO_UPDATER_IMAGE_TAG to the full sha-<commit> tag, then
running docker compose … pull updater && docker compose … up -d --wait --pull never updater.
Sidecar /apply and /rollback recover a failed recreate by redeploying the previously cached
image when possible; if that also fails, they report a possible mixed-version runtime.
Source checkouts (not Compose) still upgrade the old way: pull, rebuild with
GIT_SHA=$(git rev-parse HEAD), run pnpm db:migrate, then restart API and worker.
Product contracts stay compatible across cloud and self-hosted.
Upgrading to the separate app database login
Releases with database roles need POSTGRES_APP_PASSWORD in .env. The
desktop app, install-images.sh, the updater sidecar and infra/compose/deploy-main.sh add it
when it is missing.
Elsewhere, add it yourself (openssl rand -hex 16) before you pull, or Compose stops at config
validation with Set POSTGRES_APP_PASSWORD in .env. On first start, the API creates the
rakazo_app login, and the API and worker then connect as it. Nothing else changes, and the
upgrade does not rewrite any data.
If a feature misbehaves under row-level security, run ALTER ROLE <login> BYPASSRLS; on the login
in DATABASE_URL (rakazo_app in Compose) as a superuser (the Compose owner is one), then
restart the API and worker. Role attributes are not inherited, so granting it to rakazo_app does
nothing for a separate login. The app then runs as before, with a startup warning, and later
migrations leave that login alone. Undo it with
NOBYPASSRLS and another restart, then search the API and worker logs for
violates row-level security and without a tenant or system context. An older release ignores
the extra variable and connects as the owner, but releases from the same day also encrypt content
at rest: run content-encryption decrypt before rolling back past them
(content encryption), or older releases show ciphertext.
Upgrading to Postgres 18
The production and source-checkout Compose files run Postgres 18. Installs created before that
keep their data in a Postgres 16 volume (pgdata), which Postgres 18 cannot open. Until the data
is copied to the new postgres volume, the database container exits and logs that it found
PostgreSQL data in /var/lib/postgresql/16/docker, and production deploys stop before replacing
anything. Nothing is deleted or started empty. New installs need nothing. The published-images
file (docker-compose.images.yml, also used by the desktop app) stays on Postgres 16.
Take a backup and run the drill on it first (encrypted backups). Then update the checkout and run the upgrade as root:
cd /srv/rakazo
git pull --ff-only
sudo infra/compose/upgrade-postgres.sh
When the stack uses other Compose files, such as the Docker computers overlay or the
source-checkout stack, pass all of them:
sudo RAKAZO_DEPLOY_DIR="$PWD" infra/compose/upgrade-postgres.sh infra/compose/docker-compose.yml.
Set COMPOSE_PROJECT_NAME if the stack runs under a custom -p, and on restricted networks set
RAKAZO_POSTGRES_16_IMAGE to your mirror of the previous Postgres 16 image (POSTGRES_IMAGE must
now name a Postgres 18 image).
The script stops the stack, copies every role (with its password) and database from Postgres 16
with pg_dumpall, and compares roles, memberships, ownership, grants, row-level security
policies, sequences and the row count of every table. It also checks that rakazo_app signs in
with POSTGRES_APP_PASSWORD and does not bypass row-level security. Only then does it switch
Postgres 18 over and restart the services that were running. The site is down for the whole copy,
which takes about as long as dumping and restoring the database: a few minutes for a small one. It needs free disk for one more copy of the
database plus 1 GiB. Running it again after it succeeded does nothing. If any step fails, it stops
with the stack down and the Postgres 16 data unchanged; fix the error and run it again. On the
production host, then rerun the deploy that stopped.
The Postgres 16 volume stays as it was, mounted read-only. To roll back, check out the previous
release and start the stack: it runs Postgres 16 on the original data. Anything written after the
upgrade exists only on Postgres 18, so roll back right away or not at all, and pause CI deploys
until you upgrade again. Once you no longer need the rollback, stop postgres, remove the
<project>_pgdata volume and start it again; Compose recreates that volume empty.
Space privacy-boundary migration
Before upgrading across migration 20260830200000_space_scope_names_and_user_credentials, check
that every existing model and voice credential still belongs to a member of its Space. This query
uses the pre-migration workspaceId column name and must return no rows:
SELECT 'model' AS credential_type, credential."id", credential."userId",
credential."workspaceId" AS "spaceId"
FROM "user_model_credentials" AS credential
LEFT JOIN "space_members" AS membership
ON membership."spaceId" = credential."workspaceId"
AND membership."userId" = credential."userId"
WHERE membership."id" IS NULL
UNION ALL
SELECT 'voice', credential."id", credential."userId", credential."workspaceId"
FROM "user_voice_credentials" AS credential
LEFT JOIN "space_members" AS membership
ON membership."spaceId" = credential."workspaceId"
AND membership."userId" = credential."userId"
WHERE membership."id" IS NULL;
The migration renames columns used by the API and worker and is therefore a coordinated cutover, not an online rolling migration. Stop the old API and worker, apply the migration, and start the new versions together. Its lock waits are bounded so contention fails the migration instead of leaving application traffic queued indefinitely.
Published images and tags
.github/workflows/publish-server-image.yml publishes to ghcr.io/<owner>/<repo>/…, derived from
${{ github.repository }} rather than hardcoded, so a fork’s CI fills the fork’s own namespace. For
this repository that is:
| Image | Contents |
|---|---|
ghcr.io/elie222/rakazo/app |
api, worker, web, and sandbox supervisor — one image, multiple commands |
ghcr.io/elie222/rakazo/computer |
Linux desktop used as each bot computer |
ghcr.io/elie222/rakazo/updater |
the updater sidecar, plus the Docker CLI |
infra/compose/docker-compose.images.yml is the no-checkout path for those app and computer tags
plus Postgres. The supervisor runs from the app image on the internal network only (not a separate
published supervisor image, and no host port). Production Compose (docker-compose.prod.yml) can
also pull the same app tags once RAKAZO_IMAGE_TAG is set to a published value.
If you deploy from your own fork, set RAKAZO_IMAGE and RAKAZO_UPDATER_IMAGE to your namespace —
your CI cannot publish into someone else’s.
| Tag | Published on | Moves? |
|---|---|---|
local |
nothing — built locally by up --build |
rebuilt in place |
local-<full-commit> |
nothing — built on the server by a fork update | never |
vX.Y.Z, vX.Y |
release tags | conventionally no / on patch releases |
latest |
stable vX.Y.Z tags only (not prereleases) |
yes, to the newest stable release |
sha-<full-commit> |
every main commit, release tag and manual run | source-addressed; used by the updater sidecar |
edge |
pushes to main | yes, to the newest main build |
Every publish, including edge from main merges, is multi-arch (amd64 + arm64): each
architecture builds natively, on Depot when the DEPOT_PROJECT_ID repository variable names a
project and on GitHub’s runners otherwise, and one manifest is assembled per image. Depot builds
publish and attest each image in the same job; native GitHub builds combine their architecture
artifacts in a separate publication job. Main CI revisions validate independently. Publication
locks are per image, and a superseded main build keeps its commit tag without moving edge.
Image and release queues retain pending jobs so a slower older run cannot displace a newer one.
Production deployment remains serialized and deploys the exact revision that passed its checks.
Before any tag points at a new app image, CI fails the publish if it holds a .env file, a .git directory or a
private key (infra/compose/check-image.sh). Until a
stable vX.Y.Z has been published, GHCR may only have edge and sha-* tags; do not pin
latest unless that tag exists in the registry.
Building the images yourself does not need QEMU. docker compose up --build builds for the host’s
own architecture, and a fork publishing multi-arch images should do what publish-server-image.yml
does: build each architecture on a native runner (GitHub Actions provides ubuntu-24.04-arm for
public repositories) and merge the digests into one manifest. QEMU emulation
(docker/setup-qemu-action, binfmt) still works if you have no native arm64 machine, but it is
many times slower, hours rather than minutes for the computer image.
The updater resolves the newest stable vX.Y.Z source tag but deploys its sha-<full-commit> image,
not latest or a moving minor tag. A registry tag is not an OCI digest and GHCR package writers can
replace it, so the trust boundary remains this repository’s publishing credentials. The workflow
reduces that boundary by using SHA-pinned actions, read-only pull-request jobs, digest-pinned base
images, SBOM/provenance output, and a GitHub build attestation. Operators who require registry-level
content addressing can pin RAKAZO_IMAGE outside the automatic updater to a verified digest.
Rollback never contacts the registry: it redeploys the previous tag from the local Docker cache, so a later tag move cannot change rollback content. Do not prune the previous application image until the next update has been accepted. If it is missing, rollback fails closed instead of pulling new content under an old tag.
To populate the registry the first time, run the workflow manually (workflow_dispatch) or push a
v* tag. A manual run produces sha-<full-commit>; only a stable vX.Y.Z tag (no prerelease
suffix) produces latest, and any v* tag produces semver tags. The updater ignores prereleases
and refuses the official path until a stable vX.Y.Z exists.
Updater sidecar
Compose production deployments offer an opt-in updater profile on a private control network.
Normal deployments do not start it or require its credential. To enable it, set a dedicated
RAKAZO_UPDATER_TOKEN and explicitly start the profile:
docker compose --env-file .env -f infra/compose/docker-compose.prod.yml \
--profile updater up -d --build updater
It exposes /health, /state, /plan, /apply, and /rollback at http://updater:7092 with
RAKAZO_UPDATER_TOKEN. Operator CLI upgrades above do not need it; the sidecar is for automated
apply/rollback over that private HTTP API. Only deployment tooling calls it; app accounts have no update endpoint, and the application API does not receive the updater token.
The API cannot update itself — its image has no .git, and nothing inside the container would
restart it — so the work happens in a separate updater container that outlives the recreate:
- Official repository: resolves the newest stable release and its source commit with
git ls-remote --tags, pins the corresponding fullsha-<commit>image tag in.env, keeps the outgoing tag inRAKAZO_IMAGE_TAG_PREVIOUS, explicitly pulls the new image, then runsup -d --wait --pull never. No build runs on the server. - Fork (Advanced): a fork has no published images, so the sidecar fast-forwards the checkout in
RAKAZO_DEPLOY_DIRand runsup -d --build. This builds on the server and takes minutes rather than seconds. Point it only at a fork you control and have reviewed — the sidecar runs that Compose file through a root-equivalent Docker socket.
Updates and rollbacks run one at a time. A failed pull leaves running services alone; a failed recreate restores the previous environment
pin and attempts to redeploy the cached previous image. A failed fork build also restores the
pre-update branch and commit (including when checkout succeeded but merge did not) so a later
manual --build cannot deploy the rejected or unintended revision. Database migrations are not
reversed. The sidecar never recreates itself, never touches Postgres or Caddy, and never runs
migrations — that ordering belongs to the API’s dependency on the migrate service.
Only https:// and ssh:// git remotes are accepted. Merges are fast-forward only. A dirty or
untracked source tree fails closed before anything runs (the application Dockerfile uses COPY . .).
The deploy directory must be one path
RAKAZO_DEPLOY_DIR is bind-mounted into the updater at the same path it is read from
(${RAKAZO_DEPLOY_DIR}:${RAKAZO_DEPLOY_DIR}), and that is load-bearing rather than tidy. Production
Compose defaults both sides to /srv/rakazo; set the variable for any other layout. When the
updater runs docker compose -p <project> --file $RAKAZO_DEPLOY_DIR/infra/compose/docker-compose.prod.yml up -d,
the Compose CLI inside the container expands this file’s relative bind mounts — ../../.env,
./Caddyfile.prod — against that path and hands the results to the daemon. The daemon has to be
able to resolve the same strings, or it silently creates empty directories where your .env and
Caddyfile should be. Compose makes the effective -p value available for interpolation but does
not automatically put it in a container’s environment, so the production file explicitly assigns
COMPOSE_PROJECT_NAME to the updater. A standalone sidecar can instead set
RAKAZO_COMPOSE_PROJECT_NAME; the final fallback is rakazo-prod. Without that propagation, a
stack started with -p something-else would be left alone while a second project with a new empty
Postgres volume came up beside it.
The value therefore has to be the path the daemon sees, which is not always the path your shell sees:
- Linux. The daemon shares the host filesystem, so the checkout path is the answer:
/srv/rakazois the default and supported production layout. SetRAKAZO_DEPLOY_DIRexplicitly when the checkout is elsewhere. - Docker Desktop (Windows/macOS). The daemon runs in a VM that mounts your drive somewhere else.
On Windows,
C:appears at/run/desktop/mnt/host/c, so a checkout atC:\Users\you\rakazoisRAKAZO_DEPLOY_DIR=/run/desktop/mnt/host/c/Users/you/rakazo. Host Git may usecore.autocrlf=true; the updater ignores CR-only diffs so that does not block/apply. Verify the mount before deploying:
docker compose --env-file .env -f infra/compose/docker-compose.prod.yml \
--profile updater run --rm updater git -C "$RAKAZO_DEPLOY_DIR" log --oneline -1
That must print your checkout’s HEAD. The two tempting wrong answers both fail: a native Windows
path is rejected by the daemon (mount denied: … too many colons, because the drive letter’s
colon collides with the bind-mount separator), and /mnt/c/... fails silently — the container
starts, the mount is an empty directory, and the updater simply reports no checkout.
Deployments that layer a Compose overlay
RAKAZO_COMPOSE_FILE takes a list, separated the way Compose’s own COMPOSE_FILE is
(: by default, or whatever COMPOSE_PATH_SEPARATOR says). Each entry becomes its own --file,
in the order given, so the updater reconciles the same stack the operator runs by hand:
RAKAZO_COMPOSE_FILE=infra/compose/docker-compose.prod.yml:ops/compose/overlay.yml
Every entry is validated separately and must stay inside RAKAZO_DEPLOY_DIR.
If the overlay adds a service built from the application image, name it in
RAKAZO_UPDATE_SERVICES (comma separated) so it is pulled, recreated and rolled back with the
rest. Otherwise an update leaves that service running the previous code:
RAKAZO_UPDATE_SERVICES=supervisor
These names are appended to the built-in api, worker, web, never substituted for them, so no
value here can drop a core service from an update.
The updater’s privileges
The updater holds the Docker socket, which is root-equivalent on the host. It is scoped as narrowly as that allows:
- No
ports, so nothing is published on the host. - Only on the dedicated
controlnetwork shared with the API. Caddy is not attached, so the reverse proxy has no route to the updater. - Every route except
/healthrequires the shared bearer token, compared in constant time. - The process environment carries only updater settings (
RAKAZO_UPDATER_TOKEN, deploy path, image name, project name). Application secrets stay in the bind-mounted.envthat Compose reads for interpolation; they are not loaded into this container. - The Docker CLI lives only in the updater image. The api, worker, and web containers keep
cap_drop: ALLand no socket.
Enabling the updater profile requires RAKAZO_UPDATER_TOKEN to be a dedicated random value (at
least 32 characters in production). It must differ from BETTER_AUTH_SECRET,
SANDBOX_SUPERVISOR_TOKEN, and SCREEN_PROXY_SECRET. Leave the profile disabled if you would
rather not grant the capability.