Single-region PostgreSQL HA is a well-trodden path: a primary, a standby, and streaming replication between them. Multi-region DR introduces problems that setup guides rarely cover — notably that pgBackRest insists on HTTPS endpoints for S3, while running native TLS on local MinIO is tedious to configure. Equally thorny is failback: returning to the original region means navigating timeline conflicts, operator deadlocks, and poisoned WAL archives.

The runbook below walks the full lifecycle with the Crunchy PostgreSQL Operator (PGO) on Kubernetes, using MinIO as the WAL repository: bootstrap, standby replication, simulated regional failure, promotion, and role reversal.

Multi region PostgreSQL

Building a TLS Gateway for MinIO

pgBackRest rejects plain HTTP S3 endpoints, but the local MinIO deployment does not need to speak TLS itself. A small NGINX reverse proxy in the minio namespace terminates TLS on port 443 with a self-signed certificate and forwards plain HTTP to MinIO on port 9000.

The certificate must be valid for the proxy's in-cluster DNS name, minio-secure.minio.svc.cluster.local:

1

2

3

4

5

6

7

8

9

10

11

12

# Generate the private key and self-signed certificate

openssl req -x509 -nodes -days 365 -newkey rsa:2048 \

  -keyout tls.key \

  -out tls.crt \

  -subj "/CN=minio-secure.minio.svc.cluster.local" \

  -addext "subjectAltName = DNS:minio-secure.minio.svc.cluster.local"

# Save the TLS certificate inside the minio namespace

kubectl create secret tls minio-secure-tls \

  --key tls.key \

  --cert tls.crt \

  -n minio

Backups and WAL streams can grow very large, so the default NGINX buffering behavior will stall on the payloads. Request and response buffering must be disabled so database traffic bypasses disk limits:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

57

58

59

60

61

62

63

64

65

66

67

68

69

70

71

72

73

74

75

76

77

78

79

80

81

82

83

# minio-secure-proxy.yaml

apiVersion: v1

kind: ConfigMap

metadata:

  name: nginx-proxy-config

  namespace: minio

data:

  nginx.conf: |

    events {

        worker_connections 1024;

    }

    http {

        server {

            listen 443 ssl;

            server_name minio-secure.minio.svc.cluster.local;

            ssl_certificate /etc/nginx/ssl/tls.crt;

            ssl_certificate_key /etc/nginx/ssl/tls.key;

            # CRITICAL: Disable request body buffering to allow massive backup streams

            client_max_body_size 0;

            proxy_buffering off;

            proxy_request_buffering off;

            location / {

                proxy_set_header Host $http_host;

                proxy_set_header X-Real-IP $remote_addr;

                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

                proxy_set_header X-Forwarded-Proto $scheme;

                # Forward requests to the internal plain HTTP MinIO Service

                proxy_pass http://minio.minio.svc.cluster.local:9000;

            }

        }

    }

apiVersion: apps/v1

kind: Deployment

metadata:

  name: minio-secure

  namespace: minio

spec:

  replicas: 1

  selector:

    matchLabels:

      app: minio-secure

  template:

    metadata:

      labels:

        app: minio-secure

    spec:

      containers:

      - name: nginx

        image: nginx:alpine

        ports:

        - containerPort: 443

        volumeMounts:

        - name: config

          mountPath: /etc/nginx/nginx.conf

          subPath: nginx.conf

        - name: ssl

          mountPath: /etc/nginx/ssl

          readOnly: true

      volumes:

      - name: config

        configMap:

          name: nginx-proxy-config

      - name: ssl

        secret:

          secretName: minio-secure-tls

apiVersion: v1

kind: Service

metadata:

  name: minio-secure

  namespace: minio

spec:

  ports:

  - port: 443

    targetPort: 443

    protocol: TCP

  selector:

    app: minio-secure

Apply the secure gateway configuration:

1

kubectl apply -f minio-secure-proxy.yaml

Any namespace can then reach MinIO at minio-secure.minio.svc.cluster.local:443.

Bootstrapping Both Regions

Shared credentials

The same MinIO credentials secret is applied to both region-east and region-west:

1

2

3

4

5

6

7

8

9

10

11

# minio-secret.yaml

apiVersion: v1

kind: Secret

metadata:

  name: pgbackrest-s3-creds

type: Opaque

stringData:

  s3.conf: |

    [global]

    repo1-s3-key=minioadmin

    repo1-s3-key-secret=minioadmin

1

2

kubectl apply -f minio-secret.yaml -n region-east

kubectl apply -f minio-secret.yaml -n region-west

Primary cluster in Region East

The East cluster points backup and WAL archiving at the secure proxy. Because the certificate is self-signed, the manifest sets repo1-s3-verify-tls: "n"; transport is still HTTPS on port 443.

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

# primary-db.yaml

apiVersion: postgres-operator.crunchydata.com/v1beta1

kind: PostgresCluster

