<?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: SuperLede</title>
    <description>The latest articles on DEV Community by SuperLede (@superlede).</description>
    <link>https://dev.to/superlede</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%2F4027485%2Fc4e05bf5-3771-4db9-839a-ce58ee7f8e11.png</url>
      <title>DEV Community: SuperLede</title>
      <link>https://dev.to/superlede</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/superlede"/>
    <language>en</language>
    <item>
      <title>Supabase Backups Don't Include Your Storage Files. Here's What Does.</title>
      <dc:creator>SuperLede</dc:creator>
      <pubDate>Wed, 15 Jul 2026 17:20:39 +0000</pubDate>
      <link>https://dev.to/superlede/supabase-backups-dont-include-your-storage-files-heres-what-does-14e9</link>
      <guid>https://dev.to/superlede/supabase-backups-dont-include-your-storage-files-heres-what-does-14e9</guid>
      <description>&lt;p&gt;The restore finished clean. &lt;code&gt;pg_restore&lt;/code&gt; exited 0, every table came back, row counts matched, the app booted. Twenty minutes later the first support message arrived: profile pictures were broken. Then the PDFs. Then every file anyone had ever uploaded.&lt;/p&gt;

&lt;p&gt;Nothing about the restore failed. This is how Supabase Storage is built — and it applies equally to the built-in daily backups, to PITR, and to your own &lt;code&gt;pg_dump&lt;/code&gt;. None of them include your Storage files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your database only stores pointers
&lt;/h2&gt;

&lt;p&gt;Supabase Storage is two systems. The &lt;code&gt;storage.objects&lt;/code&gt; table in your Postgres database holds the &lt;strong&gt;metadata&lt;/strong&gt; — bucket, path, timestamps, owner. The actual file bytes live in a separate S3 backend operated by Supabase, entirely outside your database.&lt;/p&gt;

&lt;p&gt;Restoring the database restores the pointers. If the files behind them were deleted, or you're restoring into a fresh project, those pointers now reference objects that don't exist. The dangerous part: everything looks healthy from SQL. Row counts match, constraints hold, &lt;code&gt;select count(*) from storage.objects&lt;/code&gt; is exactly right — until something requests a file and gets a 404.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove it to yourself in five minutes
&lt;/h2&gt;

&lt;p&gt;Ask your database what it thinks is in Storage:&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;bucket_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&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;created_at&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;
&lt;span class="k"&gt;limit&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now dump your database with &lt;code&gt;pg_dump&lt;/code&gt;, restore it into a local Postgres, and run the same query. Every row is there. Not one of the files those rows describe came with the dump. The restore passes every SQL check you can write and still lost your users' data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backing the files up
&lt;/h2&gt;

&lt;p&gt;The good news: Supabase exposes the Storage backend over an S3-compatible endpoint, so the files can be copied out like any other bucket.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, create S3 credentials.&lt;/strong&gt; Dashboard → &lt;strong&gt;Storage → Settings → S3 Access Keys&lt;/strong&gt; → enable the S3 connection and generate a key pair. The endpoint and region are shown on the same page; the endpoint looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://&amp;lt;project-ref&amp;gt;.storage.supabase.co/storage/v1/s3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One warning before anything else: &lt;strong&gt;these keys bypass RLS.&lt;/strong&gt; Treat them like a service-role key — server-side only, never in client code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option A: DIY with rclone
&lt;/h3&gt;

&lt;p&gt;Sync every bucket to storage you own — Cloudflare R2, Backblaze B2, or your own S3:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# ~/.config/rclone/rclone.conf
&lt;/span&gt;&lt;span class="nn"&gt;[supabase]&lt;/span&gt;
&lt;span class="py"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;s3&lt;/span&gt;
&lt;span class="py"&gt;provider&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;Other&lt;/span&gt;
&lt;span class="py"&gt;access_key_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;SUPABASE_S3_ACCESS_KEY_ID&amp;gt;&lt;/span&gt;
&lt;span class="py"&gt;secret_access_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;SUPABASE_S3_SECRET&amp;gt;&lt;/span&gt;
&lt;span class="py"&gt;endpoint&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;https://&amp;lt;project-ref&amp;gt;.storage.supabase.co/storage/v1/s3&lt;/span&gt;
&lt;span class="py"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;project-region&amp;gt;&lt;/span&gt;

