<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Muhammad Adnan Sarfraz</title>
    <description>The latest articles on DEV Community by Muhammad Adnan Sarfraz (@muhammad_adnansarfraz_cd).</description>
    <link>https://dev.to/muhammad_adnansarfraz_cd</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4188046%2F51f06e83-9b13-4304-82dc-cac13daff513.png</url>
      <title>DEV Community: Muhammad Adnan Sarfraz</title>
      <link>https://dev.to/muhammad_adnansarfraz_cd</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/muhammad_adnansarfraz_cd"/>
    <language>en</language>
    <item>
      <title>Leaving Cloud SQL: How We Moved 11 Postgres Databases onto One CloudNativePG Cluster</title>
      <dc:creator>Muhammad Adnan Sarfraz</dc:creator>
      <pubDate>Fri, 16 Oct 2026 07:18:22 +0000</pubDate>
      <link>https://dev.to/muhammad_adnansarfraz_cd/leaving-cloud-sql-how-we-moved-11-postgres-databases-onto-one-cloudnativepg-cluster-5b2c</link>
      <guid>https://dev.to/muhammad_adnansarfraz_cd/leaving-cloud-sql-how-we-moved-11-postgres-databases-onto-one-cloudnativepg-cluster-5b2c</guid>
      <description>&lt;p&gt;A practical playbook: one shared cluster, strict per-app isolation, a zero-disk copy pipeline, reversible cutovers, and a restore test that told us exactly how much data we could lose.&lt;br&gt;
For a long time our microservice databases lived on Google Cloud SQL. It worked. But every one of our apps ran on Kubernetes in a different cloud, which meant every single query crossed the public internet, depended on cross-cloud firewall rules, and broke in surprising ways the day we changed our egress IPs.&lt;/p&gt;

&lt;p&gt;So we brought the data home. Over about two weeks in September 2026, we moved eleven application databases (roughly 1.3 GB in total) off Cloud SQL and onto a single PostgreSQL 17 cluster run by CloudNativePG inside our production Kubernetes cluster.&lt;/p&gt;

&lt;p&gt;This post is the playbook we ended up with: the design decisions, the exact commands, the mistakes we nearly made, and what's still on our list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why move at all?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Three reasons, in order of how much they hurt:&lt;br&gt;
&lt;strong&gt;1. Keep data next to the apps.&lt;/strong&gt; Every consumer of these databases ran in the same Kubernetes environment. A database in another cloud meant internet latency on every query, plus a fragile dependency on firewall allow-lists. When we migrated some services to new egress IPs, parts of the cluster simply lost their route to Cloud SQL.&lt;br&gt;
&lt;strong&gt;2. One platform to run. **Storage, access, monitoring and deploys all go through Kubernetes, the same way as the applications. No separate console, no separate mental model.&lt;br&gt;
**3. Cost and consolidation.&lt;/strong&gt; One shared cluster instead of one managed instance per service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What CloudNativePG gives you&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;CloudNativePG (CNPG) is a Kubernetes operator for PostgreSQL. You declare a single Cluster resource and the operator creates and looks after everything else: the Postgres pods, persistent volumes, TLS certificates (auto-renewed), and a set of Services:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;cluster&amp;gt;-rw&lt;/code&gt;always points at the primary. This is what apps should use.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;cluster&amp;gt;-ro&lt;/code&gt; points at replicas only.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;cluster&amp;gt;-r&lt;/code&gt; points at any instance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can also ask CNPG to manage extra Services, such as a LoadBalancer for clients outside the cluster, so you never hand-edit them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The design decisions that mattered&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;1. One shared cluster, many isolated databases&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We chose a single cluster that holds every microservice database, instead of one cluster per service. Each application gets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;its own database,&lt;/li&gt;
&lt;li&gt;its own login role, which owns that database (so it has full DDL inside it and nothing outside it),&lt;/li&gt;
&lt;li&gt;and a single pg_hba rule that lets that role reach only that database.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing is shared between apps except the server itself.&lt;/p&gt;

