<?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>Three times our Supabase scanner said all clear without looking</title>
      <dc:creator>Vlad Tkachenko</dc:creator>
      <pubDate>Thu, 29 Oct 2026 10:40:23 +0000</pubDate>
      <link>https://dev.to/vladtkdev/three-times-our-supabase-scanner-said-all-clear-without-looking-36np</link>
      <guid>https://dev.to/vladtkdev/three-times-our-supabase-scanner-said-all-clear-without-looking-36np</guid>
      <description>&lt;p&gt;On 11 August 2026 two apps I had permission to scan came back from our scanner with the same two rows: "Database access: Not applicable" and "Storage buckets: Not applicable". Both apps run on Supabase and call it from the browser, so neither row could have been right. Working out why turned up three separate defects, and each one turned a question we could not answer into a clean result.&lt;/p&gt;

&lt;p&gt;The scanner checks an app from outside, with what any visitor has: the page, the JavaScript it loads, and the publishable key that ships in that JavaScript. A check can end three ways. It found something, it looked and found nothing, or it could not look. We print the second as "All good" or "Not applicable" and the third as "Couldn't check", and all three defects below turned the third into the second.&lt;/p&gt;

&lt;p&gt;Every Supabase response in this post was measured that day against real projects, so some of them may have changed since.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The table list came back empty
&lt;/h2&gt;

&lt;p&gt;The RLS check used to start by asking the PostgREST root, &lt;code&gt;/rest/v1/&lt;/code&gt;, for its OpenAPI document, then probe each table it listed. On a project using Supabase's current key format, that root answers a publishable key like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;401 {"message":"Secret API key required"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The list was empty, nothing got probed, and the check returned no findings, which the scanner prints as "Not applicable". Our main check had no coverage at all on any app that ships one of the new publishable keys.&lt;/p&gt;

&lt;p&gt;Discovery is now a union. The root document is still tried first, because a legacy project still serves it and its list is authoritative. After that come the table names the app's own bundle uses (&lt;code&gt;.from("x")&lt;/code&gt; calls and &lt;code&gt;/rest/v1/x&lt;/code&gt; paths), then 26 conventional names, up to 30 probes in all. Thirty came from the clock: a wave of four probes through our proxy measured about 480 ms, so 30 names take about four seconds of a 12-second check budget. For one day the cap was 20, because a stale &lt;code&gt;.env&lt;/code&gt; on the scanning machine still held the old 5-second budget.&lt;/p&gt;

&lt;p&gt;If none of the 30 names exists, the check now prints "Couldn't check". It knows the app has a database and has learned nothing about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  RLS answers 200 either way
&lt;/h2&gt;

&lt;p&gt;Fixing discovery uncovered a second problem in the same file. The old code counted any &lt;code&gt;200&lt;/code&gt; from a table as readable by strangers. Row Level Security filters rows instead of refusing the request, so a protected table answers &lt;code&gt;200&lt;/code&gt; with &lt;code&gt;content-range: */0&lt;/code&gt;, the same status a wide-open table gets. On one of the two apps, &lt;code&gt;profiles&lt;/code&gt;, &lt;code&gt;notes&lt;/code&gt;, &lt;code&gt;decks&lt;/code&gt; and &lt;code&gt;flashcards&lt;/code&gt; all answered 200 and all said &lt;code&gt;*/0&lt;/code&gt;. Under the old rule every one of them would have been reported as "Strangers can read your private database tables", at critical severity because &lt;code&gt;profiles&lt;/code&gt; holds people, about four tables that were in fact protected.&lt;/p&gt;

&lt;p&gt;That would have been a false alarm, the opposite failure. On these apps it never fired, because the empty table list meant no probe reached it.&lt;/p&gt;

&lt;p&gt;The probe asks for a count and never for rows: &lt;code&gt;?select=count&lt;/code&gt; with &lt;code&gt;Prefer: count=exact&lt;/code&gt; and &lt;code&gt;Range: 0-0&lt;/code&gt;. The verdict is the number after the slash in &lt;code&gt;content-range&lt;/code&gt;. Above zero is a finding. Zero means the table exists and nothing came back. A missing or unreadable header means we learned nothing, and it is never a finding in either direction.&lt;/p&gt;

