Upgrading Postgres major versions has traditionally been one of the more disruptive tasks for database operators, often involving downtime or complex migration steps. This can be especially challenging for applications that must remain online 24/7.

With Spock running on CloudNativePG (CNPG), the upgrade process becomes far simpler and significantly less disruptive using a blue-green deployment strategy. Spock uses logical replication, which operates at the data and schema level rather than the binary level, allowing it to work seamlessly across different Postgres versions. This means you can introduce new clusters running the upgraded version (green), replicate data continuously from the old clusters (blue), and cut over when ready — minimizing interruption to application traffic.

In other words, Spock turns what used to be a high-risk operation into a smooth, predictable blue-green deployment workflow. Below, we walk through a complete real-world example, supported with logs, showing how Spock enables blue-green major version upgrades from Postgres 17 to Postgres 18.

Step 1: Initial PG 17 Clusters and Replication

Note: For this demo, I used the Helm chart available at
https://github.com/pgEdge/pgedge-helm

We start with three Postgres 17 clusters (pgedge-n1, pgedge-n2, pgedge-n3) connected in a bidirectional replication setup using Spock.

Initial Helm Configuration

pgEdge:
  appName: pgedge
  nodes:
    - name: n1
      hostname: pgedge-n1-rw
      clusterSpec:
        instances: 3
        postgresql:
          synchronous:
            method: any
            number: 1
            dataDurability: required
    - name: n2
      hostname: pgedge-n2-rw
    - name: n3
      hostname: pgedge-n3-rw
  clusterSpec:
    storage:
      size: 1Gi

Deploy the initial clusters:

helm install \
--values examples/configs/single/values.yaml \
	--wait \
	pgedge ./

Wait for all clusters to become healthy:

NAME        AGE    INSTANCES   READY   STATUS                     PRIMARY
pgedge-n1   106s   3           3       Cluster in healthy state   pgedge-n1-1
pgedge-n2   106s   1           1       Cluster in healthy state   pgedge-n2-1
pgedge-n3   106s   1           1       Cluster in healthy state   pgedge-n3-1

Verify Replication

Insert into cluster n1:

app=# CREATE TABLE IF NOT EXISTS test_table (
  id SERIAL PRIMARY KEY,
  val TEXT
);
INFO:  DDL statement replicated.
CREATE TABLE
app=# INSERT INTO test_table VALUES(1, 'n1');
INSERT 0 1

app=# SELECT * FROM test_table;
 id | val 
----+-----
  1 | n1
(1 row)
app=# SELECT version();
 PostgreSQL 17.6 on aarch64-unknown-linux-gnu

Insert into cluster n2:

app=# INSERT INTO test_table VALUES(2, 'n2');
INSERT 0 1
app=# SELECT * FROM test_table;
 id | val 
----+-----
  1 | n1
  2 | n2
(2 rows)

Insert into cluster n3:

app=# INSERT INTO test_table VALUES(3, 'n3');
INSERT 0 1
app=# SELECT * FROM test_table;
 id | val 
----+-----
  1 | n1
  2 | n2
  3 | n3
(3 rows)

Verify on all clusters:

-- On pgedge-n1 and pgedge-n2
app=# SELECT * FROM test_table;
 id | val 
----+-----
  1 | n1
  2 | n2
  3 | n3
(3 rows)

Confirmed replication across all PG 17 clusters.

Step 2: Add PG 18 Clusters (pgedge-n4, pgedge-n5, pgedge-n6)

Next, we extend our CNPG Helm deployment by adding three Postgres 18 clusters: pgedge-n4, pgedge-n5, and pgedge-n6. These new clusters will bootstrap from the existing PG 17 data using Spock's logical replication.

Updated Helm Configuration

pgEdge:
  appName: pgedge
  nodes:
    - name: n1
      hostname: pgedge-n1-rw
      clusterSpec:
        instances: 3
        postgresql:
          synchronous:
            method: any
            number: 1
            dataDurability: required
    - name: n2
      hostname: pgedge-n2-rw
    - name: n3
      hostname: pgedge-n3-rw
    - name: n4
      hostname: pgedge-n4-rw
      clusterSpec:
        imageName: ghcr.io/pgedge/pgedge-postgres:18-spock5-standard
        instances: 3
        postgresql:
          synchronous:
            method: any
            number: 1
            dataDurability: required
      bootstrap:
        mode: spock
        sourceNode: n1
    - name: n5
      hostname: pgedge-n5-rw
      bootstrap:
        mode: spock
        sourceNode: n1
      clusterSpec:
        imageName: ghcr.io/pgedge/pgedge-postgres:18-spock5-standard
    - name: n6
      hostname: pgedge-n6-rw
      bootstrap:
        mode: spock
        sourceNode: n1
      clusterSpec:
        imageName: ghcr.io/pgedge/pgedge-postgres:18-spock5-standard
  clusterSpec:
    storage:
      size: 1Gi

