<?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>Your Backups Are a Hypothesis: Restore-Testing CloudNativePG and Measuring Real RPO</title>
      <dc:creator>Muhammad Adnan Sarfraz</dc:creator>
      <pubDate>Fri, 23 Oct 2026 09:11:57 +0000</pubDate>
      <link>https://dev.to/muhammad_adnansarfraz_cd/your-backups-are-a-hypothesis-restore-testing-cloudnativepg-and-measuring-real-rpo-4a3n</link>
      <guid>https://dev.to/muhammad_adnansarfraz_cd/your-backups-are-a-hypothesis-restore-testing-cloudnativepg-and-measuring-real-rpo-4a3n</guid>
      <description>&lt;p&gt;&lt;em&gt;A backup you've never restored is a theory. Here's how we tested ours: the method, the numbers, and the one setting that could have quietly overwritten our entire archive.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://dev.to/muhammad_adnansarfraz_cd/leaving-cloud-sql-how-we-moved-11-postgres-databases-onto-one-cloudnativepg-cluster-5b2c"&gt;my previous post&lt;/a&gt; I wrote about moving eleven microservice databases off Google Cloud SQL and onto a single shared CloudNativePG cluster. Near the end, one paragraph covered the restore test we ran the day backups were switched on.&lt;/p&gt;

&lt;p&gt;That paragraph deserved its own article. The restore test turned out to be the most useful thing we did in the whole migration. It didn't just tell us that backups worked. It told us &lt;strong&gt;how much data we would actually lose&lt;/strong&gt; on a bad day, and that number was not the one anybody would have guessed from our backup schedule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Most teams have backups they've never restored
&lt;/h2&gt;

&lt;p&gt;If you ask a team "do you have backups?", the answer is almost always yes. Ask "when did you last restore one?" and the room goes quiet.&lt;/p&gt;

&lt;p&gt;That's not laziness. It's how the incentives line up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Configuring backups gives you a green checkmark.&lt;/strong&gt; There's a dashboard, a cron schedule, a bucket filling up. It &lt;em&gt;looks&lt;/em&gt; finished.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restoring is awkward.&lt;/strong&gt; It needs spare capacity, a place to put the copy, and someone to decide what "it worked" means. None of that is on anyone's sprint board.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing forces the question.&lt;/strong&gt; Until the day you need it, a broken backup and a working one look exactly the same.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And backups break in quiet ways: credentials get rotated, a bucket lifecycle rule deletes WAL the backup tool still needs, the archive command fails while the database runs happily, or the backup contains everything except the one database somebody added last month.&lt;/p&gt;

&lt;p&gt;On a managed service like Cloud SQL, the provider absorbs a lot of this for you. When you bring Postgres onto Kubernetes, &lt;strong&gt;you become the provider&lt;/strong&gt;. So we made a rule for ourselves: backups don't count as "enabled" until a restore has passed.&lt;/p&gt;

&lt;h2&gt;
  
  
  How CloudNativePG recovery actually works
&lt;/h2&gt;

&lt;p&gt;The first thing to understand is that CNPG &lt;strong&gt;never restores over a running cluster&lt;/strong&gt;. Recovery is always a &lt;em&gt;bootstrap method&lt;/em&gt; for a brand-new &lt;code&gt;Cluster&lt;/code&gt;. You don't "roll back" &lt;code&gt;postgres&lt;/code&gt;. You create &lt;code&gt;postgres-restore&lt;/code&gt; (or whatever you call it) and tell it to start life from someone else's backups.&lt;/p&gt;

&lt;p&gt;This is a feature, not a limitation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The original keeps running.&lt;/strong&gt; A restore test needs no downtime and doesn't touch production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A bad restore can't make things worse.&lt;/strong&gt; If you pick the wrong timestamp, you delete the new cluster and try again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cutover is a separate, explicit step.&lt;/strong&gt; When you do restore for real, you check the new cluster's data first, then point apps at its &lt;code&gt;-rw&lt;/code&gt; Service.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Under the hood, CNPG pulls the most suitable base backup from object storage, then replays archived WAL on top of it until it reaches the end of the archive (or the point in time you asked for). Then it promotes the new cluster and starts serving.&lt;/p&gt;