&lt;p&gt;The app roles are plain &lt;code&gt;LOGIN&lt;/code&gt; roles: no &lt;code&gt;SUPERUSER&lt;/code&gt;, no &lt;code&gt;CREATEDB&lt;/code&gt;, no &lt;code&gt;CREATEROLE&lt;/code&gt;. For humans and tools like DBeaver there's a separate &lt;code&gt;db_admin&lt;/code&gt; role with &lt;code&gt;CREATEDB&lt;/code&gt;, &lt;code&gt;CREATEROLE&lt;/code&gt;, &lt;code&gt;pg_read_all_data&lt;/code&gt;, &lt;code&gt;pg_write_all_data&lt;/code&gt; and &lt;code&gt;pg_monitor&lt;/code&gt;. It still isn't a superuser, and it is only allowed in over TLS. The real &lt;code&gt;postgres&lt;/code&gt; superuser is reachable only via &lt;code&gt;kubectl exec&lt;/code&gt; into the pod (&lt;code&gt;enableSuperuserAccess: false&lt;/code&gt;).&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Isolation lives in pg_hba, and it's declarative&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The access rules sit right in the Cluster spec:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;spec:
  postgresql:
    pg_hba:
      - host survey_db      survey_user      all scram-sha-256
      - host payroll_db     payroll_user     all scram-sha-256
      - host finance_db     finance_user     all scram-sha-256
      - host scheduler_db   scheduler_user   all scram-sha-256  # reserved, db not created yet
      - hostssl all         db_admin         all scram-sha-256
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things we learned:&lt;/p&gt;

&lt;p&gt;A rule for a database or role that doesn't exist yet does nothing, so reserving lines ahead of time is harmless.&lt;br&gt;
Never add a blanket &lt;code&gt;host all all&lt;/code&gt; rule. One convenient line would quietly undo the isolation for every app.&lt;/p&gt;

&lt;p&gt;When you change pg_hba and apply the &lt;code&gt;Cluster&lt;/code&gt; object, CNPG reloads it without restarting Postgres.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Every database is added the same way&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;CNPG has a bootstrap import feature, but it only runs once, when a cluster is first created. Since we were adding databases one service at a time, we deliberately didn't use it. Every database goes through the same three steps instead:&lt;/p&gt;

&lt;p&gt;Create the role and an empty database.&lt;br&gt;
Load data with &lt;code&gt;pg_dump | psql&lt;/code&gt; straight from Cloud SQL.&lt;br&gt;
Switch the app.&lt;/p&gt;

&lt;p&gt;Boring and repeatable beats clever.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pin the image, and make storage outlive mistakes
The image is pinned to an exact version, &lt;code&gt;ghcr.io/cloudnative-pg/postgresql:17.6&lt;/code&gt;. Never a floating tag like :17, which can change under you on the next pod restart.
We created a custom StorageClass with &lt;code&gt;reclaimPolicy: Retain&lt;/code&gt;, so the data survives even if someone deletes a PersistentVolumeClaim.
Data and WAL get separate volumes (200 Gi and 50 Gi). The StorageClass allows expansion, so volumes can grow later (they can never shrink).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's a trimmed version of the cluster definition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: postgres
  namespace: service-db
spec:
  instances: 1
  imageName: ghcr.io/cloudnative-pg/postgresql:17.6
  enableSuperuserAccess: false

  storage:
    size: 200Gi
    storageClass: db-retain-fast
    resizeInUseVolumes: true
  walStorage:
    size: 50Gi
    storageClass: db-retain-fast

  resources:
    requests: { cpu: "2", memory: 4Gi }
    limits:   { cpu: "2", memory: 4Gi }

  postgresql:
    parameters:
      max_connections: "200"
      shared_buffers: 2GB
      effective_cache_size: 6GB
      maintenance_work_mem: 1GB
      log_connections: "on"
      log_disconnections: "on"
      log_min_duration_statement: "1000"   # log queries slower than 1s
    pg_hba:
      - host survey_db survey_user all scram-sha-256
      # ...one line per app...

  managed:
    services:
      additional:
        - selectorType: rw
          serviceTemplate:
            metadata:
              name: postgres-external
            spec:
              type: LoadBalancer
              # restrict who can reach it!
              loadBalancerSourceRanges:
                - 203.0.113.10/32