Apply the upgrade:

helm upgrade \

--values examples/configs/single/values.yaml \

--wait \

pgedge ./

Watch the new clusters come online:

NAME        AGE     INSTANCES   READY   STATUS                     PRIMARY
pgedge-n1   5m51s   3           3       Cluster in healthy state   pgedge-n1-1
pgedge-n2   5m51s   1           1       Cluster in healthy state   pgedge-n2-1
pgedge-n3   5m51s   1           1       Cluster in healthy state   pgedge-n3-1
pgedge-n4   82s     3           2       Waiting for the instances to become active
pgedge-n5   82s     1           1       Cluster in healthy state   pgedge-n5-1
pgedge-n6   82s     1           1       Cluster in healthy state   pgedge-n6-1

All clusters healthy:

NAME        AGE     INSTANCES   READY   STATUS                     PRIMARY
pgedge-n1   6m35s   3           3       Cluster in healthy state   pgedge-n1-1
pgedge-n2   6m35s   1           1       Cluster in healthy state   pgedge-n2-1
pgedge-n3   6m35s   1           1       Cluster in healthy state   pgedge-n3-1
pgedge-n4   2m6s    3           3       Cluster in healthy state   pgedge-n4-1
pgedge-n5   2m6s    1           1       Cluster in healthy state   pgedge-n5-1
pgedge-n6   2m6s    1           1       Cluster in healthy state   pgedge-n6-1

Step 3: Verify Cross-Version Replication on PG 18 Clusters

Now we test that the new PG 18 clusters have successfully replicated the existing data and can participate in the replication mesh.

Check cluster n4 (PG 18):

app=# SELECT * FROM test_table;
 id | val 
----+-----
  1 | n1
  2 | n2
  3 | n3
(3 rows)
app=# SELECT version();
 PostgreSQL 18.0 on aarch64-unknown-linux-gnu
Data successfully replicated from PG 17 to PG 18!

Insert into cluster n4:

app=# INSERT INTO test_table VALUES(4, 'n4');
INSERT 0 1
app=# SELECT * FROM test_table;
 id | val 
----+-----
  1 | n1
  2 | n2
  3 | n3
  4 | n4
(4 rows)

Insert into cluster n5:

app=# INSERT INTO test_table VALUES(5, 'n5');
INSERT 0 1
app=# SELECT * FROM test_table;
 id | val 
----+-----
  1 | n1
  2 | n2
  3 | n3
  4 | n4
  5 | n5
(5 rows)

Insert into cluster n6:

app=# INSERT INTO test_table VALUES(6, 'n6');
INSERT 0 1
app=# SELECT * FROM test_table;
 id | val 
----+-----
  1 | n1
  2 | n2
  3 | n3
  4 | n4
  5 | n5
  6 | n6
(6 rows)

Verify replication across all PG 18 clusters:

-- On pgedge-n4 and pgedge-n5

app=# SELECT * FROM test_table;
 id | val 
----+-----
  1 | n1
  2 | n2
  3 | n3
  4 | n4
  5 | n5
  6 | n6
(6 rows)

Verified bidirectional replication across all three PG 18 clusters, as well as cross-version replication with PG 17 clusters.

Step 4: Remove PG 17 Clusters from Helm Values

Once you've verified the PG 18 clusters are working correctly, it's time to remove the old PG 17 clusters. Update your values.yaml to remove the n1, n2, and n3 cluster entries:

Final Helm Configuration

pgEdge:
  appName: pgedge
  nodes:
    - name: n4
      hostname: pgedge-n4-rw
      clusterSpec:
        imageName: ghcr.io/pgedge/pgedge-postgres:18-spock5-standard
        instances: 3
        postgresql:
          synchronous:
            method: any
            number: 1
            dataDurability: required
      bootstrap:
        mode: spock
        sourceNode: n1
    - name: n5
      hostname: pgedge-n5-rw
      bootstrap:
        mode: spock
        sourceNode: n1
      clusterSpec:
        imageName: ghcr.io/pgedge/pgedge-postgres:18-spock5-standard
    - name: n6
      hostname: pgedge-n6-rw
      bootstrap:
        mode: spock
        sourceNode: n1
      clusterSpec:
        imageName: ghcr.io/pgedge/pgedge-postgres:18-spock5-standard
  clusterSpec:
    storage:
      size: 1Gi