&lt;p&gt;The manifest has two parts: a &lt;code&gt;bootstrap.recovery&lt;/code&gt; section that says "start from a backup", and an &lt;code&gt;externalClusters&lt;/code&gt; entry that says where that backup lives.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql.cnpg.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Cluster&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;restore-test&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service-db&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;instances&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;imageName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/cloudnative-pg/postgresql:17.6&lt;/span&gt;   &lt;span class="c1"&gt;# same as the original&lt;/span&gt;

  &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10Gi&lt;/span&gt;
    &lt;span class="na"&gt;storageClass&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;your-storage-class&amp;gt;&lt;/span&gt;

  &lt;span class="na"&gt;bootstrap&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;recovery&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;origin&lt;/span&gt;
      &lt;span class="c1"&gt;# recoveryTarget:&lt;/span&gt;
      &lt;span class="c1"&gt;#   targetTime: "2026-10-01 09:30:00+00"    # optional point-in-time&lt;/span&gt;

  &lt;span class="na"&gt;externalClusters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;origin&lt;/span&gt;
      &lt;span class="na"&gt;barmanObjectStore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;serverName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;                       &lt;span class="c1"&gt;# the ORIGINAL cluster's name&lt;/span&gt;
        &lt;span class="na"&gt;destinationPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;s3://&amp;lt;bucket&amp;gt;/&amp;lt;prefix&amp;gt;/&lt;/span&gt;
        &lt;span class="na"&gt;endpointURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://&amp;lt;s3-endpoint&amp;gt;&lt;/span&gt;
        &lt;span class="na"&gt;s3Credentials&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;accessKeyId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;     &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;backup-creds&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;ACCESS_KEY_ID&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;secretAccessKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;backup-creds&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;ACCESS_SECRET_KEY&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;wal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;maxParallel&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8&lt;/span&gt;

  &lt;span class="c1"&gt;# Deliberately NO spec.backup section. See "The serverName trap" below.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;serverName&lt;/code&gt; is the original cluster's name.&lt;/strong&gt; CNPG stores each cluster's backups under &lt;code&gt;&amp;lt;destinationPath&amp;gt;/&amp;lt;serverName&amp;gt;/&lt;/code&gt;. The restore needs to read from the original's folder, so this must match the original, not the new cluster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The data volume can be much smaller than production's.&lt;/strong&gt; Our production volume is 200 Gi, but the data inside is about 1.3 GB. A 10 Gi volume was plenty for the test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin the same image.&lt;/strong&gt; Restoring into a different major version won't work, and a different minor version adds a variable you don't need in a test.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Apply it and watch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; service-db apply &lt;span class="nt"&gt;-f&lt;/span&gt; restore-test.yaml
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; service-db get cluster restore-test &lt;span class="nt"&gt;-w&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; service-db logs &lt;span class="nt"&gt;-f&lt;/span&gt; restore-test-1-full-recovery-&amp;lt;&lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;   &lt;span class="c"&gt;# the recovery job&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note on versions:&lt;/strong&gt; CNPG's built-in Barman Cloud support (&lt;code&gt;barmanObjectStore&lt;/code&gt;) is deprecated in favour of the Barman Cloud Plugin. The recovery concepts in this post are the same either way; only the configuration shape changes. If you're on the plugin, the &lt;code&gt;externalClusters&lt;/code&gt; entry points at the plugin and an &lt;code&gt;ObjectStore&lt;/code&gt; resource instead.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The test, and what "it worked" means
&lt;/h2&gt;

&lt;p&gt;Our restore took &lt;strong&gt;about three minutes&lt;/strong&gt; for roughly 1.3 GB across eleven databases. The new cluster reported healthy, and &lt;code&gt;\l+&lt;/code&gt; listed all eleven databases.&lt;/p&gt;

&lt;p&gt;That's where most restore tests stop. "It came up and the databases are there" is a much weaker claim than it sounds. A restore can come up healthy and still be missing data, still be hours stale, or still have tables silently truncated. So we defined "passed" more strictly, and checked one database (&lt;code&gt;survey_db&lt;/code&gt;, 23 tables) thoroughly against production:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Same set of tables.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Same row count for every table.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same migration version&lt;/strong&gt; (the &lt;code&gt;alembic_version&lt;/code&gt; row).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same database size&lt;/strong&gt;, roughly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same row contents&lt;/strong&gt;, checked by checksum.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first four are quick. The fifth is the one that actually proves the data is right.&lt;/p&gt;