&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A note on memory: our first version set &lt;code&gt;shared_buffers&lt;/code&gt; equal to the memory request with a higher limit. That works until the node is short on memory and Postgres gets OOM-killed. Setting requests equal to limits (Guaranteed QoS) is the safer choice for a database, which is what the snippet above shows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Create the role, database and Secret&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We wrapped database creation in a small idempotent script, &lt;code&gt;create-db.sh &amp;lt;database&amp;gt; &amp;lt;role&amp;gt; &amp;lt;secret-name&amp;gt;&lt;/code&gt;. It:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;generates a random 64-hex-character password,&lt;/li&gt;
&lt;li&gt;stores it in a Kubernetes Secret (username, password),&lt;/li&gt;
&lt;li&gt;creates the role and the database owned by it,&lt;/li&gt;
&lt;li&gt;tests a real network login through the -rw Service with TLS.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because it's idempotent, re-running it is also the fix when a password drifts (more on that in troubleshooting).&lt;/p&gt;

&lt;p&gt;To read a password later, without ever pasting it into a ticket or chat:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kubectl -n service-db get secret &amp;lt;secret-name&amp;gt; \
  -o jsonpath='{.data.password}' | base64 -d
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2: Copy the data without touching disk&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the part I'm most happy with. The dump runs inside the Postgres pod itself, which already has pg_dump 17 and can reach Cloud SQL. It pipes straight into the new database, so nothing is written to disk, and passwords go in through stdin, never on the command line (where they'd show up in ps and shell history).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
K="kubectl -n service-db"
NPW=$($K get secret &amp;lt;secret-name&amp;gt; -o jsonpath='{.data.password}' | base64 -d)

printf '%s\n%s\n' '&amp;lt;cloudsql-password&amp;gt;' "$NPW" | \
$K exec -i postgres-1 -c postgres -- bash -c '
  read -r SRC; read -r DST; set -o pipefail
  PGPASSWORD="$SRC" pg_dump "host=&amp;lt;cloudsql-ip&amp;gt; user=&amp;lt;cloudsql-user&amp;gt; dbname=&amp;lt;source-db&amp;gt;" \
      --no-owner --no-acl --no-publications --no-subscriptions \
    | grep -v "^SET transaction_timeout" \
    | PGPASSWORD="$DST" psql "host=localhost user=&amp;lt;role&amp;gt; dbname=&amp;lt;db&amp;gt; sslmode=require" \
      -v ON_ERROR_STOP=1 -q --single-transaction \
  &amp;amp;&amp;amp; echo RESTORE OK'