metadata:

  name: primary-db

  namespace: region-east

spec:

  postgresVersion: 17

  instances:

    - name: instance1

      replicas: 2

      dataVolumeClaimSpec:

        accessModes: [ "ReadWriteOnce" ]

        resources:

          requests:

            storage: 1Gi

  backups:

    pgbackrest:

      configuration:

        - secret:

            name: pgbackrest-s3-creds

      global:

        repo1-s3-uri-style: "path"

        repo1-s3-verify-tls: "n"

      repos:

        - name: repo1

          s3:

            bucket: pgbackrest

            endpoint: minio-secure.minio.svc.cluster.local:443

            region: us-east-1

1

kubectl apply -f primary-db.yaml

A standby cannot initialize without a physical base backup, so a manual baseline backup is triggered on the East primary before the West cluster exists:

1

kubectl annotate postgrescluster primary-db -n region-east --overwrite postgres-operator.crunchydata.com/pgbackrest-backup="$(date +%s)"

Standby cluster in Region West

The West manifest matches the East one apart from spec.standby.enabled: true, which directs Patroni to boot in continuous archive-recovery mode instead of creating a fresh writable instance.

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

# standby-db.yaml

apiVersion: postgres-operator.crunchydata.com/v1beta1

kind: PostgresCluster

metadata:

  name: standby-db

  namespace: region-west

spec:

  postgresVersion: 17

  standby:

    enabled: true

    repoName: repo1

  instances:

    - name: instance1

      replicas: 1

      dataVolumeClaimSpec:

        accessModes: [ "ReadWriteOnce" ]

        resources:

          requests:

            storage: 1Gi

  backups:

    pgbackrest:

      configuration:

        - secret:

            name: pgbackrest-s3-creds

      global:

        repo1-s3-uri-style: "path"

        repo1-s3-verify-tls: "n"

      repos:

        - name: repo1

          s3:

            bucket: pgbackrest

            endpoint: minio-secure.minio.svc.cluster.local:443

            region: us-east-1

1

kubectl apply -f standby-db.yaml

Checking the Replication Path

Write to the East primary:

1

2

3

4

5

# Exec into the active East primary pod

kubectl exec -it -n region-east primary-db-instance1-xxxx-0 -c database -- psql -d postgres

# Create database school

\c school

1

2

3

# Create a test table and insert data

CREATE TABLE test_sync (id SERIAL PRIMARY KEY, message TEXT);

INSERT INTO test_sync (message) VALUES ('Hello from Region East!');

PostgreSQL normally archives a WAL segment only once it is full (16MB). For a quick test, force the current active transaction log to be written and archived immediately:

1

kubectl exec -it -n region-east primary-db-instance1-xxxx-0 -c database -- psql -d school -c "SELECT pg_switch_wal();"

The West standby pulls that WAL segment from MinIO and replays it locally:

1

2

3

4

5

6

kubectl exec -it -n region-west standby-db-instance1-yyyy-0 -c database -- psql -d school -c "SELECT * FROM test_sync;"

id |         message        

----+-------------------------

  1 | Hello from Region East!

(1 row)

Failover: Promoting Region West

With a regional outage simulated in the East, the West cluster becomes the active primary.

Regional database status

First, shut down the primary database in the East namespace through the operator:

1

kubectl patch postgrescluster primary-db -n region-east --type merge --patch '{"spec":{"shutdown": true}}'

Promotion means patching the West manifest to set spec.standby.enabled to false:

1

kubectl patch postgrescluster standby-db -n region-west --type merge --patch '{"spec":{"standby":{"enabled": false}}}'

Patroni then leaves recovery mode and performs a timeline switch, moving from Timeline 1 to Timeline 2. The West database is writable and owns the data:

1

2

kubectl exec -it -n region-west standby-db-instance1-dhr8-0 -c database -- \

  psql -d school -c "INSERT INTO test_sync (message) VALUES ('Hello from the promoted Region West!');"

Failback: Two Obstacles

Bringing Region East back as a standby of the West primary is not a matter of restarting the old pods. Two failure modes stand in the way.

Operator deadlock. To avoid data loss, the operator leaves the previous instance pod generations running side by side. It will not delete instances that are unhealthy — and they stay unhealthy precisely because of the timeline mismatch.

Archive poisoning. The old East primary may have written Timeline 3 metadata to MinIO before going down. The new West master runs on Timeline 2. A standby will not replicate from a Timeline 2 master while Timeline 3 is present in the archive, because it restores from the highest timeline it can find.

Clean out the East namespace

Stale operator state and physical volumes must be removed to give the database a blank slate.

  1. Delete the PostgresCluster resource.

1

kubectl delete postgrescluster primary-db -n region-east

  1. Force-delete stuck pods and PVCs.

1

2

3

kubectl delete pods --all -n region-east --grace-period=0 --force

