<?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: Vlad Tkachenko</title>
    <description>The latest articles on DEV Community by Vlad Tkachenko (@vladtkdev).</description>
    <link>https://dev.to/vladtkdev</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%2F4169760%2F0eef5a71-9824-4b4d-bc29-3b66747a646b.jpg</url>
      <title>DEV Community: Vlad Tkachenko</title>
      <link>https://dev.to/vladtkdev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vladtkdev"/>
    <language>en</language>
    <item>
      <title>Five things restore-testing Supabase dumps turned up</title>
      <dc:creator>Vlad Tkachenko</dc:creator>
      <pubDate>Thu, 15 Oct 2026 07:36:00 +0000</pubDate>
      <link>https://dev.to/vladtkdev/five-things-restore-testing-supabase-dumps-turned-up-22lh</link>
      <guid>https://dev.to/vladtkdev/five-things-restore-testing-supabase-dumps-turned-up-22lh</guid>
      <description>&lt;p&gt;We spent the first week of October restore-testing backups made with the Supabase CLI: the three files from Supabase's backup and restore guide, replayed with the &lt;code&gt;psql&lt;/code&gt; command from the same guide into a fresh Supabase database. The goal was a GitHub Action that does that restore on every run, so we needed to know every way it fails.&lt;/p&gt;

&lt;p&gt;Some of it fails loudly, which is fine. The parts worth writing down are the ones that finish without an error and leave you with less than you think.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;These are the commands from Supabase's guide, unchanged:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;supabase db dump &lt;span class="nt"&gt;--db-url&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DB_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; roles.sql &lt;span class="nt"&gt;--role-only&lt;/span&gt;
supabase db dump &lt;span class="nt"&gt;--db-url&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DB_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; schema.sql
supabase db dump &lt;span class="nt"&gt;--db-url&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DB_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; data.sql &lt;span class="nt"&gt;--use-copy&lt;/span&gt; &lt;span class="nt"&gt;--data-only&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-x&lt;/span&gt; &lt;span class="s2"&gt;"storage.buckets_vectors"&lt;/span&gt; &lt;span class="nt"&gt;-x&lt;/span&gt; &lt;span class="s2"&gt;"storage.vector_indexes"&lt;/span&gt;

psql &lt;span class="nt"&gt;--single-transaction&lt;/span&gt; &lt;span class="nt"&gt;--variable&lt;/span&gt; &lt;span class="nv"&gt;ON_ERROR_STOP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--file&lt;/span&gt; roles.sql &lt;span class="nt"&gt;--file&lt;/span&gt; schema.sql &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--command&lt;/span&gt; &lt;span class="s1"&gt;'SET session_replication_role = replica'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--file&lt;/span&gt; data.sql &lt;span class="nt"&gt;--dbname&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TARGET_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The target was a local Supabase from &lt;code&gt;supabase start&lt;/code&gt;, on the same Postgres major version as the source. A plain Postgres container is not enough: the dump refers to the &lt;code&gt;auth&lt;/code&gt; schema and to roles like &lt;code&gt;authenticated&lt;/code&gt; that only a Supabase database has.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A cut-off dump can restore with exit code 0
&lt;/h2&gt;

&lt;p&gt;This is the one that matters most, and it has nothing to do with Supabase.&lt;/p&gt;

&lt;p&gt;On 27 September we dumped a database of two tables, 5,000 rows and 300 rows, cut the data file at three points and replayed each with &lt;code&gt;--single-transaction&lt;/code&gt; and &lt;code&gt;ON_ERROR_STOP=1&lt;/code&gt;, which are there to stop at the first problem and roll everything back.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cut in the middle of a value: &lt;code&gt;psql&lt;/code&gt; stopped with an error and wrote nothing.&lt;/li&gt;
&lt;li&gt;Cut inside the last column of a row: exit code 0. The first table came back with 2,596 of its 5,000 rows and the second came back empty.&lt;/li&gt;
&lt;li&gt;Cut exactly between the two tables: exit code 0, first table complete, second empty.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each table's rows sit in one &lt;code&gt;COPY&lt;/code&gt; block that ends with a line holding only &lt;code&gt;\.&lt;/code&gt;. When the file runs out first, &lt;code&gt;psql&lt;/code&gt; takes the end of the file as the end of the block and carries on.&lt;/p&gt;

&lt;p&gt;The cheap check is the line &lt;code&gt;pg_dump&lt;/code&gt; writes at the very end of a complete file:&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;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'PostgreSQL database dump complete'&lt;/span&gt; data.sql   &lt;span class="c"&gt;# 1 is good, 0 means truncated&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only &lt;code&gt;data.sql&lt;/code&gt; keeps it. The CLI strips comments out of the schema file (CLI 2.111), so search the data file.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Your grep for users is probably wrong
&lt;/h2&gt;

