Blue-Green Postgres Major Version Upgrades with Spock + CNPG: From PG 17 to PG 18
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: 1GiDeploy 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-1Verify 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-gnuInsert 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: 1GiApply 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-1All 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-1Step 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: 1GiApply 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 6m2sFinal 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-1Step 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 1Verify 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.