kubectl delete pvc --all -n region-east --grace-period=0 --force

  1. Strip finalizers from PVCs trapped in Terminating.

1

kubectl get pvc -n region-east -o jsonpath='{.items[*].metadata.name}' | xargs -I {} kubectl patch pvc {} -n region-east -p '{"metadata":{"finalizers":null}}' --type=merge

  1. Wipe Patroni's distributed consensus store (DCS) ConfigMaps and Endpoints.

1

2

3

kubectl delete configmap --all -n region-east

kubectl delete endpoints --all -n region-east

Confirm the namespace is clear:

1

kubectl get pods,pvc,statefulsets,configmaps,endpoints -n region-east

Reset the object store

The poisoned Timeline 3 archives have to go. In the MinIO console, or via the S3 CLI, empty and delete the pgbackrest bucket, then recreate it empty.

Re-initialize pgBackRest on the West master

With the bucket empty, the active West master has nothing to archive into. Create the stanza inside its database container and lay down a fresh Timeline 2 backup:

1

2

3

kubectl exec -it -n region-west standby-db-instance1-dhr8-0 -c database -- \

  pgbackrest --stanza=db stanza-create

1

2

3

kubectl exec -it -n region-west standby-db-instance1-dhr8-0 -c database -- \

  pgbackrest --stanza=db --type=full backup

1

2

3

4

5

6

7

8

9

10

11

12

13

kubectl exec -it -n region-west standby-db-instance1-dhr8-0 -c database -- pgbackrest info

Under the db (current) block, your WAL archive range should now cleanly list Timeline 2 files (00000002...):

stanza: db

    status: ok

    cipher: none

    db (current)

        wal archive min/max (17): 000000020000000000000014/000000020000000000000014

Re-create Region East as a standby

Update primary-db.yaml so that spec.standby.enabled is true and spec.shutdown is false:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

# primary-db.yaml

apiVersion: postgres-operator.crunchydata.com/v1beta1

kind: PostgresCluster

metadata:

  name: primary-db

  namespace: region-east

spec:

  postgresVersion: 17

  shutdown: false # <-- Ensure the database boots

  standby:

    enabled: true  # <--- Reconfigured to act as a Standby Leader

    repoName: repo1

  instances:

    - name: instance1

      replicas: 1  # Standardizing on a single standby replica for testing

      dataVolumeClaimSpec:

        accessModes: [ "ReadWriteOnce" ]

        resources:

          requests:

            storage: 1Gi

  backups:

    pgbackrest:

      configuration:

        - secret:

            name: pgbackrest-s3-creds

      global:

        repo1-s3-uri-style: "path"

        repo1-s3-verify-tls: "n"

      repos:

        - name: repo1

          s3:

            bucket: pgbackrest

            endpoint: minio-secure.minio.svc.cluster.local:443

            region: us-east-1

1

kubectl apply -f primary-db.yaml

Confirm the loop is closed

The standby pod should pull the Timeline 2 backup and bootstrap cleanly:

1

kubectl get pods -n region-east -w

Once it reports 4/4 Running, patronictl list should show it joined as a healthy standby leader on Timeline 2:

1

2

3

4

5

6

7

8

kubectl exec -it -n region-east primary-db-instance1-zzzz-0 -c database -- patronictl list

+ Cluster: primary-db-ha (7665256549608558683) -----------------------------+----------------+---------------------+----+-------------+-----+------------+-----+

| Member                      | Host                                        | Role           | State               | TL | Receive LSN | Lag | Replay LSN | Lag |

+-----------------------------+---------------------------------------------+----------------+---------------------+----+-------------+-----+------------+-----+

| primary-db-instance1-5lm8-0 | primary-db-instance1-5lm8-0.primary-db-pods | Replica        | in archive recovery |  2 |  0/12000000 |  16 | 0/13000000 |   0 |

| primary-db-instance1-sktj-0 | primary-db-instance1-sktj-0.primary-db-pods | Standby Leader | in archive recovery |  2 |             |     |            |     |

+-----------------------------+---------------------------------------------+----------------+---------------------+----+-------------+-----+------------+-----+

Finally, read the table in Region East to confirm that the write made in the West during the outage has replicated back:

1

2

3

4

5

6

7

8

kubectl exec -it -n region-east primary-db-instance1-5lm8-0 -c database -- \

  psql -d school -c "SELECT * FROM test_sync;"

id |                message                

----+--------------------------------------

  1 | Hello from Region East!

34 | Hello from the promoted Region West!

(2 rows)

What the Exercise Demonstrates

  • A terminating TLS proxy lets pgBackRest satisfy its HTTPS requirement against a local MinIO without native TLS or plaintext credentials in the cluster.
  • Kubernetes operators deliberately preserve old database state; recovering from that state during a role reversal requires manual cleanup.
  • Timeline splits (Timeline 2 versus Timeline 3) will block standby replication, and the fix is to purge and rebuild the WAL repository rather than patch around it.