&lt;h3&gt;
  
  
  The checksum comparison
&lt;/h3&gt;

&lt;p&gt;Row counts can match while contents differ: an &lt;code&gt;UPDATE&lt;/code&gt; changes data without changing the count. So for each table we computed a checksum over every row, in a fixed order, on both sides:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- run the same query against production and the restored cluster&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string_agg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'|'&lt;/span&gt; &lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;checksum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                       &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;row_count&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt;   &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Casting the whole row to text (&lt;code&gt;t::text&lt;/code&gt;) catches a change in any column, and the &lt;code&gt;order by&lt;/code&gt; makes the result independent of physical row order, which will differ between the two clusters. For large tables, order by the primary key instead; it's much cheaper than sorting on the full row text.&lt;/p&gt;

&lt;p&gt;To run it for every table without writing 23 queries, generate them from the catalog:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s1"&gt;'select %L as tbl, count(*) as rows, md5(string_agg(t::text, &lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt;|&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt; order by t::text)) as checksum from %I.%I t;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table_schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tables&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;table_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'BASE TABLE'&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save the output, run it with &lt;code&gt;psql -At -f checks.sql&lt;/code&gt; against each side, and &lt;code&gt;diff&lt;/code&gt; the two files. Every line that differs is a table to look at.&lt;/p&gt;

&lt;h3&gt;
  
  
  What we found: 20 matched, 3 didn't
&lt;/h3&gt;

&lt;p&gt;All 23 tables had identical row counts, the migration version matched, and the sizes matched. &lt;strong&gt;Twenty of the 23 checksums matched exactly. Three did not.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's the moment where a restore test either earns its keep or gets hand-waved. "Close enough" is the wrong answer. So we looked at the three tables.&lt;/p&gt;

&lt;p&gt;All three were tables the application writes to continuously. In each one, the rows that differed had been changed &lt;strong&gt;after&lt;/strong&gt; the newest change in the restored copy. Production had simply kept moving after the point the restore stopped at. Every row older than that point matched.&lt;/p&gt;

&lt;p&gt;That's the expected result, and it's exactly the signal you want: &lt;strong&gt;the backup is consistent up to a point in time, and the only differences are writes that happened after it.&lt;/strong&gt; If a difference had been in an &lt;em&gt;old&lt;/em&gt; row, or a table that nobody writes to, that would have been a real problem.&lt;/p&gt;

&lt;p&gt;Which raises the obvious question: &lt;em&gt;how far&lt;/em&gt; after?&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring your real RPO
&lt;/h2&gt;

&lt;p&gt;RPO, the recovery point objective, is how much data you can afford to lose: the gap between the last change you can recover and the moment disaster strikes.&lt;/p&gt;

&lt;p&gt;Teams usually quote their backup schedule as their RPO. "We back up daily" sounds like "we lose at most a day". With continuous WAL archiving, people go the other way and say "we can restore to any point in time", which sounds like zero.&lt;/p&gt;

&lt;p&gt;Neither is your real RPO. Your real RPO is &lt;strong&gt;the gap between the newest change you can actually restore and the newest change that existed when you lost the primary&lt;/strong&gt;. You can measure it directly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: find the newest change in the restored cluster
&lt;/h3&gt;

&lt;p&gt;Postgres records the last transaction it replayed during recovery, and after promotion that value stays frozen at the end of recovery:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- on the restored cluster&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;pg_last_xact_replay_timestamp&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The recovery log says the same thing in plain words. Look for a line like &lt;code&gt;last completed transaction was at log time ...&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; service-db logs restore-test-1-full-recovery-&amp;lt;&lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"last completed transaction"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can cross-check from the data itself if your tables carry timestamps, e.g. &lt;code&gt;select max(updated_at) from &amp;lt;busy_table&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: compare with when you started the restore
&lt;/h3&gt;

&lt;p&gt;For our test:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Newest change in the restored copy:&lt;/strong&gt; 13:43:52 UTC&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restore finished (and production was still writing):&lt;/strong&gt; 13:48 UTC&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Production was busy the whole time, so the roughly four minutes of writes between those two timestamps were exactly the changes behind the three mismatched checksums. Had the primary died at 13:48, those writes would have been gone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: understand where the number comes from
&lt;/h3&gt;

