Repository navigation
Feature: support backups taken from a standby (pgBackRest TLS multi-host + --backup-standby) #103
Description
Summary
CNPG's default backup target is prefer-standby, so by default CNPG dispatches scheduled backups to a replica to keep the datafile scan and object-store upload IO off the primary. With this plugin, a backup that lands on a standby fails at stanza-create with pgBackRest exit 56 (unable to find primary cluster - cannot proceed). On a cluster with two or more instances, the only way to get backups working today is to pin spec.target: primary, which puts the full backup IO back on the primary. That defeats the purpose of prefer-standby.
This is a feature request for backups physically taken from a standby, coordinated with the primary the way pgBackRest supports natively via multi-host plus --backup-standby. It comes with a concrete, SSH-less design and an offer to implement it.
I've read the two earlier issues on this (#79 and #83). Both are closed, and neither landed a fix on main. How this proposal relates to them, and why it isn't a re-file, is below.
Current behavior (main at 025fd12, functionally identical to the latest release v0.6.0)
The plugin configures pgBackRest for exactly one local PostgreSQL instance and never generates a second (primary) host:
-
AppendStanzaOptionsFromConfigurationcallsappendStanzaOptions(ctx, options, 0, …)with a hardcoded db index0. The maintainer TODO right above it already raises the same question:// TODO: There probably should be more entries for replicas. Is it even doable
// to backup from a replica without SSH access or pgbackrest server running on the other node? -
That single instance is wired to the local unix socket
/controller/run/. NopgN-hostis emitted for a peer: -
The
BackupRPC runsCreatePgbackrestStanzaunconditionally, beforeTake, with no check for whether the pod is a standby or in recovery: -
The public API type
PgbackrestConfiguration(and thearchives.pgbackrest.cnpg.opera.comCRD) has no topology, host, or standby fields. It only carries repositories, wal, data, restore, compression, and stanza: -
The project's own e2e backup fixtures pin
target: primarywith// TODO: Implement support for standby backups.in two places:https://github.com/operasoftware/cnpg-plugin-pgbackrest/blob/v0.6.0/test/e2e/internal/tests/backup/fixtures.go#L153
https://github.com/operasoftware/cnpg-plugin-pgbackrest/blob/v0.6.0/test/e2e/internal/tests/backup/fixtures.go#L176
Reproduction: any CNPG cluster with two or more instances plus this plugin. Create a Backup or ScheduledBackup with spec.target: prefer-standby (or leave it at the CNPG default). CNPG places the backup on a replica, the phase goes to failed, and the sidecar shows stanza-create exit 56. Setting spec.target: primary makes the same backup succeed.
Relationship to #79 and #83
- Support running backups on a standby (currently fails at stanza-create with exit 56) #79 ("Support running backups on a standby …") is closed. The reporter noted it had been auto-filed and walked it back.
- Backup RPC always runs stanza-create, so target: prefer-standby / standby backups fail with pgbackrest exit 56 #83 ("Backup RPC always runs stanza-create …") was closed by the reporter, not by a fix.
backup.gostill callsCreatePgbackrestStanzaunconditionally onmain. On that thread @Agalin wrote: "while I agree that standby backups should be added, it's not as simple as executing pgbackrest on the standby … backup process must be coordinated across primary and standby node." That is correct, and it is the reason for the design below.
This issue picks up from that comment. It is a concrete design for the coordinated case plus an offer to build it, rather than a re-open of the "just skip stanza-create" idea.
Why the minimal "skip stanza-create on a standby" idea isn't enough
One natural first instinct is to guard CreatePgbackrestStanza with pg_is_in_recovery() and skip it on a standby. That does not produce a working standby backup. pgBackRest's backup command itself requires a primary among its configured pgN hosts. It brackets the backup with pg_backup_start() and pg_backup_stop(), and PostgreSQL refuses those during recovery. With only the local standby configured, backup fails with the same unable to find primary cluster (exit 56) that stanza-create hits. So removing the stanza-create step does not get you there. The plugin has to give pgBackRest a connection to the current primary, which means a multi-host setup. This matches @Agalin's point above.
Proposed design (SSH-less, TLS-server multi-host)
pgBackRest's supported path for a standby backup is a two-host config. It talks to both primary and standby, runs start/stop backup on the primary, and copies data from the standby. A distroless sidecar has no SSH, so this uses pgBackRest's TLS server transport, which is the modern alternative to SSH. Sketch:
- Emit a second pg-host in the config of the pod running the backup (the standby):
pg2-host=<current primary>,pg2-host-type=tls,pg2-host-port,pg2-path,pg2-user, andpg2-host-cert-file/pg2-host-key-file/pg2-host-ca-file.pg1stays the local instance as today. - Run a pgBackRest TLS server in each instance sidecar (
pgbackrest serverplustls-server-address/-port/-cert-file/-key-file/-ca-file/-auth), so a peer sidecar can drivepg_backup_startandpg_backup_stopon it. Today the sidecar only runs client commands against the local socket. - Pass
--backup-standby, either asyor mapped from the CNPG target soprefer-standbybecomes--backup-standby=prefer, so pgBackRest offloads the data copy to the standby while coordinating control on the primary. - Failover-aware primary discovery. The primary is dynamic. The plugin already reads this from CNPG:
configuration.Cluster.Status.CurrentPrimaryandCluster.IsReplica()are used in the WAL path (internal/cnpgi/common/wal.go). The backup config-build path can reuse the same signal to fillpg2-host(the current-primary pod, or the-rwService) and reconcile it when the primary changes. - TLS material for the pgBackRest server. This is a separate cert chain from the plugin's existing CNPG-I gRPC client and server secrets. The plugin would need to provision (or deliberately reuse) certs for the pgBackRest server, with client-cert CNs that
tls-server-authaccepts.
This also implies a small API addition, for example an opt-in backupStandby knob on the archive config, since PgbackrestConfiguration has nowhere to express any of this today.
Open questions for maintainers
- Is a PR along these lines something you'd accept, or do you already have a direction in mind?
- TLS server lifecycle: a long-running
pgbackrest serveralongside the existing sidecar process, or one started on demand per backup. Any preference? - Certs: provision a dedicated pgBackRest-server chain, or reuse and derive from the existing plugin TLS secrets? Any constraint I should respect?
- Primary discovery: the
-rwService (k8s handles the failover repointing, though TLS CN verification against a Service name needs some thought), or resolving the current-primary pod fromCluster.Status.CurrentPrimarydirectly? - Anything in the CNPG-I sidecar model that makes a peer-to-peer TLS server between instance pods harder than I've assumed?
Happy to prototype this and open a draft PR to make the discussion concrete. I wanted to align on direction, and on the questions above, before writing much code.
References: pgBackRest user guide sections on backup from a standby and TLS server setup; the pgstef writeup linked on #83; pgBackRest #1553 and the Crunchy Data writeup linked on #79.
Activity
Agalin commentedon Jul 24, 2026
Thanks for the detailed description. I'm yet to check the MR. Answering the questions:
-roand-rwservice domains in SANs and I'm not even sure if they by default include pod's own name (if cert is shared - and I believe it is then quite likely they don't). That means the service usage is preferred. As long as db certs are reused, we can expect (and simply document that expectation) that db nodes have proper SANs for service connections - those are necessary for proper TLS connection from client pods so it's the scenario we should encounder in most cases anyway.melancholictheory commentedon Jul 24, 2026
Thanks, that settles most of it. We're happy to go with all three: a short-lived on-demand server, reusing the existing db certificates, and service-based addressing. Before reworking the PR I checked how the certificates and services actually look on a CNPG 1.28 cluster, and one thing doesn't line up.
You remembered the SANs correctly. The
<cluster>-servercertificate has subjectCN=<cluster>-rw, and its SANs cover only service domains:<cluster>-rw,<cluster>-ro,<cluster>-external-rwand<cluster>-external-ro, each in the short,.<ns>,.<ns>.svcand.<ns>.svc.cluster.localforms. No pod name, no pod IP. Its extended key usage is TLS Web Server Authentication.Certificate reuse also splits more cleanly than I expected.
<cluster>-replicationis already a client certificate (CN=streaming_replica, TLS Web Client Authentication), so it fits the client side together withtls-server-auth=streaming_replica=*, with<cluster>-caas the CA.The problem is addressing. Every name in those SANs belongs to a Service, and the Services only publish 5432:
They're ordinary ClusterIP services, not headless, so connecting to
<cluster>-rw:<pgbackrest-port>never reaches a pgbackrest server in the primary's sidecar. The pod itself takes traffic on any port via its IP, but that IP and the pod name aren't in the certificate. So no single name is both covered by the db certificate and able to carry traffic to a non-Postgres port on the primary.Three ways out, as far as I can see:
Do you have a preference, or is there an approach I'm missing? Once this is settled I'll rework the PR onto the on-demand server and certificate reuse.
Agalin commentedon Jul 27, 2026
Hmm. You're correct, CNPG currently doesn't provide any way to extend autogenerated services, only disable them. To be honest, that's something they should address - either add an option to extend via the Cluster CRD or at least via CNPG-i.
We can drop the third option from the get go. That's absolutely the worst option and hardly acceptable to anyone.
So let's talk about what CNPG and CNPG-i actually let us do:
mutate clusterhook which allows plugins to emit patches to the cluster resource.Those options together would let us:
That would quite cleanly implement option 2. Is that a good approach? To be honest, I'm not sure, mutating the cluster object is less than ideal. Still, I believe it should be a viable and mostly stable solution - as long as there is an opt-out from each automatic change (or at least a shared opt-out).
That still seems easier than providing a dedicataed CA and duplicating operator's work to ensure service is pointing to a correct node.
The ultimate hack here would be to disable the default
rwserrvice (which is possible) and create a replacement with a slightly altered implementation, but I really don't think this would be a good idea. Instead, this limitation should be raised as an issue in the CNPG repository.melancholictheory commentedon Jul 27, 2026
Sounds good, we'll go with option 2 via the mutate cluster hook. Options 1 and 3 are out for the reasons you gave.
I checked the certificate and service layout on a live cluster first, and it holds up:
spec.managed.servicestakes aserviceTemplate, soclusterIP: Nonegives a headless service with the rw/ro/r selector split, which is what we need to reach a specific primary on a non-Postgres port.<cluster>-replicationis a client cert (CN=streaming_replica, client auth) that pairs withtls-server-auth=streaming_replica=*,<cluster>-serveris the server side with the service-domain SANs you described, and<cluster>-cais the CA.serverAltDNSNamesisn't in the documented rolling-update triggers, and I confirmed it on a running cluster: the operator regenerated the server certificate online, with no pod restart. So turning the feature on doesn't bounce the instances.So the shape on our side: the mutate-cluster hook adds a headless managed service on the pgBackRest port plus the matching SAN, each behind an opt-out, reuses the db certs, and starts a short-lived server per backup. I'll rework the PR onto that.
On the underlying limitation (autogenerated services can only be disabled, not extended): I can open an issue on the CNPG repo for it, unless you'd rather raise it yourself since you know where it should land.
feat(operator): add MutateCluster hook injecting the standby-backup s…
feat(instance): reach the primary via the injected service and reuse …
melancholictheory commentedon Jul 27, 2026
Quick follow-up: we opened the CNPG issue for the underlying limitation at cloudnative-pg/cloudnative-pg#11255. It describes the options from this thread (letting the generated services be extended, or covering managed-service names in the server certificate) and links back here.
Agalin commentedon Jul 28, 2026
Thanks for raising the issue to upstream.
ardentperf commentedon Jul 30, 2026
Instead of trying to set up custom services or manage custom certs, could we just manually have our code verify that the SAN is what’s expected? I think we know what it should be (-rw)?
melancholictheory commentedon Jul 30, 2026
Good instinct on the SAN, @ardentperf, that is exactly what makes reusing the existing certs viable. I went and checked pgBackRest to be sure, and the catch is where the verification happens.
pgBackRest does the check itself, inside its own TLS handshake, not somewhere the plugin can hook. In
tlsClientAuth()it callstlsClientHostVerify(host, cert), which matches the certificate SAN (falling back to CN) against the host it dials, i.e. thepg1-host/repo1-hostvalue. So there is no spot for us to "verify the SAN is<name>-rw" in our own code. pgBackRest already verifies it, against whatever address we hand it to connect to.We also cannot turn the check off to sidestep it. In
protocolRemoteExec()(src/protocol/helper.c) theverifyPeerargument totlsClientNewP()is hardcoded totrue, and there is no config option for host-to-host TLS to disable it. The only*-verify-tlstoggle isrepo-storage-verify-tls, which covers the object store, not pg/repo host connections.That leaves us needing the certificate to be valid for the exact name we connect on.
<cluster>-rwis already in the server cert SAN, but it resolves to the Postgres port (5432), not the pgBackRest sidecar port, so we cannot reuse it as-is. That is why the managed-service route still needs both parts: a headless managed service on the pgBackRest port, plus that service name added to the server cert viaserverAltDNSNames, reusing the existing CA and certs.So I think we still land on option 2. Happy to be wrong if there is a pgBackRest angle I missed here.
ardentperf commentedon Jul 30, 2026
I see - sounds like there’s no equivalent of
sslmode = require(or prefer) 😢ardentperf commentedon Jul 30, 2026
Just to clarify - would option 1 with pod-level SAN be possible today with no CNPG changes needed? If so, and if the CNPG changes take a long time to get consensus on, that might be worth keeping in mind as a backup plan?
melancholictheory commentedon Jul 31, 2026
Right, there's no client-side "require but don't verify" knob like libpq's
sslmode=require. pgBackRest either verifies the host against the cert, or (withverifyPeeroff, which isn't exposed) checks nothing.On option 1: yes, it's doable today with no CNPG changes. If the plugin runs its own small CA, it can mint a short-lived server cert per backup with the target pod's address in the SAN (the pod IP is known at backup time via the API, or a pod DNS name) and point pgBackRest at that address. Verification passes because we control the SAN, and CNPG's certs and services stay untouched. So it is a fair backup plan to keep in the pocket.
Two things to weigh before treating it as the fallback:
spec.managed.services(a new headless service on the pgBackRest port) plusspec.certificates.serverAltDNSNames(the SAN), both already in current CNPG. I confirmed on a live cluster that adding the SAN regenerates the server cert online with no restart. The CNPG issue (#11255, extending generated services) is only a cleaner long-term shape, not a blocker for shipping option 2.So my plan is still option 2 as the primary, with the self-signed / pod-SAN route noted as a fallback if the managed-service approach hits something unexpected.
feat: support taking backups from a standby (pgBackRest multi-host TLS)
feat: support taking backups from a standby (pgBackRest multi-host TLS)
feat: support taking backups from a standby (pgBackRest multi-host TLS)
feat: support taking backups from a standby (pgBackRest multi-host TLS)