Overview
Postgres Multi-Location deploys one PostgreSQL 17 Patroni cluster whose members span several Control Plane locations: a single primary that takes all traffic, asynchronous streaming replicas in the other locations, and automatic promotion of a replica in a surviving location when the primary’s location is lost. An HAProxy tier in every location routes connections to whichever member currently holds the leader lock, so applications connect to a stable name in their own region and never need to know where the primary is. Consensus is provided by a bundledetcd-multi-location cluster with one member per location — that quorum, not PostgreSQL, decides what the cluster survives.
How Many Locations You Need
The consensus store commits a write only when a majority of its members agree, and it runs exactly one member per location.floor((N-1)/2) losses, so an even count buys nothing over the odd count below it. Two locations cannot form a symmetric quorum, which is why that topology is a warm standby rather than an automatic-failover cluster.
What Gets Created
- Stateful Patroni PostgreSQL Workload —
{release}-postgres, running PostgreSQL 17 with Patroni. Each configured location runsreplicasmembers, every member gets its own volume, and every member is individually addressable. PostgreSQL listens on5432and the Patroni REST API on8008. - Stateful etcd Workload —
{release}-etcd, the bundledetcd-multi-locationchart providing consensus, one member per location. - HAProxy Leader-Routing Workload (optional, enabled by default) —
{release}-postgres-proxy, one tier per location, each routing to the single current primary. - PgBouncer Workload (optional) —
{release}-postgres-pgbouncer, a connection pooler, one tier per location, pooling into that location’s HAProxy. - Cron Backup Workload (optional) —
{release}-postgres-backup, a scheduledpg_dumpallto object storage, running in exactly one location. - WAL-G Sidecar (optional) — A sidecar on every Patroni member; only the member holding the leader lock archives WAL and pushes base backups.
- Volume Sets —
{release}-postgres-vsfor the PostgreSQL data directory (ext4, final snapshot with 7-day retention), plus the etcd cluster’s own volume set. - Secrets — Opaque secrets holding the Patroni startup script, the HAProxy startup script, and the WAL-G backup script. The database credentials secret is not created by this template — see Prerequisites.
- Identities & Policies — An identity per workload group with
revealon exactly the secrets in play,viewon exactly the one GVC this release installs into (for the boot-time location check described in Matching the Location List to the GVC), plus a bucket-scoped cloud binding when backups are enabled.
Prerequisites
An existing GVC with at least 2 locations, andglobal.locations set to match it. This template deploys into the GVC you install it into and creates none of its own. Read the GVC’s list with cpln gvc get GVC_NAME -o yaml and compare spec.staticPlacement.locationLinks before you install — see Matching the Location List to the GVC for what happens if the two disagree.
PostgreSQL credentials are supplied through a dictionary secret that you create before installing. They are never passed through Helm values.
Create the credentials secret
username, password and database:username and database — they are used unquoted when the database is created. Secrets are org-level, so no GVC flag is involved.Point the template at it
postgres.credentialsSecretName to the secret’s name.Read the credentials back later
UI
CLI
Terraform
Pulumi
Configuration
The defaultvalues.yaml for this template:
Locations
The location list lives underglobal so that Helm passes the same list to the bundled etcd cluster. Never maintain the two lists separately.
global.locations[].name— A Control Plane location (e.g.aws-us-east-1) that must already be one of the locations of the GVC you install into. At least 2 are required; see How Many Locations You Need.global.locations[].replicas— Number of Patroni members in that location. etcd always runs exactly one member per location regardless of this value. Members are named{workload}-{location}-{index}, so they stay unique when a location runs more than one.
Matching the Location List to the GVC
The platform validates the pairing in neither direction, so the template closes both. Each mismatch fails differently:view grant described in What Gets Created. The check is deliberately asymmetric:
- On a fresh data directory it is a hard failure: etcd and Patroni both log a
FATAL:line naming the missing location and refuse to bootstrap, and the containers exit with code1. - On an already-initialized member it is a warning only, and the member keeps serving. A location removed from a GVC is indistinguishable from a location that is down, and surviving that is exactly what this template is for.
- If the GVC read itself fails — a control-plane blip, or a missing policy — the check is skipped with a warning. It never stops a database on its own unavailability.
primaryLocation or backup.location names a location the GVC lacks. Look for [patroni] and [etcd] lines in cpln logs '{gvc="GVC_NAME", workload="RELEASE_NAME-postgres"}'.
Credentials
postgres.credentialsSecretName names the dictionary secret described in Prerequisites. The template creates no credentials of its own, and PgBouncer reads the same secret.
ALTER ROLE first, then update the secret to match.Preferred Primary Location
primaryLocation names the location you want the primary in. Patroni treats “which member initializes an empty cluster” and “which member wins a failover” as separate questions, and the knob addresses both:
- On a fresh install it biases where the primary starts. Since template version 1.0.2, a member outside the preferred location whose data directory is empty holds back before bootstrapping: it polls the preferred location’s members and proceeds as soon as one answers as the leader — or as a replica, which means the cluster is already initialized elsewhere and waiting cannot move it.
- On a running cluster it biases failover elections toward that location through Patroni’s
failover_prioritytag, a tiebreaker between candidates that have replayed the same amount of WAL.
WARNING naming the consequence. Move the primary afterwards with patronictl switchover --candidate. The wait cannot split the cluster: the consensus store is the arbiter, so a member that gives up early finds the cluster already initialized and clones as a replica instead. Before version 1.0.2 the knob rendered only the failover tag, so a fresh install raced and the primary could land in any location.primaryLocation puts a cross-region hop in front of your entire write path, and schema migrations are where that shows up first. On Grafana Multi-Location, which bundles this template, the same 713 migrations took about 5 seconds with the primary local and stretched to minutes when it bootstrapped in another region. In the pair of test installs measured that way, the cold install took 11 m 41 s with the primary remote against 4 m 19 s with it in place, and the Grafana instances waiting on the migrations restarted 15 times against 2 — every figure in that comparison comes from those two runs.
The preference biases elections; it does not fail back automatically after an outage. A cluster whose preferred location is restored keeps its current leader until the next election.
Resources and Storage
image— The Patroni + PostgreSQL 17 image.resources.minCpu/resources.minMemory— Reserved CPU and memory per member.resources.maxCpu/resources.maxMemory— Limits per member.volumeset.capacity— Initial volume size in GiB (minimum 10). Every member gets its own volume.volumeset.autoscaling.enabled— Expand the volume automatically as it fills. When enabled:maxCapacity— Maximum volume size in GiB.minFreePercentage— Trigger a scale-up when free space drops below this percentage.scalingFactor— Multiply the current capacity by this factor when scaling up.
Internal Access
internalAccess applies to the PostgreSQL, HAProxy and PgBouncer tiers alike. There is no public access in this version — every endpoint is internal to Control Plane.
workload-list, list only your clients. The same list governs Patroni-to-Patroni streaming replication, the proxy’s health checks of every member, PgBouncer pooling into HAProxy and the nightly dump, so a list naming only clients would cut the cluster off from itself. This release’s own workloads — Patroni, the proxy, PgBouncer and the backup cron — are appended for you, and a client that is not listed is still refused.Leader-Routing Proxy
Only the member holding the leader lock accepts writes; the rest are read-only. HAProxy polls each member’s Patroni REST API and forwards connections to whichever one currently answers as the primary, so applications get a stable endpoint in their own location.proxy.minReplicas / proxy.maxReplicas are per location — the default of 2 means 2 replicas in each configured location.
logical backups, which must dump from the current primary, and the chart refuses to render without it. Enabling PgBouncer enables the proxy implicitly, even if proxy.enabled is set to false.PgBouncer Connection Pooling
PgBouncer multiplexes application connections into a smaller pool of real database connections. It pools into its own location’s HAProxy rather than into a member, so leader routing and failover stay transparent. When enabled it becomes the connection endpoint applications use.pgbouncer.defaultPoolSize— Real PostgreSQL connections kept per PgBouncer replica (default25).pgbouncer.maxClientConn— Client connections accepted per PgBouncer replica (default1000).pgbouncer.maxDbConnections— Cap on PostgreSQL connections per PgBouncer replica, not across the tier. PgBouncer instances do not coordinate, so the real ceiling ismaxReplicas × maxDbConnections; at the shipped values that is 4 × 100 = 400 against the cluster’smax_connectionsof 100, three of which are reserved for superusers. Size it somaxReplicas × maxDbConnectionsstays comfortably under 97, or clients getremaining connection slots are reservedunder load rather than being queued. TreatdefaultPoolSizethe same way.pgbouncer.minReplicas/pgbouncer.maxReplicas— Autoscaling bounds, per location.
etcd Consensus Store
Theetcd block is passed straight through to the bundled etcd-multi-location chart — pinned to 2.0.0, the version that no longer creates a GVC — which is where its image, resources, storage, access and emergency recovery settings are documented. It reads its location list from the same global.locations, so the two can never be edited apart.
etcd.tuning.autoCompactionMode, etcd.tuning.autoCompactionRetention and etcd.tuning.quotaBackendBytes control how much revision history etcd keeps and how large its backend may grow. Compaction is enabled in every version of the bundled chart and cannot be switched off, only retuned. The defaults — periodic, 1h and 0 (etcd’s own 2 GiB limit) — are the right settings for a Patroni consensus store and should be left alone: Patroni renews its leader lease every ten seconds or so, and every renewal is a revision, so an uncompacted backend grows with time alone until etcd goes read-only and takes the database’s failover with it. See Compaction and Backend Growth for the mechanism and the accepted value formats.
etcd.tuning.heartbeatIntervalMs and etcd.tuning.electionTimeoutMs are the raft timers, tuned for cross-region round trips; raise both in proportion if your locations are more than about 250 ms apart.
Connecting
Everything is internal to Control Plane. Substitute your release name and the name of the GVC you installed the release into.Migrating From Version 1
Every release before 2.0.0 created its own GVC. Do nothelm upgrade a 1.x release onto 2.0.0. Once the chart stops declaring a GVC, Helm prunes the one the old release created — and deleting a GVC deletes every workload, volume set and identity inside it, including the volumes holding your database. Measured on a sibling template: everything was gone in about six seconds, while the command printed upgraded successfully.
Install 2.0.0 as a new release against an existing GVC, move the data across, then remove the old release.
Back up the 1.x cluster and verify the dump
backup.mode: logical, or run a pg_dumpall through the old release’s proxy endpoint. On 1.0.0 and 1.0.1 that endpoint is {release}-postgres-ml-proxy; from 1.0.2 onwards it is {release}-postgres-proxy. This dump is the only copy that crosses the boundary, so confirm it is a real dump and not a zero-length object before going further.Rewrite your values
global.gvc.name and rename global.gvc.locations to a top-level global.locations. Every location you list must already exist in the GVC you are installing into.Install 2.0.0 as a new release into an existing GVC
Restore the dump and move your connection strings
{new-release}-postgres-proxy.GVC_NAME.cpln.local:5432.Remove the old release
Availability and Planned Outages
Measured on a three-location cluster (aws-us-east-1, aws-eu-central-1, aws-us-west-2) with one member per location and a one-second write probe running in each location.
Upgrading From 1.0.0 or 1.0.1
bootstrapped from leader …). That old primary kept serving for 86 seconds after Helm reported it deleted, and a 7.5 MB database cloned in 12 seconds. A database that cannot finish a base backup inside that window has nothing to fall back on, because the volume holding it has already been destroyed — that failure case is inferred, not observed, and it is not a race worth running.
Two further consequences of the same upgrade, both measured:
- No writable primary for about 2 minutes 22 seconds. The upgrade bounces the etcd tier too, so Patroni cannot immediately expire the stale leader key.
{release}-postgres-ml-proxyis deleted and never returns. Every application connection string has to move to{release}-postgres-proxy; nothing recovers on its own.
Back up the existing cluster — first, and before anything else
backup.mode: logical, or run a pg_dumpall against the proxy endpoint of the old release. This
dump is the only copy of your data that survives the rename, so verify it before you touch the release.Uninstall the old release
cpln helm uninstall RELEASE_NAME --gvc GVC_NAME.Install 1.0.2 and restore
Move every client to the new proxy hostname
{release}-postgres-ml-proxy to
{release}-postgres-proxy. The old hostname no longer exists.Application Retry Logic
Applications must retry on transient database errors. Two are worth handling explicitly during a failover:- Connection-level failures —
server closed the connection unexpectedly,terminating connection due to administrator command. ERROR: cannot execute INSERT in a read-only transaction, seen briefly when the proxy forwards to a member that has just been demoted and its health check has not yet flipped.
Data Loss on Failover
Replication is asynchronous. A promoted replica applies everything it has received, so a failover can lose the transactions that had not yet reached it — bounded by the replication lag at the instant of failure. Check lag withpg_stat_replication on the primary; in the service mesh every replication client reports the same client_addr, so use application_name (the member name) to tell members apart.
Operating the Cluster
Members are named{workload}-{location}-{index}, for example my-db-postgres-aws-us-east-1-0. patronictl reads the config the startup script writes to /tmp/patroni_config.yml:
Failover Timing
The cluster ships a fixed Patroni consensus configuration, and it is what bounds how long an abrupt loss of the primary takes to resolve:loop_wait + 2 × retry_timeout <= ttl and, when that is violated, silently substitutes values of its own rather than reporting an error — which is why the three always move together.
Consensus-level settings (ttl, loop_wait, retry_timeout, maximum_lag_on_failover, failsafe mode) are written once, when the cluster is first initialized, and are not values knobs — a knob would look adjustable while only ever applying to a brand-new cluster. Read and change them on a live cluster with patronictl:
Recovering From a Lost Location
With 3 or more locations this section does not apply — losing one location is an automatic failover. With 2 locations, losing one loses consensus quorum permanently: the survivor holds current data but cannot be granted the leader lock, and consensus writes time out. To rebuild from the surviving member, setetcd.recovery.forceNewClusterInLocation to that member’s location and run a helm upgrade. Once writes are accepted again, return the value to "" and reprovision the failed location’s members — their volumes must be reset before they rejoin.
Backing Up
Backups are disabled by default. Setbackup.enabled: true, choose a mode, and configure a storage provider.
backup.location exists because a cron workload runs in every location of its GVC. Without it a three-location cluster would dump the same database three times a night into one bucket. Pick the location nearest the bucket — that is where the dump is read and uploaded from. It applies to logical mode only: the WAL-G sidecar runs on every member but only the one holding the leader lock pushes, so the archive follows the primary across a failover with no selector needed.
backup.logical.schedule— Cron expression for the dump (default: daily at 02:00 UTC). The dump runs through the proxy, so it always hits the current primary, and includes roles and all databases.backup.walg.intervalSeconds— Seconds between base backups (default21600, every 6 hours). WAL segments are archived continuously in between.backup.resources.cpu/backup.resources.memory— Resources for whichever mode is enabled.
backup.mode to or from wal-g restarts PostgreSQL, because it changes archive_mode. Enabling logical backups does not. After enabling wal-g, confirm SHOW archive_mode reports on in every location before relying on the archive — one location can take up to about ten minutes to pick up the new configuration.AWS S3
Before enabling backup withprovider: aws, complete the following in your AWS account:
- Create an S3 bucket. Set
backup.aws.bucketto its name andbackup.aws.regionto its region. - If you do not have a Cloud Account set up, refer to the docs to Create a Cloud Account. Set
backup.aws.cloudAccountNameto its name. - Create an IAM policy with the following JSON, replacing
YOUR_BUCKET_NAME:
- Set
backup.aws.policyNameto the name of the policy created in step 3. This bucket-scoped policy is all the workload identity needs — no broad managed policy is required. - Set
backup.aws.prefixto the folder path where backups will be stored.
GCS
Before enabling backup withprovider: gcp, complete the following in your GCP account:
- Create a GCS bucket. Set
backup.gcp.bucketto its name. - If you do not have a Cloud Account set up, refer to the docs to Create a Cloud Account. Set
backup.gcp.cloudAccountNameto its name. - Add the Storage Admin role to the GCP service account associated with the Cloud Account. The template additionally binds the identity to
roles/storage.objectAdminon exactly the bucket named inbackup.gcp.bucket. - Set
backup.gcp.prefixto the folder path where backups will be stored.
MinIO
No Cloud Account is needed — credentials are supplied as a secret.- Create a bucket in MinIO. Set
backup.minio.bucketto its name. - Set
backup.minio.endpointto the MinIO S3 API address including the port. For the minio template deployed in the same GVC, usehttp://WORKLOAD_NAME:9000. - Create a dictionary secret holding the MinIO credentials and set
backup.minio.credentialsSecretNameto its name. For theminiotemplate these are itsadmin.usernameandadmin.password:
- Set
backup.minio.prefixto the folder path where backups will be stored.
Restoring a Backup
pg_dumpall artifact was replayed with psql into an initdb-fresh cluster rather than through the
proxy into a live one, and the WAL-G restore was fetched and replayed into an empty directory rather
than onto a swapped volume set. The steps below are the operational form of those procedures — rehearse
them in a scratch environment before you need them.Logical
Stream the dump back through the proxy, which writes to the current primary. Run this from a client with access to the bucket:aws s3 cp with gsutil cp "gs://BUCKET_NAME/PREFIX/BACKUP_FILE.sql.gz" -. For MinIO, add --endpoint-url "http://MINIO_ENDPOINT:9000" and run aws configure set default.s3.addressing_style path first.
WAL-G
A point-in-time restore needs an empty data directory, so it restores into a new volume set:- Run
wal-g backup-listto identify the desired backup. Verified working from the WAL-G sidecar. - Stop the Patroni workload.
- Create a new volume set and mount it at
/var/lib/postgresql/dataon a one-off restore workload. - Run
wal-g backup-fetch /var/lib/postgresql/data/pgdata BACKUP_NAME. Verified working — it produces a complete, valid data directory. - Re-point the Patroni workload at the restored volume set and start it. Not achievable through the chart — see the warning above.
- Change the WAL-G prefix before re-enabling backups, or the new cluster’s WAL collides with the old system identifier.
Important Notes
- There is no upgrade path from 1.x. Every 1.x release created its own GVC; 2.0.0 deploys into an existing one, and a
helm upgradeacross that boundary deletes the old GVC and every volume set in it. The chart refuses to render on the 1.xglobal.gvckey, but that guard cannot see an upgrade run with no values at all. Follow Migrating From Version 1: new release, restore, then uninstall the old one. global.locationsmust match the GVC you install into. A GVC location this release does not list runs nothing, which is harmless; a listed location the GVC lacks makes a fresh cluster refuse to bootstrap, naming the location. See Matching the Location List to the GVC.- Back up before upgrading a 1.0.0 or 1.0.1 cluster to 1.0.2. Resource names dropped the
-mlinfix in 1.0.2, and the upgrade deletes the old volume set — there is no orphaned copy and no reachable final snapshot. Data survived a measured upgrade only because the new members re-cloned from the old primary during an 86-second window before it was torn down. Back up, uninstall, reinstall and restore instead, and move every connection string to{release}-postgres-proxy: see Upgrading From 1.0.0 or 1.0.1. - Create the credentials secret before installing.
postgres.credentialsSecretNamenames a secret this template does not create. Without it the deployment waits on a secret that does not exist; creating it afterwards clears the wedge within a few minutes. - Every
helm upgradeinterrupts writes in all locations for about two minutes. Plan changes as maintenance windows. - Replication is asynchronous, so a failover can lose the most recent transactions — bounded by the replication lag at the moment of failure.
- Set
primaryLocationbefore the first install if you care where the primary is. Since version 1.0.2 it also biases where the primary bootstraps: members elsewhere wait up to 90 seconds for the preferred location, then bootstrap anyway and log aWARNING. Changing it later moves a live primary — that restarts every member, costs the full upgrade outage and triggers an election — and it does not fail back automatically after an outage. - Never suspend a location. Suspending and resuming one permanently withdraws its endpoints from the other locations’ service discovery while every status surface still reports healthy. To remove a location, take it out of
global.locationsand out of the GVC. - With
internalAccess.type: workload-list, list only your clients — this release’s own tiers are added for you. The bundled etcd tier is separate: settingetcd.internalAccess.type: workload-listrequires adding the Patroni workload link by hand, or Patroni loses its consensus store. - A restore from WAL-G cannot be completed through the chart today. The archive is restorable and
backup-fetchproduces a valid data directory, but nothing invalues.yamlnames the volume set the final step tells you to swap in. See WAL-G. - Allow about two minutes after a cold install before believing a member is unreachable — cross-location service discovery takes that long to converge. Firewall changes can take up to about four minutes.
- Consensus-level settings are not values knobs. They are written once, at first initialization; change them with
patronictl edit-config. - Cost scales with write volume multiplied by the members outside the primary’s location. Each of them receives a full copy of the WAL stream, and cross-region traffic is billed. Read-mostly workloads are inexpensive to stretch; write-heavy ones are not.
External References
Patroni Documentation
patronictl Reference
Patroni Dynamic Configuration
patronictl edit-config