&lt;p&gt;That gap isn't random. Postgres archives WAL one &lt;strong&gt;segment&lt;/strong&gt; (16 MB by default) at a time, and only a &lt;em&gt;finished&lt;/em&gt; segment gets uploaded. Under a light write load, a segment can take a long time to fill. To stop it sitting there forever, Postgres has &lt;code&gt;archive_timeout&lt;/code&gt;, which forces a switch to a new segment after a set time. &lt;strong&gt;CNPG sets it to 5 minutes by default.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So with continuous archiving and no replicas, the worst case is roughly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;RPO ≈ &lt;code&gt;archive_timeout&lt;/code&gt; + upload time ≈ 5 minutes&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Our measured four-ish minutes sits right inside that. Now we can say "we lose at most about five minutes of writes" and point at evidence, instead of "we have daily backups" and hope.&lt;/p&gt;

&lt;h3&gt;
  
  
  Making the number smaller
&lt;/h3&gt;

&lt;p&gt;Once you know your RPO, you can decide whether it's acceptable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lower &lt;code&gt;archive_timeout&lt;/code&gt;&lt;/strong&gt; (e.g. to &lt;code&gt;1min&lt;/code&gt;). More, smaller uploads to object storage; WAL segments are compressed, so mostly-empty segments are cheap, but the request count goes up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add replicas.&lt;/strong&gt; With &lt;code&gt;instances: 2&lt;/code&gt; or more, a node failure is handled by failover to a replica that already has almost everything. For near-zero loss on failover, CNPG supports synchronous replication.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accept it.&lt;/strong&gt; For many internal services, five minutes is fine. The point is that it's now a decision, not an accident.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Replicas protect you from losing a node; object-storage backups protect you from losing the cluster, the namespace or the data itself (a bad migration, a &lt;code&gt;DELETE&lt;/code&gt; without a &lt;code&gt;WHERE&lt;/code&gt;). You want both, and each has its own RPO.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;serverName&lt;/code&gt; trap: how a restore can overwrite your archive
&lt;/h2&gt;

&lt;p&gt;This is the gotcha that's easy to miss, because it doesn't hurt you during the test. It hurts you after a real restore.&lt;/p&gt;

&lt;p&gt;Recall that CNPG writes backups to &lt;code&gt;&amp;lt;destinationPath&amp;gt;/&amp;lt;serverName&amp;gt;/&lt;/code&gt;, and &lt;code&gt;serverName&lt;/code&gt; defaults to &lt;strong&gt;the cluster's name&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Now picture a real incident. You restore production into a new cluster. To keep things simple, you copy the original manifest, which includes the original &lt;code&gt;spec.backup&lt;/code&gt; section with the same bucket and path. Maybe you even name the new cluster &lt;code&gt;postgres&lt;/code&gt; again, because that's what all the apps' connection strings expect.&lt;/p&gt;

&lt;p&gt;The restored cluster comes up and starts archiving WAL and taking scheduled backups, into &lt;strong&gt;the same folder as the original&lt;/strong&gt;. It began a new timeline from an older point, and now it's writing into the archive you might still need: the one containing the original's history, including whatever you were trying to recover from.&lt;/p&gt;

&lt;p&gt;Recent CNPG versions have a safety net: before archiving its first WAL file, a cluster checks that the target archive is empty and refuses to write if it isn't. That's good, but look at what it means in practice: your freshly restored production cluster comes up with &lt;code&gt;ContinuousArchiving=False&lt;/code&gt;, mid-incident, and someone under pressure "fixes" it. Don't build your process on the safety net.&lt;/p&gt;