&lt;span class="nn"&gt;[r2]&lt;/span&gt;
&lt;span class="py"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;s3&lt;/span&gt;
&lt;span class="py"&gt;provider&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;Cloudflare&lt;/span&gt;
&lt;span class="py"&gt;access_key_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;R2_ACCESS_KEY_ID&amp;gt;&lt;/span&gt;
&lt;span class="py"&gt;secret_access_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;R2_SECRET&amp;gt;&lt;/span&gt;
&lt;span class="py"&gt;endpoint&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;https://&amp;lt;account-id&amp;gt;.r2.cloudflarestorage.com&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rclone &lt;span class="nb"&gt;sync &lt;/span&gt;supabase:&amp;lt;bucket&amp;gt; r2:my-backups/storage/&amp;lt;bucket&amp;gt; &lt;span class="nt"&gt;--checksum&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If rclone throws signature errors against the Supabase endpoint (some releases after 1.67.0 did), the plain AWS CLI is a reliable fallback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws s3 &lt;span class="nb"&gt;sync &lt;/span&gt;s3://&amp;lt;bucket&amp;gt; ./storage-backup/&amp;lt;bucket&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--endpoint-url&lt;/span&gt; &lt;span class="s2"&gt;"https://&amp;lt;project-ref&amp;gt;.storage.supabase.co/storage/v1/s3"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; &amp;lt;project-region&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to get right if you go this route:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Dump the database in the same run.&lt;/strong&gt; Schedule the &lt;code&gt;rclone sync&lt;/code&gt; and the &lt;code&gt;pg_dump&lt;/code&gt; together (cron, or a GitHub Actions workflow), so the database snapshot and the file snapshot describe the same moment. Otherwise a restore hands you metadata rows and files that disagree with each other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budget for egress.&lt;/strong&gt; Downloading your files counts as uncached egress on your Supabase bill — $0.09/GB beyond your plan's allowance (Free: 5 GB/month, enforced through a fair-use flow of notice → grace period → restrictions, so recurring large backups aren't viable there; Pro: 250 GB included). For big buckets, weekly beats daily.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Option B: an open-source CLI that does both in one run
&lt;/h3&gt;

&lt;p&gt;Disclosure up front: I'm the author. The backupdrill CLI (MIT, &lt;a href="https://github.com/backupdrill/cli" rel="noopener noreferrer"&gt;github.com/backupdrill/cli&lt;/a&gt;) grew out of exactly the incident at the top of this post. One run streams the &lt;code&gt;pg_dump&lt;/code&gt; (custom format) &lt;strong&gt;and&lt;/strong&gt; the Storage files to a bucket you own, and writes a manifest with SHA-256 checksums:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; backupdrill

&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;BACKUPDRILL_SUPABASE_STORAGE_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://&amp;lt;ref&amp;gt;.storage.supabase.co/storage/v1/s3"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;BACKUPDRILL_SUPABASE_STORAGE_REGION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;project-region&amp;gt;"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;BACKUPDRILL_SUPABASE_STORAGE_ACCESS_KEY_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"…"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;BACKUPDRILL_SUPABASE_STORAGE_SECRET_ACCESS_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"…"&lt;/span&gt;

backupdrill backup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll also need the database connection string and a destination bucket — the repo covers the full configuration, ships a GitHub Actions template for scheduling, and has an &lt;code&gt;estimate&lt;/code&gt; command that projects the egress cost before you commit to a schedule.&lt;/p&gt;

&lt;p&gt;The part I actually built it for is &lt;code&gt;backupdrill drill&lt;/code&gt;: it restores the latest snapshot into a throwaway Docker Postgres and verifies it — archive checksum, table count against the manifest, populated tables restored non-empty, sampled SHA-256 checks on the Storage files — then destroys the container. Because a backup you've never restored is a guess.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option C: the hosted version
&lt;/h3&gt;

&lt;p&gt;If you'd rather not babysit cron: &lt;a href="https://backupdrill.com" rel="noopener noreferrer"&gt;BackupDrill&lt;/a&gt; runs the same open-source engine on a schedule — database and Storage files captured together into your own bucket, restore drills that prove the snapshot actually comes back, failures emailed. The free plan is one project with weekly backups and includes a restore drill on your first backup, no card.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Whatever you use for the database — built-in backups, PITR at $100 per month, or your own dumps — none of it covers Storage files. If users upload anything you'd mind losing, the files need their own backup path, and the database snapshot and file snapshot need to describe the same moment.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is an expanded version of a guide originally published on &lt;a href="https://backupdrill.com/guides/supabase-storage-backup" rel="noopener noreferrer"&gt;backupdrill.com&lt;/a&gt;. I build BackupDrill; the CLI above is MIT and works without the hosted service.&lt;/em&gt;&lt;/p&gt;

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