&lt;p&gt;The second check most people write is whether the accounts made it into the dump. Our first version searched for &lt;code&gt;COPY auth.users&lt;/code&gt;, and it would have failed on every real dump, because the CLI quotes every identifier:&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;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'COPY "auth"."users"'&lt;/span&gt; data.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth getting right, because &lt;code&gt;supabase db dump&lt;/code&gt; without &lt;code&gt;--data-only&lt;/code&gt; writes no rows at all and leaves &lt;code&gt;auth&lt;/code&gt; out entirely. It restores cleanly. You find out at the login page.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. pg_dump 17 against a Postgres 15 project
&lt;/h2&gt;

&lt;p&gt;With no &lt;code&gt;supabase/config.toml&lt;/code&gt; next to it (the usual case in a repository that only holds a backup workflow), the CLI dumps with &lt;code&gt;pg_dump&lt;/code&gt; 17 whatever your project runs. The backup succeeds. The data file then carries this near its top:&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;SET&lt;/span&gt; &lt;span class="n"&gt;transaction_timeout&lt;/span&gt; &lt;span class="o"&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;Postgres 15 has no such setting, and with &lt;code&gt;ON_ERROR_STOP=1&lt;/code&gt; the replay stops on that line in any Postgres 15 database, including the project the file came from. We reproduced it on 4 October with &lt;code&gt;pg_dump&lt;/code&gt; 17.11 and Postgres 15.19.&lt;/p&gt;

&lt;p&gt;If your project is on 15, one line in the job's environment makes the CLI use the matching client:&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;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;SUPABASE_DB_MAJOR_VERSION&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;15'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. roles.sql stops twice on Postgres 17
&lt;/h2&gt;

&lt;p&gt;Replaying the files of two Postgres 17 databases, one of them a hosted Supabase project, &lt;code&gt;roles.sql&lt;/code&gt; stopped on two lines that Supabase's guide does not mention:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR: "supabase_admin" is a reserved role, only superusers can modify it
ERROR: permission denied for parameter log_min_messages
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first is on a line starting &lt;code&gt;ALTER ROLE "supabase_admin"&lt;/code&gt;, the second on one starting &lt;code&gt;GRANT SET ON PARAMETER&lt;/code&gt;. Both configure roles Supabase creates in every project for itself, so commenting them out is safe and the replay goes through. The CLI already comments out most lines about its reserved roles; these get past it.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. pg_cron jobs do not come back
&lt;/h2&gt;

&lt;p&gt;If your project schedules work with &lt;code&gt;pg_cron&lt;/code&gt;, the restore brings back the extension and none of the jobs. The jobs are rows in &lt;code&gt;cron.job&lt;/code&gt;, and the CLI's data dump leaves those rows out. We confirmed it in CI on 4 October: the source had a scheduled job, and the restored database had 36 tables and no &lt;code&gt;cron&lt;/code&gt; rows.&lt;/p&gt;

&lt;p&gt;A restored project therefore runs nothing on a schedule until you notice. Keep your &lt;code&gt;cron.schedule&lt;/code&gt; calls in a migration or a script you can run again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a restore still does not prove
&lt;/h2&gt;

&lt;p&gt;Counting rows tells you the data came back. Three things it cannot tell you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;That anyone can sign in.&lt;/strong&gt; The rows in &lt;code&gt;auth.users&lt;/code&gt; include the password hashes, but only a real sign-in against the restored copy tests them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your uploaded files.&lt;/strong&gt; Storage keeps the bytes outside Postgres. The dump has the &lt;code&gt;storage.objects&lt;/code&gt; rows and none of the files, so a restore shows every bucket full and every download broken.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encrypted columns.&lt;/strong&gt; Vault secrets and pgsodium columns come back as ciphertext, and the root key is never in a dump.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Doing it on every run
&lt;/h2&gt;

&lt;p&gt;All of the above is what our open-source Action does after each backup. It reads the server's Postgres version, takes the three dumps, comments out the two &lt;code&gt;roles.sql&lt;/code&gt; lines, restores into &lt;code&gt;supabase db start&lt;/code&gt; on the runner, and compares the row count of every table with the rows the file holds. The run goes green only when every table came back:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Reeve-page/supabase-backup-action@v1&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;db-url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SUPABASE_DB_URL }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is MIT licensed: &lt;a href="https://github.com/Reeve-page/supabase-backup-action" rel="noopener noreferrer"&gt;https://github.com/Reeve-page/supabase-backup-action&lt;/a&gt;. Use the Session pooler connection string in the secret, since GitHub's runners reach IPv4 only and the direct host is IPv6 unless you pay for the add-on. Every run also downloads the whole database, and Supabase counts that against your egress allowance.&lt;/p&gt;

&lt;p&gt;The full drill, with the row counts to compare and the sign-in test, is here: &lt;a href="https://reeve.page/blog/test-your-supabase-backup" rel="noopener noreferrer"&gt;https://reeve.page/blog/test-your-supabase-backup&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I build Reeve, and if you would rather not run this yourself, its managed Supabase backups keep the copies outside your Supabase account and restore them with one button: &lt;a href="https://reeve.page/supabase-backups" rel="noopener noreferrer"&gt;https://reeve.page/supabase-backups&lt;/a&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>devops</category>
      <category>database</category>
    </item>
  </channel>
</rss>