&lt;p&gt;The rules we follow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Restore tests have no &lt;code&gt;spec.backup&lt;/code&gt; section at all.&lt;/strong&gt; A test cluster has nothing to archive. (Ours didn't, which is why this never bit us.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A restored cluster that will become production gets its own backup location.&lt;/strong&gt; Either a different &lt;code&gt;serverName&lt;/code&gt; or a different &lt;code&gt;destinationPath&lt;/code&gt;:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;   &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
     &lt;span class="na"&gt;backup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
       &lt;span class="na"&gt;barmanObjectStore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
         &lt;span class="na"&gt;destinationPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;s3://&amp;lt;bucket&amp;gt;/&amp;lt;prefix&amp;gt;/&lt;/span&gt;
         &lt;span class="na"&gt;serverName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres-restored-20261001&lt;/span&gt;   &lt;span class="c1"&gt;# NOT the original's name&lt;/span&gt;
         &lt;span class="c1"&gt;# ...credentials as before...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;In &lt;code&gt;externalClusters&lt;/code&gt;, &lt;code&gt;serverName&lt;/code&gt; is the original's name.&lt;/strong&gt; In &lt;code&gt;spec.backup&lt;/code&gt;, it's always a new name. Same field, opposite rule, depending on whether you're reading or writing. That asymmetry is exactly why it's easy to get wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never put a short lifecycle rule on the backup bucket.&lt;/strong&gt; Let CNPG's &lt;code&gt;retentionPolicy&lt;/code&gt; manage deletion, and keep any bucket rule well beyond it, so old archives are still there when you need to restore from before an incident.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Restoring one database out of a shared cluster
&lt;/h2&gt;

&lt;p&gt;Our cluster holds eleven databases for eleven different applications. That creates a new kind of problem: what happens when &lt;strong&gt;one&lt;/strong&gt; app has a bad day?&lt;/p&gt;

&lt;p&gt;Say a migration in the finance service drops a column it shouldn't have. The other ten apps are fine. You definitely don't want to roll the whole cluster back an hour and lose everyone else's writes.&lt;/p&gt;

&lt;p&gt;CNPG restores whole clusters, not single databases. But a whole-cluster restore into a &lt;em&gt;temporary&lt;/em&gt; cluster gives you everything you need. The recipe:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Restore to just before the incident&lt;/strong&gt; into a temporary cluster, using a point-in-time target:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;bootstrap&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;recovery&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;origin&lt;/span&gt;
      &lt;span class="na"&gt;recoveryTarget&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;targetTime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-10-01&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;09:29:00+00"&lt;/span&gt;   &lt;span class="c1"&gt;# just before the bad migration&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Dump just the one database&lt;/strong&gt; from the temporary cluster, in custom format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; service-db &lt;span class="nb"&gt;exec &lt;/span&gt;restore-test-1 &lt;span class="nt"&gt;-c&lt;/span&gt; postgres &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  pg_dump &lt;span class="nt"&gt;-Fc&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; finance_db &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; finance_db.dump
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In our test, dumping one database from the restored cluster this way produced a 13 MB file with all 23 tables. That's the proof that the single-database path works, not just the whole-cluster one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Restore it into production next to the live one&lt;/strong&gt;, not on top of it. Create an empty &lt;code&gt;finance_db_restored&lt;/code&gt; owned by the app's role, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; service-db &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; postgres-1 &lt;span class="nt"&gt;-c&lt;/span&gt; postgres &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  pg_restore &lt;span class="nt"&gt;--no-owner&lt;/span&gt; &lt;span class="nt"&gt;--role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;finance_user &lt;span class="nt"&gt;-d&lt;/span&gt; finance_db_restored &amp;lt; finance_db.dump
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Decide how to bring the data back.&lt;/strong&gt; You now have the damaged database and a clean copy side by side, which gives you options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Copy back only what was lost&lt;/strong&gt;, for example the dropped column's data or a set of deleted rows, and keep everything written since. Usually the best outcome.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swap databases entirely:&lt;/strong&gt; stop the app's writers, rename &lt;code&gt;finance_db&lt;/code&gt; to &lt;code&gt;finance_db_broken&lt;/code&gt; and &lt;code&gt;finance_db_restored&lt;/code&gt; to &lt;code&gt;finance_db&lt;/code&gt;, then restart. Simple, but you lose that app's writes since the restore point.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;5. Delete the temporary cluster&lt;/strong&gt; and its volume when you're done.&lt;/p&gt;

&lt;p&gt;The other ten applications never notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it a habit
&lt;/h2&gt;

&lt;p&gt;One passing restore test is a snapshot. Backups break over time, and you want to find out on a quiet Tuesday rather than during an outage. What we settled on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repeat the test on a schedule.&lt;/strong&gt; We're doing it quarterly, and also before any operator upgrade (the move to the Barman Cloud Plugin in particular).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write down the numbers each time:&lt;/strong&gt; restore duration, measured RPO, tables checked, checksums matched. A restore that took 3 minutes last quarter and 40 minutes this quarter is telling you something.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch the two conditions that matter&lt;/strong&gt;, continuously, not just on test day:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; service-db get cluster postgres &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{range .status.conditions[*]}{.type}={.status} {.message}{"\n"}{end}'&lt;/span&gt;
  &lt;span class="c"&gt;# ContinuousArchiving=True and LastBackupSucceeded=True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and alert when either turns &lt;code&gt;False&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Clean up.&lt;/strong&gt; Delete the test cluster and its volume afterwards, especially if your storage class uses &lt;code&gt;reclaimPolicy: Retain&lt;/code&gt;, where deleting the PVC leaves the underlying disk behind.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The checklist
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Create a recovery cluster from the original's &lt;code&gt;serverName&lt;/code&gt;, with &lt;strong&gt;no&lt;/strong&gt; &lt;code&gt;spec.backup&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Wait for healthy; confirm every database is there.&lt;/li&gt;
&lt;li&gt;Compare tables, row counts and migration versions against production.&lt;/li&gt;
&lt;li&gt;Checksum every table; explain every mismatch.&lt;/li&gt;
&lt;li&gt;Read &lt;code&gt;pg_last_xact_replay_timestamp()&lt;/code&gt; and record your real RPO.&lt;/li&gt;
&lt;li&gt;Dump one database from the restored cluster to prove single-database recovery.&lt;/li&gt;
&lt;li&gt;Delete the test cluster and its volume.&lt;/li&gt;
&lt;li&gt;Write down the numbers.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Before the test, our backup story was "daily backups plus continuous WAL archiving, 30-day retention". All true, and none of it answered the question you actually care about during an incident.&lt;/p&gt;

&lt;p&gt;After the test, we could say: &lt;strong&gt;a full restore takes about three minutes, we'd lose at most about five minutes of writes, we know where that number comes from, and we can recover a single application's database without touching the others.&lt;/strong&gt; That's a very different sentence to say to your team.&lt;/p&gt;

&lt;p&gt;If you haven't restored your backups lately, pick a quiet afternoon this week and do it. The worst outcome is that you find a problem while it's still cheap.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is part two of a series on moving Postgres from Cloud SQL to CloudNativePG. Part one covers &lt;a href="https://dev.to/muhammad_adnansarfraz_cd/leaving-cloud-sql-how-we-moved-11-postgres-databases-onto-one-cloudnativepg-cluster-5b2c"&gt;the migration itself&lt;/a&gt;: shared-cluster design, the zero-disk copy pipeline and reversible cutovers.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudnative</category>
      <category>postgres</category>
      <category>devops</category>
    </item>
    <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 yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;postgresql&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;pg_hba&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;host survey_db      survey_user      all scram-sha-256&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;host payroll_db     payroll_user     all scram-sha-256&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;host finance_db     finance_user     all scram-sha-256&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;host scheduler_db   scheduler_user   all scram-sha-256&lt;/span&gt;  &lt;span class="c1"&gt;# reserved, db not created yet&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;hostssl all         db_admin         all scram-sha-256&lt;/span&gt;
&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 yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql.cnpg.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Cluster&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service-db&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;instances&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;imageName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/cloudnative-pg/postgresql:17.6&lt;/span&gt;
  &lt;span class="na"&gt;enableSuperuserAccess&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

  &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;200Gi&lt;/span&gt;
    &lt;span class="na"&gt;storageClass&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db-retain-fast&lt;/span&gt;
    &lt;span class="na"&gt;resizeInUseVolumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;walStorage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;50Gi&lt;/span&gt;
    &lt;span class="na"&gt;storageClass&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db-retain-fast&lt;/span&gt;

  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;4Gi&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;4Gi&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;

  &lt;span class="na"&gt;postgresql&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;max_connections&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;200"&lt;/span&gt;
      &lt;span class="na"&gt;shared_buffers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2GB&lt;/span&gt;
      &lt;span class="na"&gt;effective_cache_size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;6GB&lt;/span&gt;
      &lt;span class="na"&gt;maintenance_work_mem&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1GB&lt;/span&gt;
      &lt;span class="na"&gt;log_connections&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;on"&lt;/span&gt;
      &lt;span class="na"&gt;log_disconnections&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;on"&lt;/span&gt;
      &lt;span class="na"&gt;log_min_duration_statement&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1000"&lt;/span&gt;   &lt;span class="c1"&gt;# log queries slower than 1s&lt;/span&gt;
    &lt;span class="na"&gt;pg_hba&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;host survey_db survey_user all scram-sha-256&lt;/span&gt;
      &lt;span class="c1"&gt;# ...one line per app...&lt;/span&gt;

  &lt;span class="na"&gt;managed&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;additional&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;selectorType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rw&lt;/span&gt;
          &lt;span class="na"&gt;serviceTemplate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres-external&lt;/span&gt;
            &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;LoadBalancer&lt;/span&gt;
              &lt;span class="c1"&gt;# restrict who can reach it!&lt;/span&gt;
              &lt;span class="na"&gt;loadBalancerSourceRanges&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;203.0.113.10/32&lt;/span&gt;


&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 shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; service-db get secret &amp;lt;secret-name&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.data.password}'&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;
&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 shell"&gt;&lt;code&gt;
&lt;span class="nv"&gt;K&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"kubectl -n service-db"&lt;/span&gt;
&lt;span class="nv"&gt;NPW&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nv"&gt;$K&lt;/span&gt; get secret &amp;lt;secret-name&amp;gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.data.password}'&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n%s\n'&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;cloudsql-password&amp;gt;'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NPW&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nv"&gt;$K&lt;/span&gt; &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; postgres-1 &lt;span class="nt"&gt;-c&lt;/span&gt; postgres &lt;span class="nt"&gt;--&lt;/span&gt; bash &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'
  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'&lt;/span&gt;

