Skip to content

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.


The primary path is to pull the latest source and rebuild:

Terminal window
git pull
podman compose --env-file backend/container/.env up --build -d

For the native-pod path (podman.sh):

Terminal window
./podman.sh build && ./podman.sh restart

Migrations 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: ./backend

After:

services:
backend:
image: git.desord.re/eofredj/patrimoine/backend:vX.Y.Z

Apply 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.

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:

Terminal window
podman image inspect --format '{{.Digest}}' git.desord.re/eofredj/patrimoine/backend:vX.Y.Z

Or read it without pulling the image, with skopeo:

Terminal window
skopeo inspect --format '{{.Digest}}' docker://git.desord.re/eofredj/patrimoine/backend:vX.Y.Z

Then reference the digest in docker-compose.yml instead of the tag:

services:
backend:
image: git.desord.re/eofredj/patrimoine/backend@sha256:<digest>

Before updating across a breaking change:

  1. Check CHANGELOG.md (or the release notes) for BREAKING CHANGE or ! commits between your current version and the target version.

  2. 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_PORT and ADMIN_EMAILS are 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.json are served only with EXPOSE_API_DOCS=true (or DEBUG=true). If you used them, add EXPOSE_API_DOCS=true to backend/container/.env.

    • Transaction amounts now stored signed: Migration 050_signed_transaction_amounts flips the sign of every existing expense row in transactions and recurring_transactions (previously a positive magnitude with transaction_type carrying the sign separately; now negative for expense, positive for income). This runs automatically as part of alembic 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_active columns default to false/true for every existing user. To get a working admin:

      • OIDC: set ADMIN_EMAILS (or ADMIN_SUBS) in backend/container/.env to 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_EMAILS and ADMIN_SUBS never 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 in ADMIN_EMAILS has 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 now requires an explicit OIDC_ENABLED flag: previously OIDC was on whenever OIDC_CLIENT_ID was non-empty. Any deployment with OIDC_CLIENT_ID already set must also set OIDC_ENABLED=true in backend/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_transactions creates planned_transactions, copies every row out of bills (as a manual, auto_post=false item) and recurring_transactions (as an auto-posting auto_post=true item), remaps the Bill.recurring_transaction_id cross-reference and every recurring transaction’s tags onto the new table, then drops both source tables. This runs automatically as part of alembic 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 Bill to preserve), and a migrated recurring transaction with no description is given the placeholder title “Recurring transaction” (its original description, if any, is preserved unchanged in its own column either way). The old /bills and /recurring-transactions API 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_transactions section 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_unique adds 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 set archived = true.

      This runs automatically as part of alembic upgrade head. Nothing is deleted – every budget row, limit, and rollover history is preserved, only its archived flag changes – and no .env change 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 budgets table directly if a user needs a specific archived one restored.

    • Frontend host port variable renamed: PORT in frontend/container/.env is now FRONTEND_HOST_PORT. The old name is no longer read, so a deployment that sets PORT falls back to 8080 after this update until the variable is renamed.

    • Web container port and proxy variables renamed: DOCS_PORT in web/container/.env is now SITE_PORT, and DOCS_DOMAIN in the reverse proxy’s environment (Caddyfile.example) is now SITE_DOMAIN. The old names are no longer read, so a deployment that sets DOCS_PORT falls back to 8082 after this update until the variable is renamed.

    • Frontend backend address variables removed: the frontend container no longer reads BACKEND_HOST or BACKEND_PORT; it uses BACKEND_URL (for example http://backend:8000). With compose and podman.sh nothing changes, because the default BACKEND_URL is built from the backend’s BACKEND_PORT; only a frontend container started by hand with its own BACKEND_HOST or BACKEND_PORT needs the new variable.

    • Forwarding headers trusted only from private addresses: X-Real-IP and X-Forwarded-Proto are now honoured only from peers on private addresses unless TRUSTED_PROXIES is set. A reverse proxy that reaches the backend from a public address must be listed in TRUSTED_PROXIES, otherwise the real client IPs and the cookie Secure flag are lost.

    • PostgreSQL 18 by default: the managed PostgreSQL container (podman.sh and docker-compose.postgres.yml) now uses docker.io/library/postgres:18-alpine, and PostgreSQL 18 keeps its data under 18/docker in the mounted directory. An existing PostgreSQL 17 data directory is not upgraded in place: podman.sh refuses 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 to docker.io/library/postgres:17-alpine, or take a pg_dump with 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 head encrypts every existing webhook secret in place with a key derived from JWT_SECRET. No manual step is needed, but JWT_SECRET must 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 shows secret_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 same JWT_SECRET.

    • Local account emails lowercased (migration 087): email addresses of local accounts are now compared without regard to letter case, and alembic upgrade head lowercases every existing local email and local: sub in place. If two live local accounts differ only by case (for example Ann@example.com and ann@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 head adds database CHECK constraints so transactions and planned_transactions only accept income or expense as type, pending, cleared or reconciled as ledger status, and upcoming, due, posted, overdue or failed as 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) sends id_token_hint, client_id and post_logout_redirect_uri to the provider’s end-session endpoint. The ID token is kept for that purpose in a new id_token cookie, set at the next OIDC sign-in. Register FRONTEND_URL as an allowed post-logout redirect URI in your provider if you use the full logout.

    • GET /auth/logout removed (no migration): logging out is POST /auth/logout only, because a state-changing GET could be triggered from another site (for example an image tag) to sign a user out. A GET now returns 405. The web app and the Android app already use POST and the token-revoke endpoint, so nothing changes for them. If you registered /auth/logout as 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_ALGORITHM restricted (no migration): local-auth JWTs are now signed with a key derived from JWT_SECRET with HKDF, and TOTP secrets are encrypted under their own derived key. Nobody is signed out: tokens signed with the raw secret stay valid while JWT_ACCEPT_LEGACY_KEY is true (the default), and TOTP secrets under the old key are re-encrypted at the user’s next successful sign-in. After REFRESH_TOKEN_EXPIRE_DAYS (at most MAX_SESSION_DAYS) you can set JWT_ACCEPT_LEGACY_KEY=false. JWT_ALGORITHM must now be HS256, HS384 or HS512: 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 head converts 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-rules has a breaking change: budget_overspent must now send percent2 (an integer above percent, or null for 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): the net_worth.threshold webhook event is no longer sent. A crossed net worth threshold now appears in the bell like the other alerts and posts alert.fired with rule_type net_worth_threshold and the data alert_id, rule_type, threshold_id and direction, without the target amount or the current net worth. Webhook consumers that listened for net_worth.threshold must switch to alert.fired; schema_version stays 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_URL is 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 clear REDIS_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_id and effective_schedule instead of the planned-income field, and GET /api/v1/envelopes/payday-plan returns 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.

  3. Take a backup first (see Backup & Restore) before attempting any breaking upgrade.


The recommended rollback path is restore-from-backup:

  1. Stop the app.
  2. Restore the pre-update database backup (see Backup & Restore).
  3. git checkout the previous release tag.
  4. Rebuild.
  5. Restart.