Overview
pgEdge is an active-active distributed PostgreSQL cluster built on the Spock multi-master replication extension. Every node accepts reads and writes, and a row written on any node replicates to all the others with last-update-wins conflict resolution. This template deploys one set of PostgreSQL 17 nodes in each location you list, a per-location HAProxy failover tier that keeps a location serving when one of its nodes fails, a per-location PgBouncer connection pooler that gives applications a single endpoint, and an optional scheduled backup to S3 or GCS that also restores with one command. Database credentials are not template values. The nodes, the pooler and the backup job read the username, password and database name from a dictionary secret you create before installing, so no password passes through Helm or lands in the release.locations must already be one of that GVC’s locations. A GVC location you do not list starts nothing — its deployment reads This workload location is deactivated because maxScale is set to 0., which is what a healthy install looks like in a shared GVC, not a fault.What Gets Created
Prerequisites
Two things must exist before you install: a GVC that already has the locations you intend to use, and the credentials secret. Secrets are org-level, so no GVC flag is involved when creating one.A GVC that contains every location you will list
--gvc. It may have more locations than you list — nothing pgEdge-related runs in those — but it must have every one you do list. Check its locations under spec.staticPlacement.locationLinks:locations to a subset of what you see there. A location that is not on the GVC is not caught at install time — the install succeeds and the nodes then refuse to initialize (see Troubleshooting).Create the database credentials secret
username, password and database. The nodes create that user and that database on first boot, PgBouncer serves only that database, and its admin console accepts the same username and password. Do not name the database pgbouncer; that name is reserved for the admin console.postgres.credentialsSecretName to the name you used. Secret names are org-wide, so give each release its own.Read the secret back later
-o yaml. A bare cpln secret reveal prints only a summary table, not the values:Installation
The defaultlocations list names three AWS regions with three nodes each, so a real install nearly always carries its own location list. Lists cannot be set piecemeal with --set — Helm replaces the whole list, and a --set locations[0].name=... without replicas fails to render with location 'aws-us-east-1' must have at least 1 replica — so put them in a values file:
UI
CLI
Terraform
Pulumi
Configuration
Locations and Replicas
Each entry pairs a location with a node count. Every location listed must already be on the GVC you install into; extra GVC locations run nothing. Listing the same location twice is rejected at render, because duplicates would produce duplicate Spock node names.locations is special: it is the only location the backup job runs in.
Verify that your locations support multiple availability zones before enabling multiZone.
Image and Resources
The node container exposes both a reservation and a limit.maxCpu: 2 against minCpu: 500m is exactly the 4:1 ratio a stateful workload allows, so raise minCpu whenever you raise maxCpu.
Credentials
username, password and database — see Prerequisites. The nodes, PgBouncer, HAProxy’s health checks and the backup job all read it; nothing else in the release carries a credential.
Volume Set
Set the initial storage capacity per node (minimum 10 GiB), the volume snapshot schedule, and whether volumes grow as data grows:snapshots.schedule must be a cron expression with a single fixed minute; anything more frequent than hourly is refused at render. A final snapshot of each volume is taken when the volume set is deleted, whether or not a schedule is set.
HAProxy Failover Tier
The request flow isapp → PgBouncer (pooler) → HAProxy (failover) → pgEdge node. PgBouncer pools connections; HAProxy picks the node. Both run one set per location, and the endpoint applications connect to is the PgBouncer one.
Each location’s HAProxy sends all of that location’s traffic to its own first node (replica-0) and, if that node is unhealthy, fails over in order to the other local nodes, then to the nodes of the other locations in locations order — so applications keep serving through a node failure without changing anything. Every location writes to its own node, so the cluster is active/active. The tier is always on.
PgBouncer Connection Pooler
PgBouncer multiplexes application connections into a smaller pool of real database connections, protecting PostgreSQL from connection exhaustion under high concurrency. It serves only the database named in your credentials secret.minReplicas and maxReplicas are per location, and PgBouncer autoscales between them on request rate.
replica-0, and each node accepts 300 connections. The defaults use at most 20 × 4 = 80 per location, so one node can carry three locations’ pools after a failover. If you raise defaultPoolSize or maxReplicas, keep defaultPoolSize × maxReplicas × number of locations under about 290.
transaction mode (the default), clients may pass these through the options startup parameter (PGOPTIONS='-c statement_timeout=5s', libpq options=, JDBC options=), and PgBouncer applies them on every server connection the client uses: statement_timeout, lock_timeout, idle_in_transaction_session_timeout, idle_session_timeout, work_mem, maintenance_work_mem, default_transaction_isolation, client_min_messages, plus search_path, application_name, TimeZone, DateStyle and client_encoding. Any other setting in options is refused at connect with unsupported startup parameter in options — set it with ALTER ROLE … SET instead.
In session mode, options is reliably applied only on a brand-new server connection; a client handed a pooled connection silently gets the server defaults. Run SET at the start of each session instead, or ALTER ROLE … SET on every node — that reaches new server connections only, so run RECONNECT on the PgBouncer admin console or wait up to 5 minutes.
Internal Access
Controls which workloads can connect to the nodes, the failover tier and PgBouncer. Nothing in this template is exposed publicly, and there is no public access option.same-gvc— Allow access from all workloads in the same GVC.same-org— Allow access from all workloads in the org.workload-list— Allow access only from the workloads you list, plus this release’s own workloads, which the template adds for you.
Backup
Whenbackup.enabled is true, a cron workload dumps your database on the configured schedule and uploads it to S3 or GCS. The same workload performs restores. Keep schedule quoted; any valid cron expression works, including one beginning with *. The bucket, cloud account and IAM setup are described under Backing Up.
Connecting
Connect through PgBouncer for all application traffic. The per-location HAProxy behind it handles node failover, and the endpoint does not change. Always use the fully-qualified.GVC_NAME.cpln.local form — the bare workload name does not resolve reliably from every workload type.
sslmode=disable or sslmode=prefer; traffic stays inside the GVC. Clients must support SCRAM-SHA-256 authentication (libpq 10+, JDBC 42.2+).
To verify from your own machine, tunnel to PgBouncer and run a query against the tunnel:
psql on it — the container already carries the credentials in its environment:
replicating.
Schema Changes and DDL
Spock replicates row-level changes (INSERT, UPDATE, DELETE) automatically. DDL does not replicate. A plain CREATE TABLE or ALTER TABLE applies only to the node you ran it on; the other nodes never learn about it, and rows written into that table on one node cannot be applied on a node where it does not exist. Run DDL against a node directly, never through PgBouncer.
Creating a Table
The simplest correct procedure is to run the sameCREATE TABLE on every node. The auto-add trigger fires locally on each one, so the table ends up in the default replication set everywhere and DML replicates in all directions:
replicate_ddl. Running step 2 on that node fails with duplicate key value violates unique constraint "replication_set_table_pkey", and skipping it strands writes made on the other nodes. Confirm on every node — each must return a row:
Other DDL
ALTER TABLE and DROP TABLE follow the same rule — apply on every node, or broadcast once:
Primary Keys
Useuuid primary keys instead of serial or bigserial. Each node maintains its own sequence, so auto-increment integers collide when the same value is generated on two nodes at once:
Operations
Backing Up
Whenbackup.enabled is true, the RELEASE_NAME-pgedge-backup cron workload dumps your database (the database entry of your credentials secret) with pg_dump in custom format and uploads it as PREFIX/pgedge-RELEASE_NAME-YYYY-MM-DDTHH-MM-SSZ.dump. Every node holds a full copy, so the job dumps the first node that accepts a login and moves on to the next if a dump fails. A run that fails part-way leaves no backup behind. The job runs only in the first location in locations, so backups pause while that whole location is down.
Roles and large objects (lo_*) are not included: Spock does not replicate large objects, and the user from your credentials secret owns everything that is restored.
To take a backup now, start the job in your first location (the first entry of locations):
AWS S3 Prerequisites
- Create your S3 bucket and note its name and region. Set
backup.aws.bucketandbackup.aws.region. - If you do not have a Cloud Account set up, refer to the docs to Create a Cloud Account. Note the cloud account name.
- Create a new AWS IAM policy with the following JSON (replace
YOUR_BUCKET_NAME):
- Set
backup.aws.cloudAccountNameto your cloud account name. - Set
backup.aws.policyNameto the policy created in step 3.
ReadOnlyAccess to the backup identity as well, which granted read access to every bucket in your account and silently supplied any read action your own policy omitted. The identity now carries cpln-connector and your bucket-scoped policy only.GCS Prerequisites
- Create your GCS bucket and note its name. Set
backup.gcp.bucket. - If you do not have a Cloud Account set up, refer to the docs to Create a Cloud Account. Note the cloud account name.
- Grant the cloud account’s GCP service account the
Storage Adminrole. Control Plane uses it to give the backup jobroles/storage.objectAdminon this one bucket. - Set
backup.gcp.cloudAccountNameto your cloud account name.
Restoring a Backup
The backup job also restores. It puts the schema on every node (DDL does not replicate), adds each table to the replication set, loads the data once so Spock replicates it, sets sequences on every node, waits until every node holds the same row counts, and finally refreshes materialized views on every node. It refuses to run unless every node is reachable and the database is empty on every node, so it can never merge into or overwrite data. Keep applications from writing to the database while it runs. Restore this release’s newest backup:successful or failed):
- Recovering into a new release (lost cluster, new GVC): install with
backup.enabled: trueagainst the same bucket, then restore withRESTORE_FILE=OLD_PREFIX/pgedge-OLD_RELEASE-….dump. - Restoring over an existing database, or retrying a restore that failed part-way: drop your objects (tables, views, sequences, functions, types, non-public schemas) on every node first, connecting to each node directly. Tables in a replication set need
CASCADE(DROP TABLE orders CASCADE;). Do not drop thepublicschema: it holds the template’s auto-replication trigger, which a node recreates only when it is first created. - The archive is downloaded to the job’s local disk before it is applied. For a large database, raise
backup.activeDeadlineSeconds. - Ownership and
GRANTs are not restored; every object belongs to the credentials user. - Only backups written by template version 3.0.0 or later can be restored this way. The
.sql.gzfiles earlier versions wrote are not read by the job.
volumeset.snapshots.schedule. Restoring a single node’s volume into a running multi-master cluster has not been verified and is not a substitute for the restore above.
Upgrading from 1.x
Template versions through 1.1.1 created their own GVC, so that GVC is part of a 1.x release’s manifest. Version 2.0.0 and later do not declare one — and Helm deletes what a chart stops declaring. The 1.0.x credential values (postgres.username, postgres.password, postgres.database) are refused at render as well, with their own message, so pasting an old values file into a new install produces an error rather than a silent misconfiguration.
Back up the old cluster
backup.enabled on the 1.x release, or take a manual pg_dump against one of its nodes directly, at replica-0.OLD_RELEASE-pgedge.LOCATION_NAME.OLD_GVC.cpln.local.If replication on the old cluster was never switched on — which is every 1.x install that did not apply the fix below — the nodes may hold different data. Compare row counts per node before deciding which one to dump, or reconcile them first.Choose the GVC for the new release
locations. See Prerequisites.Create the credentials secret and install as a new release
Load the data and cut over
NEW_RELEASE-pgbouncer.NEW_GVC.cpln.local:5432.Uninstall the old release against the GVC you installed it into
Switching Replication On for an Existing 1.x Cluster
PostgreSQL 17.11 shipsoutput_plugin_libraries = 'pgoutput, test_decoding', and Spock’s output plugin is not on that list. On every template version through 1.1.1 this means no replication slot could ever be created: each subscription sits at down, each node accepts writes that never leave it, and every status surface still reports healthy. The reason appears in the node’s own PostgreSQL server log, not in cpln logs:
postgresql.conf during initdb, so a fresh install needs nothing. An existing data directory keeps the old setting, so if you are staying on 1.x while planning the migration, apply it by hand on every node, connecting to each node directly:
replicating shortly after the reload. Check on every node:
Upgrading from 2.x
Version 3.0.0 replaces the pgcat pooler with PgBouncer and makes the HAProxy failover tier permanent. The client endpoint changes fromRELEASE_NAME-pgcat to RELEASE_NAME-pgbouncer; the port stays 5432. The pgEdge nodes, their volumes and their data are kept.
Edit your values
pgcat: to pgbouncer:, and delete image and routing from that block. Delete proxy.enabled. The chart refuses to render while a pgcat key, a pgcat image, routing or proxy.enabled: false remains, so an upgrade with stale values stops before anything changes:Optionally shorten the client gap
RELEASE_NAME-pgedge-proxy.GVC_NAME.cpln.local:5432 first. That is HAProxy, present on any 2.2.0 install with the default proxy.enabled: true. It is unpooled, so keep the connection count well below what each node accepts.Upgrade the release
RELEASE_NAME-pgcat workload is replaced by RELEASE_NAME-pgbouncer, and a release that ran with proxy.enabled: false gains the HAProxy tier.Point applications at the new endpoint
RELEASE_NAME-pgbouncer.GVC_NAME.cpln.local:5432.Take a backup
pg_dump custom format, .dump), and the restore job reads only the new format. Run a backup right after upgrading — see Backing Up.pgcat.routing: single-writer has no equivalent: every location now writes to its own node, which was already the default from 2.1.0. A release still on 2.0.x routed every write to the first location’s first node, so if your application depends on a single write target, plan for multi-master writes before upgrading.
Scaling and Availability
What each failure needs. Your application never has to pick another node for anything inside a location: the template handles that, and the application needs only the ordinary retry described below. The one thing the template cannot handle for you is the loss of a whole location, because the pooled endpoint always reaches the PgBouncer in the client’s own location and PgBouncer has no per-location address.defaultPoolSize × maxReplicas × number of locations. Keep the application’s own pool small, and switch back to the pooled endpoint once its location recovers.
Retry logic your application needs. Every row above can still fail a connection or a statement for a moment:
- Retry failed connections with a short backoff (about 0.5–1 seconds) for 10–15 seconds. New connections fail for a few seconds while HAProxy moves off a node, and a PgBouncer replica that dies drops the connections going through it; new connections go to the other replica straight away.
- Retry a failed statement, and make retried writes idempotent: a statement running on a process that fails returns an error, and may already have committed.
locations after data exists is not a verified path. The template creates subscriptions without initial synchronization, so a node added to a cluster that already holds data starts without its tables and rows and receives only changes made after it joins. Plan the final topology before loading data, or rebuild through a new release and a restore. If you remove a location from the GVC, remove it from locations in the same change; otherwise every node logs a WARNING about the missing location on each restart.
Seeing which node serves a location. HAProxy’s stats page shows which node is active, which are backups, and which are UP or DOWN:
http://localhost:8405/stats in a browser.
Storage. Each node’s volume starts at volumeset.capacity GiB. Enable volumeset.autoscaling to grow volumes automatically up to maxCapacity; the volume set is never shrunk by the template.
Troubleshooting
The deployment never starts and cpln logs returns nothing
The deployment never starts and cpln logs returns nothing
cpln logs for the node, PgBouncer or HAProxy workload returns zero lines.Cause: the credentials secret named by postgres.credentialsSecretName does not exist, so the container is never started and has nothing to log.Fix: read the message, which names the missing secret:cpln workload force-redeployment RELEASE_NAME-pgedge --gvc GVC_NAME.Nodes restart with FATAL locations declared in values are not in GVC
Nodes restart with FATAL locations declared in values are not in GVC
locations names a location the GVC does not have. The platform does not validate this at install, so the nodes check it themselves at startup and refuse to initialize a cluster whose declared members can never exist.Fix: either add the location to the GVC or remove it from locations, then upgrade the release. Read the lines with a server-side filter:[pgedge] WARNING: locations declared in values are not in GVC instead and keeps serving, so this check never takes down a running cluster. A line reading [pgedge] WARNING: could not read the location list of GVC means the check could not run at all and was skipped — verify the GVC’s locations yourself with cpln gvc get GVC_NAME -o yaml.helm upgrade fails with the pgcat pooler was REPLACED by PgBouncer
helm upgrade fails with the pgcat pooler was REPLACED by PgBouncer
pgedge 3.0.0: the pgcat pooler was REPLACED by PgBouncer, proxy.enabled was REMOVED, pgbouncer.routing does not exist or pgbouncer.image is a pgcat image.Cause: your values file still carries 2.x keys. Nothing was changed — the chart refuses before any resource is touched.Fix: edit the values as described in Upgrading from 2.x and run the upgrade again.Connections fail with unsupported startup parameter in options
Connections fail with unsupported startup parameter in options
options (for example PGOPTIONS='-c some_setting=value') is refused at connect with unsupported startup parameter in options.Cause: PgBouncer passes only a fixed list of settings through options; see PgBouncer Connection Pooler.Fix: remove the setting from options and set it with ALTER ROLE … SET on every node, or with SET at the start of the session in session mode.CREATE TABLE fails with cannot be added to replication set default
CREATE TABLE fails with cannot be added to replication set default
default replication set, which replicates UPDATE and DELETE; Spock cannot do that for a table without a primary key.Fix: give the table a PRIMARY KEY — a uuid column with DEFAULT gen_random_uuid() is the recommended shape (see Primary Keys).A table exists on one node only or rows written on one node never appear on the others
A table exists on one node only or rows written on one node never appear on the others
down after you created it.Cause: DDL does not replicate. A plain CREATE TABLE applied to one node only, or spock.replicate_ddl() was run without the follow-up spock.repset_add_table() on the other nodes.Fix: follow Schema Changes and DDL — create the table on every node directly, or broadcast it and then add it to the replication set on every other node. Confirm with SELECT * FROM spock.tables WHERE relname = 'TABLE_NAME'; on each node.spock.sub_show_status() shows subscriptions down
spock.sub_show_status() shows subscriptions down
SELECT subscription_name, status FROM spock.sub_show_status(); returns down for one or more rows, and writes made on one node do not appear on another.Causes and fixes, in order of likelihood:- A node that is still booting or restarting — the mesh reconciles itself on boot. Give it a few minutes and re-check; rows written while a subscription was down are not backfilled (see Scaling and Availability).
- A table created on one node only — see the entry above.
- A 1.x data directory — the server log (not
cpln logs) showslibrary "spock_output" may not be used as an output plugin. Apply the fix under Switching Replication On for an Existing 1.x Cluster.
A restore fails with already has objects or no backups of release
A restore fails with already has objects or no backups of release
cpln workload cron get shows the restore run failed, and the backup job’s log reads one of:RESTORE_FILE it picks only this release’s own backups. It refused before changing anything.Fix: drop your objects on every node as described in Restoring a Backup, or set RESTORE_FILE to the backup you want — for another release’s backup, the path inside the bucket.PgBouncer exits with database name pgbouncer is reserved
PgBouncer exits with database name pgbouncer is reserved
RELEASE_NAME-pgbouncer replicas restart, and their log shows [pgbouncer] FATAL: database name 'pgbouncer' is reserved for PgBouncer's admin console; use another name in your credentials secret.Cause: the database entry of your credentials secret is pgbouncer.Fix: use a different database name. The nodes create the database on first boot, so on an install that has already initialized, reinstall under a new release with the corrected secret.Important Notes
- Your application must retry failed connections and statements — without it, every upgrade and replica restart shows up as errors in your app. Retry with a short backoff for 10–15 seconds and make retried writes idempotent; see Scaling and Availability.
- 3.0.0 changes the client endpoint to
RELEASE_NAME-pgbouncer, and backups change format — take a backup right after upgrading. See Upgrading from 2.x. - Never run
helm upgradeon a 1.x release against any later version — it deletes the GVC the 1.x chart created and everything in it. Migrate to a new release instead: Upgrading from 1.x. - The GVC must already contain every location you list, and may contain more. A missing location is not caught at install — the nodes exit with
[pgedge] FATAL: locations declared in values are not in GVC .... Locations you do not list start nothing. - DDL does not replicate — create every table on every node (or broadcast it and add it to the replication set on the others), and give every table a
PRIMARY KEYorCREATE TABLEfails outright. - Use at least 3 nodes per location in production, and at least 2 for upgrades without a write interruption.
- Surviving a location loss needs your application in at least 2 locations, or a direct connection to a remote node — clients reach only their own location’s PgBouncer. See Scaling and Availability.
- With
internal_access.type: workload-list, list only your clients — the release’s own workloads are added for you. - Fix the topology before loading data — nodes added to a cluster that already holds data do not receive existing rows.
- Rotating the database password is not a supported path in this version. The password is written into each node’s Spock node and subscription definitions when they are created, so changing the secret alone would break replication; plan a new release and a restore if you must rotate.
- Release names must be unique per org — secrets are org-wide, so two releases with the same name collide even in different GVCs.
- Conflict resolution is last-update-wins by commit timestamp. Route writes for a given entity to one node in your application if you need stronger guarantees.