&lt;span class="nb"&gt;unset &lt;/span&gt;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 shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; &amp;lt;ns&amp;gt; get secret &amp;lt;app-secret&amp;gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.data.DATABASE_URL}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'s#://([^:]+):[^@]+@#://\1:***@#'&lt;/span&gt;

&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 yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql.cnpg.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ScheduledBackup&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres-daily&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service-db&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;2&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;   &lt;span class="c1"&gt;# six fields: seconds first&lt;/span&gt;
  &lt;span class="na"&gt;cluster&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
  &lt;span class="na"&gt;backupOwnerReference&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;self&lt;/span&gt;


&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 shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt; | kubectl apply -f -
apiVersion: postgresql.cnpg.io/v1
kind: Backup
metadata: {name: postgres-manual-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d%H%M&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;, namespace: service-db}
spec: {cluster: {name: postgres}, method: barmanObjectStore}
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&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 shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; service-db get cluster postgres &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{range .status.conditions[*]}{.type}={.status} {.message}{"\n"}{end}'&lt;/span&gt;

&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 yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;bootstrap&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;recovery&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;origin&lt;/span&gt;
      &lt;span class="c1"&gt;# recoveryTarget: {targetTime: "2026-10-01 09:30:00+00"}  # optional PITR&lt;/span&gt;
  &lt;span class="na"&gt;externalClusters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;origin&lt;/span&gt;
      &lt;span class="na"&gt;barmanObjectStore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;serverName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;          &lt;span class="c1"&gt;# the ORIGINAL cluster's name&lt;/span&gt;
        &lt;span class="na"&gt;destinationPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;s3://&amp;lt;bucket&amp;gt;/&amp;lt;prefix&amp;gt;/&lt;/span&gt;
        &lt;span class="na"&gt;endpointURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://&amp;lt;s3-endpoint&amp;gt;&lt;/span&gt;
        &lt;span class="na"&gt;s3Credentials&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;accessKeyId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;     &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;backup-creds&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;ACCESS_KEY_ID&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;secretAccessKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;backup-creds&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;ACCESS_SECRET_KEY&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&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 sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- connections per database / role / client&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;datname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;usename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client_addr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;datname&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
&lt;span class="k"&gt;group&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- database sizes&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;datname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_database_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;datname&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_database&lt;/span&gt; &lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;pg_database_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;datname&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- who is blocking whom&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;usename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;datname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wait_event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wait_event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_blocking_pids&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="k"&gt;cardinality&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_blocking_pids&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&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>
