Every major PostgreSQL upgrade I have done on a single primary came with the same conversation: how long can the application be down? is fast, but it is still an outage, and you still have to rebuild or resync the standbys afterwards.A multi-master cluster gives you a different option. Spock is pgEdge's open-source PostgreSQL extension for multi-master logical replication, and with it every node takes writes, so you never have to upgrade a node in place. You add a brand new node on the new major version, let it join the cluster, move traffic to it, and retire the old one. Repeat for every node and the cluster ends up on the new version without the application ever losing a writable endpoint.Starting with PostgreSQL 19, community Postgres offers a blue-green style upgrade built on , and the new sequence sync. This is not really ZERO downtime upgrade because the writes need to be paused for a certain period of time. At cutover, writes have to stop while the last changes drain, sequences are synced and the application is switched over. For a critical application, that pause is still an outage.I tested this end to end on my two node pgEdge test cluster running PostgreSQL 17.10 with Spock 5.0.12, adding a PostgreSQL 18.6 node next to them. This post walks through exactly what I ran, including the things that tripped me up, so you can do the same thing on your own cluster.One note before we start. pgEdge Control Plane is pgEdge's declarative orchestration tool for managing Postgres clusters. If your cluster runs under it, use its built-in major version upgrade instead. It drives the same zero downtime add node process through its API.
The Starting Setup
Before I touched anything, the cluster was a plain two node Spock setup on PostgreSQL 17, running on two Rocky Linux ARM64 VMs. Both nodes take writes.The database is , with the usual tables plus a couple of test tables, all in the replication set. I put n3 and its standby on the same VM as n1 to keep the lab small. In production each node would be its own server.How the Rolling Upgrade Works
The whole procedure is a per node loop. For each node you want to upgrade:- Build a fresh node on PostgreSQL 18 with the same Spock version as the cluster.
- Add it to the cluster with Zodan, a set of scripts that ships with Spock and adds or removes a node with zero downtime. Its
- spock.add_node
- procedure copies the schema and data from an existing node and wires up subscriptions in both directions, while the cluster keeps taking writes.
- Verify the new node both receives and sends changes.
- If the old node had a physical standby, build a new standby from the new node under Patroni, a high availability manager for PostgreSQL. You don't upgrade the old standby, you replace it.
- Move application traffic over and remove the old node with
- spock.remove_node
- .
Before You Start
Start by looking at what the cluster is running today. You need the PostgreSQL version, the Spock version, the node names and the full role list.In my cluster, that returns PostgreSQL 17.10, Spock 5.0.12, and nodes n1 and n2. The new node has to run the same Spock version, and every role on n1 has to exist on the new node too. Zodan checks both and refuses to continue if they do not match.Next, turn off automatic DDL replication on every existing node for the duration of the upgrade:That also means no schema changes from the application until the rollout is done. Plan the upgrade window around your deployment schedule.Step 1: Build the PostgreSQL 18 Node
Install PostgreSQL 18 and the Spock package that matches the cluster. The pgEdge repo carries Spock 5.0 and 6.0 side by side, so pick the package name carefully. For a cluster on Spock 5.0.12 that is , not the 6.0 beta.Initialize the data directory and add the settings Spock needs:On newer pgEdge builds, including the 18.6 build I used, check ;. If the parameter exists and does not list , add it and restart, otherwise the other nodes cannot create their slots on this node:Finally, create the database, the roles that exist on , and the extensions:Step 2: Add the Node with Zodan
Grab the Zodan scripts from the Spock release tag that matches your cluster, and load them into the new node:Then run on the new node. I keep passwords out of the DSNs and put them in instead, because whatever DSN you pass here gets stored in on every node in the cluster.With it prints every phase: version check, prerequisite validation, slot creation on the other nodes, the structure and data copy from , and finally subscriptions from every node to the new one.Step 3: Fix Replication Sets and Test Both Directions
At this point every subscription in the cluster showed , so I ran a simple write test. An update on arrived on . An update on never left .This is the most important thing in this post. The structure sync creates the tables on the new node, but with DDL replication turned off, nothing adds them to the new node's replication sets:So the new node receives every change but publishes none, and every status check still says healthy. In a real upgrade, writes landing on the new node would never reach the rest of the cluster, and they would be gone for good once you retire the old node.The fix is to copy replication set membership from the source node:Then test writes in both directions, from an old node and from the new one:All three nodes agree. Do not skip this test - subscription status alone will not tell you if the new node is silently one-way.Step 4: Rebuild the Standby from the New Node
The question I get most often is how the standby gets upgraded. It does not. You build a new standby from the PostgreSQL 18 node with , and here, Patroni runs that for you.Mark the Spock slots for failover first. The logical slots add_node creates on the new node, the ones the other nodes stream from, come out with . Native slot sync skips those, so after a promotion the other nodes would lose their place.A slot can only be altered while nothing is reading from it, so pause the two subscriptions, flip the flag over a replication connection, and resume. Your changes queue in the slots while the nodes are paused, so nothing is lost.Create the replication user. Patroni will take over the existing database rather than initialize a new one, so it never creates the users in its config. Do it yourself, along with a entry and a password for the superuser Patroni will connect as:Bring the node under Patroni. I used Patroni 4.1.3 with a dedicated single member etcd for the new node's Patroni pair, on ports 2389/2390. The important lines in the new node's config:Patroni adopted the running instance without complaint, and then quietly replaced its configuration with Patroni's own defaults:Nothing breaks until the next restart, and then the node either refuses to start or comes up without Spock. Patroni only accepts settings like wal_level from its cluster config in etcd, so add every Spock requirement there before you restart anything:After the restart all six subscriptions in the cluster were back to . Because these settings live in etcd, the standby inherits them too.Start the standby. A second Patroni member with an empty data directory creates the standby. Patroni finds the leader in etcd and runs itself. It also writes the standby configuration, creates a physical slot for the standby on the leader, and starts it. Its config is the same with , port 5452, REST port 8010 and its own .Confirm slot sync. On the standby, both Spock slots should show up as synced:Patroni 4.1.3 put a into on its own, which slot sync needs on PostgreSQL 17 and later.Hold Spock back until the standby has the WAL. Finally, set on the leader so the other Spock nodes never get ahead of the standby, and add an callback to both Patroni members so the setting follows a promotion. Patroni passes , so the role is :The callback only fires on a role change, so set the value by hand on the current leader the first time. Be aware of the tradeoff: if the standby goes down, the leader stops sending changes to the other Spock nodes until it comes back.Step 5: Retire the Old Node and Repeat
Once the new node and its standby are healthy, move application traffic to the new node and remove the old one. Run and on the node you are retiring:It drops the node's subscriptions, its replication sets, and its entry in the cluster registry, and prints a summary of what it removed. Confirm the remaining nodes no longer list the node and that no slots for it are left behind:Then go back to Step 1 for the next node. Keep at least two nodes active at every point, so the cluster always has somewhere to take writes. When the last old node is gone, turn DDL replication back on:Two things to set up properly before you do this in production. Run the new node under systemd or Patroni so it comes back after a reboot. And point the other nodes' Spock DSN for each new node at a VIP or DNS name rather than the server's own address, so their subscriptions follow a Patroni failover. I have not run a failover test yet, so I will cover that in a follow up post.Wrapping Up
The core idea holds up. I added a PostgreSQL 18 node to a live PostgreSQL 17 cluster, it accepted writes in both directions, and it used a Patroni managed standby built straight from the new version. The application never lost a writable node.The catch is that none of the problems I hit showed up in a status check. Here is what to watch for:- With DDL replication off, the new node's tables are in no replication set until you add them.
- The new node's Spock slots need
- failover = true
- before slot sync will touch them.
- Patroni overwrites Spock's settings unless they are in its etcd config.