Upgrading
發布於 更新於
A release can change the database schema its processes expect, so upgrading an install is more than restarting it on new images. The database goes first: the publiractl image of the new release brings the schema and the database roles forward, and only then do the processes of the new release start on it. This page goes through that order, how to check each step, and what to do when a migration fails.
The steps are written for an install you run yourself, as Installing brings up. The install from the repository's Compose file runs the same steps with commands of its own, in On the Docker Compose install.
Before you start
Read the release notes
Each release is a tag of the repository, published with its release notes on the repository's Releases page. Read the notes of every release after the one you run, up to and including the one you upgrade to. Skipping releases is fine as far as the schema goes, since db migrate applies the migrations of every release in between in one run, but nothing applies what the notes ask of you.
The notes of a release open with Upgrade notes, which names every change in it that asks something of you, under the pull request that made it, and says so when there is none. These are the changes it names:
- An environment variable added, renamed, or no longer read. Set it before the processes of the new release start.
- A new database role.
db rolesneeds a password for a role that does not exist yet: generate one as you did at install, and pass it with that role's flag. - A change to what the edge has to do, such as a path routed to another process. Make the same change at your edge, as Edge states it for the new release. If your edge is adapted from one of the sample configurations,
git diff <current tag> <new tag> -- infra/proxy/shows the change in the samples, for you to carry into your copy. - A process or a service the install has to run that it did not run before.
- A migration that runs long or locks a busy table, so that you can choose when to run it.
Only a major release names any of these, and before 1.0.0 a minor release. A patch release before 1.0.0 still runs every step on this page: it can carry a migration, and its processes fail on a schema db migrate has not brought forward, so db migrate and db roles run before its processes are replaced. Releases says what each kind of release may change and which releases receive fixes.
Get the images of the new release
Pull every image of the new release, ghcr.io/publira/<name>:<new version>, onto the hosts that run it before you change anything. The time between applying the migrations and restarting the processes is then only the time the processes take to start. If your install runs images you build yourself, build the new release's from its tag, with your changes carried onto it, and push them where your hosts pull from.
Every image has to come from the same release. publiractl carries the migrations and the role definitions of its release, each process expects the schema of its own, and the web apps call the API of the publira server they were released with.
The examples on this page name the published images, such as ghcr.io/publira/publiractl:<new version>, where the version is the release's Git tag without its v: 0.2.0 for v0.2.0. An install that builds its own names those instead.
Decide whether to stop the processes first
Publira makes no promise yet about running two releases side by side, yet an upgrade has two moments where they do run side by side:
- Between
db migrateand the restart, the processes of the release you run serve on the schema of the new one. A migration that only adds tables and columns leaves them working, but a release may also drop or rename a column they still read, and every request that reads it fails until the new processes are up. - During a rolling restart, the web apps of one release call the
publira serverof the other, and nothing promises that the two agree on the API.
So replace all four processes together, rather than one replica at a time, and keep the window short.
Processes left running also keep writing after you take the backup. If a migration then fails and you go back to the backup, everything written since it is discarded: every sign-up, purchase, comment, and edit made in the meantime, including purchases a payment provider has already charged for.
Stopping the processes before the backup avoids both: no request fails, and the backup holds every write, because nothing writes after it. The install is then down from the backup until the processes of the new release start. Stop them first whenever losing those writes is not acceptable, which on an install that sells episodes is every time.
Upgrade
1. Back up the database
If you decided to stop the processes, stop them now, before the backup.
publiractl brings the schema forward only, and nothing undoes a migration once it has been applied. The backup you take now is the way back to the release you run.
pg_dump --format=custom --file=publira-before-upgrade.dump --dbname="$PUBLIRA_DB_URL"
PUBLIRA_DB_URL is the connection of the administrative login db migrate uses, and pg_dump has to be the major version of your PostgreSQL server or newer. A snapshot from a managed PostgreSQL does the same job. The migrations change nothing in the object store, in Valkey, or in your secrets, so the database is the part to back up right before them. Keep the backup until the new release has served for a while.
2. Apply the migrations
docker run --rm -e PUBLIRA_DB_URL ghcr.io/publira/publiractl:<new version> db migrate
It logs the version it starts from and, when it is done, the version it ended at:
time=2026-11-20T09:41:02.118Z level=INFO msg=migrating from_version=20261006125036
time=2026-11-20T09:41:03.402Z level=INFO msg=migrated from_version=20261006125036 to_version=20261120093000
When the release brings no migration, both versions are the same, and it exits zero all the same.
The migrations run before the new processes start because the processes do not check the schema when they start. Started on the old schema, they would come up healthy and then fail every request that reaches a table or a column the release added.
3. Bring the roles up to date
docker run --rm -e PUBLIRA_DB_URL ghcr.io/publira/publiractl:<new version> db roles
Without flags, every role keeps its password, and the command only brings the roles to what the new release defines: the grants on the tables the migrations just created, and whatever else the release changed in what a role may reach. It prints one line per role, publira_public: kept its password and so on. Skipped, it leaves the new processes failing with a permission error on every table the release added.
If the release adds a role, pass that role's password with its --<name>-password-file flag, as in Create the roles. Without one, the command exits naming the flag it needs and creates nothing.
4. Restart the processes on the new images
Replace publira server, publira worker, web-storefront, and web-admin with the images of the new release, together with web-platform and email-renderer if you run them, and with any variable the release notes asked you to set.
publira worker applies the migrations of its own job queue when it starts, so its tables need no step of their own.
5. Check the install
-
Every process answers
GET /readyzas ready. -
db versionreports the newest migration of the release, and no dirty state:docker run --rm -e PUBLIRA_DB_URL ghcr.io/publira/publiractl:<new version> db versionversion 20261120093000 dirty falseThe version is the timestamp at the start of the newest file in
db/migrations/at the new tag. -
The tenant console signs you in, and the tenant's site opens.
When a migration fails
db migrate exits non-zero, logging failed to migrate with the error PostgreSQL returned, and db version reports a dirty state:
version 20261120093000
dirty true
This is what the database holds then:
- Every migration before the failed one is applied, and stays applied.
- When PostgreSQL refused the failed migration, it is rolled back. A migration file runs as one transaction, so none of its statements stay. The exception is a migration that builds an index with
CREATE INDEX CONCURRENTLY, which runs outside a transaction: when it fails, it leaves an invalid index of that name behind. - When the connection dropped instead, the failed migration may have been committed before the answer was lost, and
db migratecannot tell which. - The version recorded is the failed migration's, marked dirty, and
db migraterefuses to run again until the mark is cleared.
The processes you were running keep running on the migrations applied so far, as they do between any db migrate and the restart. Do not start the processes of the new release on it.
Run it again after fixing the cause
The log tells the two cases apart. When PostgreSQL refused the migration, the failed to migrate line ends with the error it returned and its SQLSTATE, such as (details: ERROR: canceling statement due to statement timeout (SQLSTATE 57014)). A dropped connection names a network error instead, with no SQLSTATE.
When PostgreSQL refused the migration for a cause outside it — the disk filled up, a statement timed out waiting on a busy table — fix the cause, set the record back to the migration before the failed one, and run db migrate again:
-
Find the version of the migration before the failed one. From the root of a checkout of the new release, this prints it first:
ls db/migrations | grep '\.up\.sql$' | grep -B1 '^20261120093000_'The same list is under
db/migrations/of the new tag on GitHub. -
If the failed migration creates an index
CONCURRENTLY, drop the invalid index it left, as the administrative login:DROP INDEX CONCURRENTLY IF EXISTS <index name>; -
Set the record back, as the administrative login:
UPDATE schema_migrations SET version = <previous version>, dirty = false; -
Run
db migrateagain. It starts from the migration that failed; once it succeeds, carry on from step 3.
When the connection dropped, look first for the failed migration's changes in the database, reading its .up.sql file at the new tag. Since a migration runs as one transaction, either all of them are there or none is:
- None is there: the migration was rolled back. Follow the steps above.
- All of them are there: the migration was committed. Set the record to the failed migration itself rather than the one before, with
UPDATE schema_migrations SET version = <failed version>, dirty = false;, and rundb migrateagain to carry on with the migrations after it. - You cannot tell, as with a migration that only changes rows and leaves nothing to look for: go back to the release you ran, below. Running such a migration a second time could change the same rows twice.
Go back to the release you ran
When the migration itself is at fault, or you cannot find the cause or tell whether it was applied, restore the backup and keep running the release you had. Restoring discards everything written after the backup, so if the processes kept running through the upgrade, the writes they made since are lost. Report the failure in an issue, with the log of db migrate and the output of db version.
-
Stop the processes: a database cannot be dropped while they hold connections to it.
-
Drop the database, and create it again empty, with the same owner and settings you first created it with.
-
Restore the backup into it:
pg_restore --dbname="$PUBLIRA_DB_URL" publira-before-upgrade.dumpThe roles belong to the PostgreSQL server rather than to the database, so they survive the drop, and the backup carries their grants.
-
Start the processes of the release you ran.
A snapshot from a managed PostgreSQL is restored the way its provider describes, in place of the steps above.
On the Docker Compose install
The Docker Compose install runs every step above with docker compose. The new release's Compose file goes in a directory of its own, beside the one you run the install from now; once step 1 has made it, run each command from there. The project name is fixed, so docker compose reaches the same containers and volumes from either directory.
1. Download the new release
From the directory that holds the one you run the install from, with <current version> and <new version> the two releases' versions, their tags without the v:
curl -fsSLO https://github.com/publira/publira/releases/download/v<new version>/publira-deploy-<new version>.tar.gz
tar -xzf publira-deploy-<new version>.tar.gz
diff publira-deploy-<current version>/.env.example publira-deploy-<new version>/.env.example
cp -p publira-deploy-<current version>/.env publira-deploy-<new version>/.env
cd publira-deploy-<new version>
The diff shows every variable the new release added to the Compose file or stopped reading: add the new ones to .env. A required one left empty stops every docker compose command and names the variable. Then set PUBLIRA_IMAGE_TAG in .env to the new version, which the diff shows as well. cp -p keeps .env readable by you alone, and the copy left in the old directory still names the release you run, which is the way back if a migration fails.
When the release notes say the release removes a service from the Compose file, stop that service with docker compose stop <service> from the old directory, which still names it. The new Compose file does not, so it would keep running the release you ran, against the schema of the new one, until step 5 removes it.
An install run from a checkout fetches the new tag and checks it out in infra/deploy/ instead, with git diff <current tag> <new tag> -- .env.example before the checkout in place of the diff. Its .env is not tracked, so it stays where it is, and the service a release removes is stopped before the checkout. To move such an install to the archive instead, take the steps above with its infra/deploy/ in place of publira-deploy-<current version>.
2. Get the images
docker compose pull
docker compose pull publiractl
The first command pulls the images of every process the install runs, at the version you just set; the second pulls publiractl, which the first leaves out because it is never kept running. Nothing is built.
If your install runs images you build yourself, build the new release's from a checkout of its tag instead, with your changes carried onto it, and push them where PUBLIRA_IMAGE_REGISTRY points; images built on the host itself with task docker:verify:full need no push.
3. Back up the database
If you decided to stop the processes, stop them first, naming web-platform and email-renderer too if you run them:
docker compose stop server web-storefront web-admin
Then take the backup:
docker compose exec -T postgres pg_dump -U postgres --format=custom publira > publira-before-upgrade.dump
-T keeps the dump from passing through a terminal, which would corrupt it.
4. Apply the migrations and bring the roles up to date
docker compose run --rm publiractl db migrate
docker compose run --rm publiractl db roles
If the release adds a role, pass its password the way you did at install, as --<name>-password-file /run/secrets/<name>-db-password, with the variable the .env.example diff showed set in .env.
5. Start the new release
docker compose up -d --remove-orphans
docker compose ps
up -d recreates every container whose image or settings changed, the proxy among them, since it now mounts the Caddyfile of the new directory, and --remove-orphans removes the container of any service the new release's Compose file no longer has. The certificates the proxy obtained stay in the caddy-data volume.
An install run from a checkout also runs docker compose restart proxy after up -d: the proxy mounts the Caddyfile as a single file, and git checkout replaces that file with a new one at the same path, so a proxy left running keeps reading the routing of the release you ran.
Then check the install as in step 5, running db version as docker compose run --rm publiractl db version. Once it passes, the old directory is no longer needed. Its .env holds every secret of the install, so delete the directory, or the .env in it, rather than leave a second copy on the host.
When a migration fails on Compose
Run the SQL in Run it again after fixing the cause as the superuser through psql in the postgres container, one statement at a time:
docker compose exec postgres psql -U postgres -d publira -c "UPDATE schema_migrations SET version = <previous version>, dirty = false"
To go back to the release you ran instead:
docker compose stop server web-storefront web-admin
docker compose exec postgres psql -U postgres -d postgres -c 'DROP DATABASE publira' -c 'CREATE DATABASE publira'
docker compose exec -T postgres pg_restore -U postgres -d publira < publira-before-upgrade.dump
cd ../publira-deploy-<current version>
docker compose up -d --remove-orphans
The old directory's .env still names the release you ran, so up -d starts it again, the proxy on the old Caddyfile. An install run from a checkout runs git checkout <current tag> in place of the cd, sets PUBLIRA_IMAGE_TAG back to the release it ran, and runs docker compose restart proxy after up -d.
Next steps
What each db command does, and every flag of db roles, is in the publiractl reference, and the variables each process reads, in case the release notes name one, are in the deployment reference.