Apply the changes:

helm upgrade \
--values examples/configs/single/values.yaml \
	--wait \
	pgedge ./

Watch as the old PG 17 clusters are terminated:

NAME        AGE     INSTANCES   READY   STATUS                     PRIMARY
pgedge-n4   6m17s   3           3       Cluster in healthy state   pgedge-n4-1
pgedge-n5   6m17s   1           1       Cluster in healthy state   pgedge-n5-1
pgedge-n6   6m17s   1           1       Cluster in healthy state   pgedge-n6-1
NAME                                  READY   STATUS        RESTARTS   AGE
pgedge-n1-1                           1/1     Terminating   0          10m
pgedge-n2-1                           1/1     Terminating   0          10m
pgedge-n3-1                           1/1     Terminating   0          10m
pgedge-n4-1                           1/1     Running       0          6m2s

Final state with only PG 18 clusters:

NAME        AGE     INSTANCES   READY   STATUS                     PRIMARY
pgedge-n4   7m45s   3           3       Cluster in healthy state   pgedge-n4-1
pgedge-n5   7m45s   1           1       Cluster in healthy state   pgedge-n5-1
pgedge-n6   7m45s   1           1       Cluster in healthy state   pgedge-n6-1

Step 5: Verify Final State and Clean Subscriptions

After the old clusters are removed, verify that replication still works correctly on the PG 18 clusters.

Insert more test data:

-- On pgedge-n4

app=# INSERT INTO test_table VALUES(7, 'n4');
INSERT 0 1

-- On pgedge-n5

app=# INSERT INTO test_table VALUES(8, 'n5');
INSERT 0 1

-- On pgedge-n6

app=# INSERT INTO test_table VALUES(9, 'n6');
INSERT 0 1

Verify replication:

-- On all PG 18 clusters

app=# SELECT * FROM test_table;
 id | val 
----+-----
  1 | n1
  2 | n2
  3 | n3
  4 | n4
  5 | n5
  6 | n6
  7 | n4
  8 | n5
  9 | n6
(9 rows)

Replication continues to work perfectly after removing the old PG 17 clusters.

Check the subscription status:

-- On pgedge-n4

app=# SELECT sub_name, sub_enabled FROM spock.subscription;
 sub_name  | sub_enabled 
-----------+-------------
 sub_n5_n4 | t
 sub_n6_n4 | t
(2 rows)

-- On pgedge-n5

app=# SELECT sub_name, sub_enabled FROM spock.subscription;
 sub_name  | sub_enabled 
-----------+-------------
 sub_n4_n5 | t
 sub_n6_n5 | t
(2 rows)

-- On pgedge-n6

app=# SELECT sub_name, sub_enabled FROM spock.subscription;
 sub_name  | sub_enabled 
-----------+-------------
 sub_n4_n6 | t
 sub_n5_n6 | t
(2 rows)

Only the active PG 18 replication subscriptions remain. The Helm chart automatically cleaned up the subscriptions to the removed PG 17 clusters.

Why Spock Makes This Possible

Spock's design is what enables this smooth migration:

  • Logical replication metadata (nodes, subscriptions, replication sets) is stored in catalog tables that are independent of Postgres major versions.

  • Replication slots are logical, not physical — they don't break during a binary upgrade.

  • Cross-version replication means new PG 18 clusters can replicate seamlessly with old PG 17 clusters until cutover is complete.

  • Automatic resync: When new clusters come online, Spock immediately resumes replication streams without manual reconfiguration.

  • Bootstrap from source: The bootstrap.mode: spock and sourceNode configuration allows new clusters to automatically initialize with data from existing clusters.

With Spock, what would traditionally require significant downtime (pg_upgrade or dump/restore) becomes a blue-green deployment with minimal application disruption.

Key Takeaways

  • Blue-green deployment strategy: Run both old (blue) and new (green) Postgres versions simultaneously, then switch over when ready.

  • Cross-version compatibility: Spock's logical replication allows PG 17 and PG 18 clusters to coexist and replicate data seamlessly during the transition.

  • Gradual migration: You can add new version clusters (green), verify they work correctly, and only then remove old clusters (blue).

  • Reduced risk: At any point during the migration, you can verify data integrity and replication health before proceeding to the next step.

  • Automatic cleanup: The Helm chart handles subscription cleanup when clusters are removed, reducing manual intervention.

This approach transforms Postgres major version upgrades from a high-stress, high-risk event into a controlled, testable process that significantly reduces disruption to your applications.