Product documentation
Upgrade and rollback
Rolling upgrades via helm upgrade: the immutable image-SHA contract, forward-only Flyway migrations on boot, surge-rolling with tiered readiness probes, and rollback with helm rollback.
Upgrade and rollback
An upgrade is the same command as a first deploy — helm upgrade --install against a new
image SHA. Because every change ships as code (chart, migrations, config, source) and the
deployed state is rebuildable from git, an upgrade is a re-render of the same chart at a
newer commit, not a bespoke procedure.
helm upgrade --install thoryn ./deploy/helm/thoryn \
--namespace thoryn \
-f deploy/helm/thoryn/values-<env>.yaml \
--set global.image.tag="$(git rev-parse HEAD)" \
--timeout 15m --cleanup-on-failThe immutable image-SHA contract
Every image tag the platform deploys must be an immutable commit SHA. The mutable
:latest tag is forbidden both in the publish pipeline and at deploy time. This is enforced
structurally:
global.image.taghas no default invalues.yaml. Thethoryn.imageTagHelm helper wraps it in arequiredassertion, so a render with no tag fails rather than silently resolving to:latest.- Each app service can carry its own
<service>.image.tag; when set it overrides the fleet-wideglobal.image.tag, so a deploy rolls only the services whose content changed. A service with no per-service tag falls back to the global tag. The staging workflow computes this per-service map from the commit that last touched each service; a manual deploy typically just setsglobal.image.tagand rolls everything to the same SHA.
Why this matters for upgrades: an immutable SHA is what makes an upgrade reproducible and
reversible. Rolling back is re-deploying the previous SHA; there is no ambiguity about
what :latest pointed at last week. See
docs/modules/ROOT/pages/operator/image-tag-contract.adoc for the full contract.
Source: deploy/helm/thoryn/templates/_helpers.tpl (thoryn.imageTag), values.yaml.
Schema migrations are forward-only, applied on boot
There is no separate migration step. Each Spring service runs Flyway on boot against its
database and applies any new V*__*.sql migrations before it reports ready. Two rules govern
what an upgrade may carry:
- Migrations are immutable once applied. Never edit a
V*__*.sqlthat has shipped — a CI gate (scripts/check-migration-immutability.sh) rejects in-place edits, deletions, and renames of a migration already onmain. Correct a mistake by adding a newV<n+1>__<slug>.sql, never by editing the old one. - Recovery is forward-patch only. If a migration drifts or fails on an existing database,
the fix is a new migration that converges the schema — never
DROP DATABASE,TRUNCATE, or a re-bootstrap. That rule holds in every environment; the procedure is on the backup-and-recovery page.
Because migrations gate readiness, an upgrade that ships a bad migration surfaces as a pod that never becomes ready — the rolling update holds the old pod in service rather than cutting traffic to a broken new one (see readiness below).
Rolling behaviour and readiness
The chart is built so a routine upgrade does not churn the whole stack:
- Infra stays up. Postgres, Redis, and OpenBao are image-digest-pinned with stable pod
templates, so they do not roll on a code deploy. Only app services (hub, identity,
product-api, api-gateway, …) surge-roll — a new pod becomes Ready before the old one
terminates. See
docs/modules/ROOT/pages/operator/deploy-rollout-model.adoc. - Probes are tiered so a cold-start blip cannot crash-loop a healthy pod. The
startup probe targets the cheap canonical liveness endpoint
(
/actuator/health/liveness) — "is the JVM up and the context initialised?" — while the readiness group folds in the deep checks (Redis ACL, OpenBao Transit, JWKS reachability, R2DBC pool). A transient Transit warm-up or Redis-ACL render delay flips readiness, not liveness, so the pod is given time to become ready instead of being killed. A CI guard enforces the canonical startup-probe path. - OpenBao consumers wait for their role. Services that authenticate to OpenBao carry a
wait-for-vault-roleinit container that blocks the JVM until the service's Kubernetes-auth role is bound — so on a fresh backend the pod waits rather than crash-looping past an unprovisioned OpenBao.
Watch an upgrade land:
for d in thoryn-hub thoryn-identity thoryn-product-api thoryn-api-gateway; do
kubectl rollout status deployment/$d -n thoryn --timeout=10m
doneThen re-run the smoke test (scripts/smoke-test.sh) as the from-scratch verify — a green
render is not a validated upgrade.
Source: CLAUDE.md (tiered readiness; startup-probe canonical path),
docs/content/product/architecture/delivery-models.mdx.
Rollback
Because the previous release is a previous immutable SHA, rollback is a first-class Helm operation:
# Roll back to the immediately-previous release revision.
helm rollback thoryn -n thoryn
# Or re-deploy a specific known-good SHA explicitly.
helm upgrade --install thoryn ./deploy/helm/thoryn \
-n thoryn -f deploy/helm/thoryn/values-<env>.yaml \
--set global.image.tag=<known-good-sha> --timeout 15m --cleanup-on-failA
helm rollbackreverts the workloads and config, but it does not reverse a Flyway migration. Migrations are forward-only by design — rolling application pods back to an older image against a newer schema is only safe when the newer schema is backward-compatible (the platform's additive-migration convention aims for this). If a schema change is genuinely incompatible with the previous image, the recovery is a forward migration that restores compatibility, plus a code fix — not a schema rollback. See the forward-patch rule on the backup-and-recovery page.
If a helm upgrade gets stuck in pending-upgrade (e.g. it timed out mid-roll),
scripts/deploy.sh handles it by rolling the release back before retrying; manually,
helm rollback thoryn -n thoryn (or helm uninstall as a last resort on a first install)
clears the stuck state.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
Render fails: Image tag MUST be set to an immutable SHA | No global.image.tag / per-service tag was passed. Set --set global.image.tag=<sha>. |
| New pod never becomes Ready after upgrade | A readiness deep check is DOWN (OpenBao Transit, Redis ACL, JWKS, R2DBC) or a Flyway migration failed on boot. Check the pod log; the rolling update keeps the old pod serving until the new one is Ready. |
helm upgrade fails: ... release: already exists / stuck pending-upgrade | A previous upgrade did not finish. helm rollback thoryn -n thoryn, then retry. |
Upgrade fails on a Server-Side Apply conflict with "kubectl-…" | An emergency kubectl write scarred field-ownership; reclaim it (see backup-and-recovery). |
| Only some services rolled, others did not | Expected — per-service image tags roll only the services whose content changed; infra never rolls on a code deploy. |
Security notes
- Never deploy a mutable tag.
:latestbreaks reproducibility and makes rollback ambiguous; therequiredhelper and the publish pipeline both forbid it. - Never edit an applied migration to "fix" an upgrade. Add a forward migration. The
immutability gate and the never-
DROPrule protect user-created data in every environment. - Prefer a re-deploy over
kubectl edit. A manual live edit both violates the rebuildable-from-git rule and scars Helm's Server-Side Apply ownership, breaking the next upgrade until reclaimed.