unset NPW

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every flag is there for a reason:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--no-owner --no-acl&lt;/code&gt;: objects are created as the new app role, and Cloud SQL's own roles and grants (hello, &lt;code&gt;cloudsqlsuperuser&lt;/code&gt;) are dropped instead of failing the restore.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;grep -v "^SET transaction_timeout"&lt;/code&gt;: &lt;code&gt;pg_dump&lt;/code&gt; 17 writes this setting into the dump, and servers older than 17 reject it. Filtering it keeps the pipeline portable in both directions.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--single-transaction&lt;/code&gt; with &lt;code&gt;ON_ERROR_STOP=1&lt;/code&gt;: all or nothing. A failed restore leaves the database empty, so it's always safe to re-run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Then verify.&lt;/strong&gt; Compare count(*) for every table and the alembic_version row (or whatever your migration tool uses) between source and target. Don't trust "it finished without errors".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At cutover, re-copy with writers stopped.&lt;/strong&gt; The first copy is a rehearsal. For the real thing, stop every writer, then drop and reload with pg_dump --clean --if-exists and SET lock_timeout = '30s' so a forgotten idle session can't hang the whole operation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Switch the app (and keep the way back)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Our apps read their connection string from a DATABASE_URL in a Secret in their own namespace. The switch is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Save the current URL&lt;/strong&gt; somewhere private (a file with mode 0600). This is your rollback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Patch the app's Secret&lt;/strong&gt; with the new URL, keeping the app's URL scheme (&lt;code&gt;postgresql://&lt;/code&gt; vs &lt;code&gt;postgresql+psycopg://&lt;/code&gt; matters to some drivers):&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;inside the cluster: &lt;code&gt;postgresql://&amp;lt;role&amp;gt;:&amp;lt;pw&amp;gt;@postgres-rw.service-db.svc:5432/&amp;lt;db&amp;gt;?sslmode=require&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;outside the cluster: &lt;code&gt;postgresql://&amp;lt;role&amp;gt;:&amp;lt;pw&amp;gt;@&amp;lt;load-balancer-ip&amp;gt;:5432/&amp;lt;db&amp;gt;?sslmode=require&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restart:&lt;/strong&gt; &lt;code&gt;kubectl -n &amp;lt;ns&amp;gt; rollout restart deploy/&amp;lt;app&amp;gt; and wait on rollout status.&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify:&lt;/strong&gt; logs show a clean startup with no DB errors, and &lt;code&gt;pg_stat_activity&lt;/code&gt; shows the role connected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rollback if needed:&lt;/strong&gt; put the saved URL back and restart. We scripted this for the services we switched in bulk.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's a handy one-liner to see where an app currently points, with the password masked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kubectl -n &amp;lt;ns&amp;gt; get secret &amp;lt;app-secret&amp;gt; -o jsonpath='{.data.DATABASE_URL}' \
  | base64 -d | sed -E 's#://([^:]+):[^@]+@#://\1:***@#'

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A real example&lt;/p&gt;

&lt;p&gt;For our finance service, the whole run looked like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The pg_hba rule was already in place (reserved earlier).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;create-db.sh&lt;/code&gt; created the role, database and Secret, and tested login and DDL over TLS.&lt;/li&gt;
&lt;li&gt;We restored from Cloud SQL (PostgreSQL 16) into the new PostgreSQL 17 database. Every object ended up owned by the app role; row counts matched.&lt;/li&gt;
&lt;li&gt;The service runs in two Kubernetes clusters, so before switching the second one we launched a throwaway &lt;code&gt;postgres:17-alpine&lt;/code&gt; pod there and read the data through the LoadBalancer. That proved the network path before any real traffic depended on it.&lt;/li&gt;
&lt;li&gt;We patched &lt;code&gt;DATABASE_URL&lt;/code&gt; in both clusters and restarted. Migrations ran, both pods came up, "Application startup complete".&lt;/li&gt;
&lt;li&gt;The old URLs went into a &lt;code&gt;0600&lt;/code&gt; backup file, so rollback was one patch away.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One honest loose end from that run: the app had been talking to one Cloud SQL endpoint, but our dump account used a different one. If those turn out to be different instances, the data could differ. The lesson: confirm the source you dump from is the source the app actually writes to, before you decommission anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backups: configure them, then prove they work&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The migration isn't done until you can restore. We set up CNPG's backups to S3-compatible object storage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a daily base backup at 02:00 UTC via a &lt;code&gt;ScheduledBackup&lt;/code&gt;,&lt;/li&gt;
&lt;li&gt;continuous WAL archiving (gzip), so we can restore to any point in time since the first backup,&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;retentionPolicy: 30d&lt;/code&gt;, which lets CNPG itself delete base backups and WAL that are no longer needed. (If you also add a bucket lifecycle rule, make it comfortably longer than the retention window, or the bucket will delete WAL that CNPG still needs.)
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
  name: postgres-daily
  namespace: service-db
spec:
  schedule: "0 0 2 * * *"   # six fields: seconds first
  cluster:
    name: postgres
  backupOwnerReference: self


&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Taking an ad-hoc backup before risky maintenance is one small manifest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cat &amp;lt;&amp;lt;EOF | kubectl apply -f -
apiVersion: postgresql.cnpg.io/v1
kind: Backup
metadata: {name: postgres-manual-$(date +%Y%m%d%H%M), namespace: service-db}
spec: {cluster: {name: postgres}, method: barmanObjectStore}
EOF

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And to check health, the two conditions that matter are ContinuousArchiving=True and LastBackupSucceeded=True:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kubectl -n service-db get cluster postgres -o \
  jsonpath='{range .status.conditions[*]}{.type}={.status} {.message}{"\n"}{end}'

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The restore test&lt;/p&gt;

&lt;p&gt;CNPG always restores into a new cluster; it never overwrites the running one. You create a &lt;code&gt;Cluster&lt;/code&gt; with a &lt;code&gt;recovery&lt;/code&gt; bootstrap pointing at the original's backup location:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;spec:
  bootstrap:
    recovery:
      source: origin
      # recoveryTarget: {targetTime: "2026-10-01 09:30:00+00"}  # optional PITR
  externalClusters:
    - name: origin
      barmanObjectStore:
        serverName: postgres          # the ORIGINAL cluster's name
        destinationPath: s3://&amp;lt;bucket&amp;gt;/&amp;lt;prefix&amp;gt;/
        endpointURL: https://&amp;lt;s3-endpoint&amp;gt;
        s3Credentials:
          accessKeyId:     {name: backup-creds, key: ACCESS_KEY_ID}
          secretAccessKey: {name: backup-creds, key: ACCESS_SECRET_KEY}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the day we enabled backups, we restored into a temporary cluster. It took about three minutes and contained all eleven databases. For one database we checked every table: all 23 had identical row counts, migration version and size. Row checksums matched on 20 tables; the other 3 differed only because of live writes after the restore point.&lt;/p&gt;

&lt;p&gt;That gap was the most useful number we got out of the whole exercise. The newest restored change was about five minutes before the restore finished, which matches how often CNPG switches WAL segments. Our real-world recovery point is roughly five minutes of data. Now we know, instead of guessing.&lt;/p&gt;

&lt;p&gt;We also ran pg_dump -Fc for a single database from the restored cluster. That's our path for "restore just one app's database" on a shared cluster. Then we deleted the test cluster and its volume.&lt;/p&gt;

&lt;p&gt;One gotcha: if a restored cluster will itself back up to the same bucket, give it a different serverName or path, so it can't overwrite the original's archive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Troubleshooting cheat sheet&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The errors we actually hit, or expect to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;no pg_hba.conf entry for host ..., user X, database Y&lt;/code&gt;: no rule for that db/role pair, or a typo in the database name (&lt;code&gt;finance_db&lt;/code&gt; vs &lt;code&gt;financedb&lt;/code&gt; got us once). Check the live rules with &lt;code&gt;kubectl get cluster postgres -o jsonpath='{.spec.postgresql.pg_hba}'&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;password authentication failed&lt;/code&gt;: the app's &lt;code&gt;DATABASE_URL&lt;/code&gt; and the source-of-truth Secret disagree. Re-running the idempotent create script re-applies the Secret's password to the role.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;unrecognized configuration parameter "transaction_timeout"&lt;/code&gt;: a 
&lt;code&gt;pg_dump&lt;/code&gt; 17 dump loaded into an older server. Filter the SET line out.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;role "cloudsqlsuperuser" does not exist&lt;/code&gt;: dump with --no-owner --no-acl.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;too many clients already&lt;/code&gt;: usually a connection-pool leak or a scaled-out app. Find the culprit by grouping &lt;code&gt;pg_stat_activity&lt;/code&gt; by role; fix the pool before raising &lt;code&gt;max_connections&lt;/code&gt; (which needs a restart).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DROP&lt;/code&gt;/&lt;code&gt;ALTER&lt;/code&gt; hangs forever: a lock held by another session, very often an idle DBeaver tab. Find it with &lt;code&gt;pg_blocking_pids()&lt;/code&gt;, terminate it, and use &lt;code&gt;SET lock_timeout&lt;/code&gt; for maintenance.&lt;/li&gt;
&lt;li&gt;Pod stuck &lt;code&gt;Pending&lt;/code&gt;: nodes are full, or the volume is zonal and free capacity is in a different zone.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ContinuousArchiving=False&lt;/code&gt;: treat this as urgent. Unarchived WAL piles up on the WAL volume, and a full WAL volume stops the database.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The queries we keep coming back to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-- connections per database / role / client
select datname, usename, client_addr, state, count(*)
from pg_stat_activity where datname is not null
group by 1,2,3,4 order by 1;

-- database sizes
select datname, pg_size_pretty(pg_database_size(datname))
from pg_database order by pg_database_size(datname) desc;

-- who is blocking whom
select pid, usename, datname, wait_event_type, wait_event,
       pg_blocking_pids(pid), left(query, 80)
from pg_stat_activity where cardinality(pg_blocking_pids(pid)) &amp;gt; 0;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rules we wrote in capital letters&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never&lt;/strong&gt; delete the data or WAL PVCs, or the &lt;code&gt;Cluster&lt;/code&gt; object, while it holds data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never&lt;/strong&gt; add &lt;code&gt;host all all&lt;/code&gt; to pg_hba.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never&lt;/strong&gt; apply a manifest from git without diffing it against the live cluster first. A copy of our cluster definition on the main branch was once missing a pg_hba rule that existed live; applying it would have silently cut off a production service. &lt;code&gt;kubectl get cluster postgres -o yaml&lt;/code&gt; first, every time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never&lt;/strong&gt; apply a whole secrets file "just to update one". It can reset live passwords to older values.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What's still on the list&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A migration is a snapshot, not an ending. Here's what we'd tell anyone starting this today, most of which we're working through ourselves:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Plan for the Barman Cloud Plugin.&lt;/strong&gt; CNPG's built-in Barman object-store support is deprecated in favour of the Barman Cloud Plugin and is slated for removal in a future release. Migrate before you upgrade the operator, or backups can stop without anything obviously breaking. And repeat the restore test on a schedule (we're going quarterly).&lt;br&gt;
&lt;strong&gt;2. Add a replica.&lt;/strong&gt; One instance means a node failure or an upgrade takes every database down at once. Once backups are proven, &lt;code&gt;instances: 2&lt;/code&gt; or &lt;code&gt;3&lt;/code&gt; is a one-line change and a big win.&lt;br&gt;
&lt;strong&gt;3. Lock down the LoadBalancer.&lt;/strong&gt; If clients outside the cluster need access, use &lt;code&gt;loadBalancerSourceRanges&lt;/code&gt; so only known IPs can reach port 5432. Passwords alone shouldn't be your only wall.&lt;br&gt;
&lt;strong&gt;4. Keep secrets out of git.&lt;/strong&gt; Use Sealed Secrets or External Secrets, give backups their own bucket-scoped key, and rotate anything that ever lived in a repo or a chat message.&lt;br&gt;
&lt;strong&gt;5. Monitor before users tell you.&lt;/strong&gt; Enable the CNPG PodMonitor and Grafana dashboard, and alert on disk usage, connection count and cluster phase.&lt;br&gt;
&lt;strong&gt;6. Decommission the old system deliberately.&lt;/strong&gt; Check Cloud SQL for any remaining connections, take a final dump, and only then shut it down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrapping up&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The whole migration came down to a handful of habits: one boring, repeatable path for every database; isolation enforced in one declarative place; copies that are all-or-nothing and verified by counting rows; cutovers with a saved way back; and backups proven by an actual restore, with a measured recovery point.&lt;/p&gt;

&lt;p&gt;CloudNativePG made the Kubernetes side of this surprisingly pleasant. The operator handles the parts that used to be "managed" for us (certificates, services, failover-ready topology, backups), and everything else is YAML we can review in a pull request.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgsjxn2mzgozkowa71js8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgsjxn2mzgozkowa71js8.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>postgres</category>
      <category>cloudnative</category>
    </item>
  </channel>
</rss>
