Updating a Deployment
This guide covers how to update a running Patrimoine deployment: the routine build-from-source path, pulling a published registry image instead, handling breaking/major upgrades, and rolling back.
Routine Update
Section titled “Routine Update”The primary path is to pull the latest source and rebuild:
git pullpodman compose --env-file backend/container/.env up --build -dFor the native-pod path (podman.sh):
./podman.sh build && ./podman.sh restartMigrations run automatically — backend/container/Containerfile’s CMD runs alembic upgrade head before starting uvicorn on every container start (see Docker Images). No manual migration step is needed for a routine update.
Alternative: Pulling a Published Registry Image
Section titled “Alternative: Pulling a Published Registry Image”If you don’t want to build locally, versioned images are published to the registry by the release workflow: git.desord.re/eofredj/patrimoine/{backend,frontend,web}:vX.Y.Z (see Docker Images and Releases).
This requires editing docker-compose.yml to replace each service’s build: block with an image: line. This is a manual, opt-in step — the shipped docker-compose.yml builds from source by default.
Before:
services: backend: build: context: ./backendAfter:
services: backend: image: git.desord.re/eofredj/patrimoine/backend:vX.Y.ZApply the same change to the frontend service (git.desord.re/eofredj/patrimoine/frontend:vX.Y.Z) and, if you run the website, to the docs service (git.desord.re/eofredj/patrimoine/web:vX.Y.Z). When the docs service gets an image: line, drop its build: and additional_contexts.
Pin by digest
Section titled “Pin by digest”A tag such as vX.Y.Z can in principle be pushed again, so a deployment that must run exactly what was released should pin the image by its content digest. Each release run records the digest of every image it pushes (backend, frontend and web) in the run’s job summary and in the success notification.
Read the digest of a tag you have already pulled:
podman image inspect --format '{{.Digest}}' git.desord.re/eofredj/patrimoine/backend:vX.Y.ZOr read it without pulling the image, with skopeo:
skopeo inspect --format '{{.Digest}}' docker://git.desord.re/eofredj/patrimoine/backend:vX.Y.ZThen reference the digest in docker-compose.yml instead of the tag:
services: backend: image: git.desord.re/eofredj/patrimoine/backend@sha256:<digest>Breaking/Major Upgrades
Section titled “Breaking/Major Upgrades”Before updating across a breaking change:
-
Check
CHANGELOG.md(or the release notes) forBREAKING CHANGEor!commits between your current version and the target version. -
A breaking change may require a manual step — a new required env var, a data backfill, etc. Any release whose migration needs a manual step documents it as an addendum in this section, in the same commit as the migration.
-
Stricter startup validation:
JWT_SECRET,FRONTEND_URL,OIDC_ISSUER,OIDC_REDIRECT_URI,SMTP_PORTandADMIN_EMAILSare validated at startup (see Environment Variables). A malformed value that was previously ignored stops the container from starting, with a message naming the offending variable. -
API docs off by default: the interactive API docs (
/docs,/redoc) and/openapi.jsonare served only withEXPOSE_API_DOCS=true(orDEBUG=true). If you used them, addEXPOSE_API_DOCS=truetobackend/container/.env. -
Transaction amounts now stored signed: Migration
050_signed_transaction_amountsflips the sign of every existing expense row intransactionsandrecurring_transactions(previously a positive magnitude withtransaction_typecarrying the sign separately; now negative for expense, positive for income). This runs automatically as part ofalembic upgrade head– no separate manual step is required – but because it rewrites every existing expense row’s stored value in place, make sure you’ve taken the backup described in step 3 below before updating across this migration. -
Admin role added (migration 060/061): existing deployments have zero admins after this update — the
is_admin/is_activecolumns default tofalse/truefor every existing user. To get a working admin:- OIDC: set
ADMIN_EMAILS(orADMIN_SUBS) inbackend/container/.envto the email of the account that should become admin, then restart the backend or have that user sign in once — promotion happens automatically on their next authenticated request. - Local auth:
ADMIN_EMAILSandADMIN_SUBSnever promote local accounts, and the first-account shortcut only works on an empty instance, so neither helps an existing install. Promote the account directly in the database, as described inADMIN_EMAILShas no effect.
See Environment Variables for details. This is optional: the app continues to work with zero admins, just without the new user-management features.
- OIDC: set
-
OIDC now requires an explicit
OIDC_ENABLEDflag: previously OIDC was on wheneverOIDC_CLIENT_IDwas non-empty. Any deployment withOIDC_CLIENT_IDalready set must also setOIDC_ENABLED=trueinbackend/container/.env— otherwise OIDC silently falls back to local-auth-only after this update.See Environment Variables for details.
-
Bills and Recurring Transactions merged into Planned Transactions: Migration
076_create_planned_transactionscreatesplanned_transactions, copies every row out ofbills(as a manual,auto_post=falseitem) andrecurring_transactions(as an auto-postingauto_post=trueitem), remaps theBill.recurring_transaction_idcross-reference and every recurring transaction’s tags onto the new table, then drops both source tables. This runs automatically as part ofalembic upgrade head, but it is a one-way reshape of live financial data — restoring from a database backup is the only way back once it has run, so take a backup first (see step 3 below) before updating across this migration.Two values are inferred during the copy because the old schema has nowhere to read them from: every migrated bill becomes an expense with its amount sign-normalised (there was no income/expense distinction on
Billto preserve), and a migrated recurring transaction with no description is given the placeholder title “Recurring transaction” (its originaldescription, if any, is preserved unchanged in its own column either way). The old/billsand/recurring-transactionsAPI endpoints are removed in this same release — the Android app must be rebuilt and reinstalled in the same sitting as this backend update, since the previously-installed APK’s Bills and Recurring Transactions screens stop working the moment this migration runs.A JSON backup file exported before this update still restores correctly after it: the import side keeps reading the same
bills/recurring_transactionssection names and routes their rows into the merged table with the same inferred values this migration uses, so nothing needs to be re-exported. -
Duplicate active budgets archived automatically: Migration
080_budget_active_uniqueadds two partial unique indexes so a user can hold at most one active budget per category-level slot and per (category, subcategory) slot. Before creating those indexes, it archives any pre-existing active duplicates it finds – for each group of active budgets sharing the same category/subcategory slot, the oldest one (lowest id) stays active and the rest are setarchived = true.This runs automatically as part of
alembic upgrade head. Nothing is deleted – every budget row, limit, and rollover history is preserved, only itsarchivedflag changes – and no.envchange or manual step is required.Affected users will simply see one budget per category/subcategory after this update instead of several; a previously-hidden duplicate can be found by an admin querying the
budgetstable directly if a user needs a specific archived one restored. -
Frontend host port variable renamed:
PORTinfrontend/container/.envis nowFRONTEND_HOST_PORT. The old name is no longer read, so a deployment that setsPORTfalls back to8080after this update until the variable is renamed. -
Web container port and proxy variables renamed:
DOCS_PORTinweb/container/.envis nowSITE_PORT, andDOCS_DOMAINin the reverse proxy’s environment (Caddyfile.example) is nowSITE_DOMAIN. The old names are no longer read, so a deployment that setsDOCS_PORTfalls back to8082after this update until the variable is renamed. -
Frontend backend address variables removed: the frontend container no longer reads
BACKEND_HOSTorBACKEND_PORT; it usesBACKEND_URL(for examplehttp://backend:8000). With compose andpodman.shnothing changes, because the defaultBACKEND_URLis built from the backend’sBACKEND_PORT; only a frontend container started by hand with its ownBACKEND_HOSTorBACKEND_PORTneeds the new variable. -
Forwarding headers trusted only from private addresses:
X-Real-IPandX-Forwarded-Protoare now honoured only from peers on private addresses unlessTRUSTED_PROXIESis set. A reverse proxy that reaches the backend from a public address must be listed inTRUSTED_PROXIES, otherwise the real client IPs and the cookieSecureflag are lost. -
PostgreSQL 18 by default: the managed PostgreSQL container (
podman.shanddocker-compose.postgres.yml) now usesdocker.io/library/postgres:18-alpine, and PostgreSQL 18 keeps its data under18/dockerin the mounted directory. An existing PostgreSQL 17 data directory is not upgraded in place:podman.shrefuses to start and tells you so, while with compose the new image starts an empty database in the same volume and leaves the old files untouched.To keep your data, pin
POSTGRES_IMAGE(podman.sh) or the compose image todocker.io/library/postgres:17-alpine, or take apg_dumpwith the old image before updating and restore it into the new one. Take the backup first (see step 3 below). -
Webhook secrets encrypted at rest (migration 086):
alembic upgrade headencrypts every existing webhook secret in place with a key derived fromJWT_SECRET. No manual step is needed, butJWT_SECRETmust be set and must not change between this deploy and the migration, and must not change afterwards either: a webhook whose secret can no longer be decrypted stops delivering (its delivery history showssecret_unavailable) and has to be deleted and recreated. Delivery errors are now stored as short codes (timeout,tls_error,connect_error,blocked,http_NNN) instead of raw messages. Downgrading decrypts the secrets again and needs the sameJWT_SECRET. -
Local account emails lowercased (migration 087): email addresses of local accounts are now compared without regard to letter case, and
alembic upgrade headlowercases every existing local email andlocal:sub in place. If two live local accounts differ only by case (for exampleAnn@example.comandann@example.com), the migration stops before changing anything and prints the colliding addresses; the container then fails to start. Nothing is merged or deleted for you: sign in as an admin on the previous version (or edit the database) and change or delete one account of each pair, then start the new version again. Existing sessions keep working. Downgrading restores the previous index but cannot restore the original letter case. -
CHECK constraints on transaction type and status (migration 112):
alembic upgrade headadds database CHECK constraints sotransactionsandplanned_transactionsonly acceptincomeorexpenseas type,pending,clearedorreconciledas ledger status, andupcoming,due,posted,overdueorfailedas planned status. The API already enforced these values, so a normal database changes nothing. If a row holds anything else (for example after a manual edit), the migration stops before changing anything and names the table, column and values; the container then fails to start. Correct those rows in the database and start the new version again. Downgrading drops the constraints and changes no data. -
OIDC logout now ends the session at the identity provider (no migration): logging out revokes the refresh and access tokens at the provider when its discovery document advertises a
revocation_endpoint(best effort: a failure is logged and never blocks the logout), and a full logout (/auth/logout?full=true) sendsid_token_hint,client_idandpost_logout_redirect_urito the provider’s end-session endpoint. The ID token is kept for that purpose in a newid_tokencookie, set at the next OIDC sign-in. RegisterFRONTEND_URLas an allowed post-logout redirect URI in your provider if you use the full logout. -
GET /auth/logoutremoved (no migration): logging out isPOST /auth/logoutonly, because a state-changing GET could be triggered from another site (for example an image tag) to sign a user out. AGETnow returns405. The web app and the Android app already usePOSTand the token-revoke endpoint, so nothing changes for them. If you registered/auth/logoutas a front-channel logout URL at your OIDC provider, that URL stops working; remove it there. -
JWT and TOTP keys derived from
JWT_SECRET;JWT_ALGORITHMrestricted (no migration): local-auth JWTs are now signed with a key derived fromJWT_SECRETwith HKDF, and TOTP secrets are encrypted under their own derived key. Nobody is signed out: tokens signed with the raw secret stay valid whileJWT_ACCEPT_LEGACY_KEYistrue(the default), and TOTP secrets under the old key are re-encrypted at the user’s next successful sign-in. AfterREFRESH_TOKEN_EXPIRE_DAYS(at mostMAX_SESSION_DAYS) you can setJWT_ACCEPT_LEGACY_KEY=false.JWT_ALGORITHMmust now beHS256,HS384orHS512: a deployment that sets anything else fails to start with a message naming the variable. -
Budget alerts at two levels (migration 125): a category budget alert now fires at two levels, 80 % and 100 % of the monthly budget by default, once per category per month each.
alembic upgrade headconverts your users’ saved rules and alerts automatically; no manual step is needed. A saved budget rule at the old default of 100 % becomes two levels, 80 % and 100 %; a different saved percentage stays the only level; a percentage above 200 % becomes 200 %. Budget alerts already raised keep their state and do not fire again, and the first alert check after the update stores the new 80 % alerts without sending webhooks or emails.PUT /api/v1/alert-ruleshas a breaking change:budget_overspentmust now sendpercent2(an integer abovepercent, ornullfor a single level) and both values must be from 1 to 200, so scripts that call it must be updated. Downgrading deletes the stored budget alerts and drops the second level of every rule. -
Net worth threshold webhooks now use
alert.fired(migration 126): thenet_worth.thresholdwebhook event is no longer sent. A crossed net worth threshold now appears in the bell like the other alerts and postsalert.firedwithrule_typenet_worth_thresholdand the dataalert_id,rule_type,threshold_idanddirection, without the target amount or the current net worth. Webhook consumers that listened fornet_worth.thresholdmust switch toalert.fired;schema_versionstays 1. The update is automatic and needs no manual step: the first alert check after it stores the thresholds that are already crossed as notifications without sending any webhook or email. Downgrading deletes the stored net worth threshold alerts. -
Redis requires a worker: while
REDIS_URLis set the backend no longer runs the scheduled jobs itself (FX refresh, price refresh, alert checks, planned-transaction sweep, reports, cleanup, snapshots, backup). Run the arq worker (Background Worker) or clearREDIS_URL. -
Snapshot reminders follow notification preferences (migration 127): reminder emails are sent according to each user’s Notifications settings. The migration sets users who had an enabled recurring snapshot rule to
email, so they keep receiving them; no manual step. -
Payees (migrations 120-122, 129): automatic and additive; 121 and 122 merge duplicate payees created by 120 in place (descriptions are never modified). No manual step.
-
Pay schedules replace the planned-income link (migrations 131 and 132): automatic; no manual step. Migration 131 adds the pay schedules table. Migration 132 turns each planned income that an envelope or savings goal was linked to into a pay schedule when the income is live, enabled and recurring with no end date and no occurrence cap (incomes with the same cadence and next due date share one schedule, named after the income), points those items at it, and removes the old link. Items linked to a one-time, ended, capped, disabled or deleted income lose their link and show the monthly suggestion until a schedule is picked, and a payday the income had skipped ahead counts again. No default schedule is set, so every unlinked item keeps the monthly suggestion until you pick a schedule or mark one as the default. This is a breaking API change (version 9.0.0): envelopes and savings goals carry
funding_schedule_idandeffective_scheduleinstead of the planned-income field, andGET /api/v1/envelopes/payday-planreturns one group per pay schedule without the income amount, the leftover or the over-allocation flag. Installed Android apps keep working and still show the per-payday amount. Backups made before this change still restore: the planned-income link is mapped to a schedule by the same rules. A downgrade relinks by name only. Take a backup first, as for any breaking upgrade.
-
-
Take a backup first (see Backup & Restore) before attempting any breaking upgrade.
Rollback
Section titled “Rollback”The recommended rollback path is restore-from-backup:
- Stop the app.
- Restore the pre-update database backup (see Backup & Restore).
git checkoutthe previous release tag.- Rebuild.
- Restart.