&lt;p&gt;Later in August we ran it over 30,998 live apps. Of the 8,435 that named a Supabase project in the page, the check could finish on 3,680, and the figure we published is over those alone: &lt;a href="https://reeve.page/blog/can-anyone-read-your-supabase-database" rel="noopener noreferrer"&gt;Can anyone read your Supabase database? We checked 3,680 apps&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The bucket check never sent a body
&lt;/h2&gt;

&lt;p&gt;Storage lists a bucket through &lt;code&gt;POST /storage/v1/object/list/&amp;lt;bucket&amp;gt;&lt;/code&gt;. The options type of our HTTP helper had no &lt;code&gt;body&lt;/code&gt; field, so the POST went out empty, and Supabase answers an empty POST with &lt;code&gt;400 "Body cannot be empty"&lt;/code&gt;. It did that for every bucket name on every project, for the whole life of the check, and since a 400 is not a listable bucket, every time the check got as far as asking, it printed "Your uploaded files are kept private".&lt;/p&gt;

&lt;p&gt;The unit test passed the entire time. Its fixture answered 200 whatever the body was, because it encoded what we believed about the endpoint.&lt;/p&gt;

&lt;p&gt;Adding the body was only half of it. A bucket that does not exist also answers &lt;code&gt;200 []&lt;/code&gt;, the same as a private one, so with the body added and the old rule kept (a 200 with any array counted), the check would have flagged all seven names on its list on every project. Listable now means a 200 with a non-empty array, and the body asks for one entry at most:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"prefix"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"limit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before committing we broke each fix on purpose to see whether the tests would notice. Dropping the request body fails exactly the four bucket-listing tests, and counting any 200 as exposed fails exactly the three count-verdict tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The page never loaded
&lt;/h2&gt;

&lt;p&gt;This one sat under the other two. The scanner loads the page in headless Chromium and collects the JavaScript it really runs, and the Supabase URL and key come out of that bundle. When the load failed, the scan runner caught the error and handed every check an empty bundle without telling anyone. The secrets check then printed "No exposed API keys found" and the source-map check printed "Your original source code isn't published", about code nobody had downloaded. With no bundle there was no Supabase URL either, and the two Supabase checks took that to mean the app had no Supabase. Both stored scans behind the report had the secrets check down as clean, which is why nobody suspected the rest.&lt;/p&gt;

&lt;p&gt;Treating every empty bundle as a failure would have been wrong too, because a page that ships no JavaScript is a real result. The runner now sets &lt;code&gt;collectFailed&lt;/code&gt; when the load fails, and the four checks that read the bundle (secrets, source maps, RLS and buckets) call &lt;code&gt;assertCollected()&lt;/code&gt; first. It throws, and a check that throws is printed as "Couldn't check".&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule since
&lt;/h2&gt;

&lt;p&gt;The rule we wrote down afterwards: an empty result means "we looked and found nothing", and nothing else may print as one. Every fixture involved had encoded a version of Supabase two API generations old, so when a vendor's response shape is in question we now check it against the live service before writing the code that reads it. Re-run after the fix, both apps got a real answer on both rows.&lt;/p&gt;

&lt;p&gt;Two days later the same mistake turned up in our own statistics. The percentages we published divided every count by the apps whose scan returned, which counted every unanswered check as clean. Over the apps each check actually answered on, "95% of apps had at least one problem" became 98%.&lt;/p&gt;

&lt;p&gt;I build Reeve, and these are the Supabase checks in its free scanner, which runs them from outside against a live URL: &lt;a href="https://reeve.page/security-scanner" rel="noopener noreferrer"&gt;Reeve's security scanner&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>security</category>
      <category>postgres</category>
      <category>testing</category>
    </item>
    <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>
