<?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>What YouTube's single-page app did to my Chrome extension</title>
      <dc:creator>SuperLede</dc:creator>
      <pubDate>Mon, 05 Oct 2026 08:53:38 +0000</pubDate>
      <link>https://dev.to/superlede/what-youtubes-single-page-app-did-to-my-chrome-extension-h7a</link>
      <guid>https://dev.to/superlede/what-youtubes-single-page-app-did-to-my-chrome-extension-h7a</guid>
      <description>&lt;p&gt;I run a small site that checks whether a YouTube channel is in the Partner Program. A few days ago I shipped a Chrome extension for it. The idea fits in one sentence: put a "Check monetization" button under the channel name, click it, and see the verdict plus the signals behind it.&lt;/p&gt;

&lt;p&gt;Adding the button was the easy part. Keeping it on the page, attached to the right channel, was not. Nearly every problem traced back to one thing: YouTube is a single-page app, and it doesn't behave like the ones I've built.&lt;/p&gt;

&lt;p&gt;Here's what I ran into.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The old page is still there, just hidden
&lt;/h2&gt;

&lt;p&gt;If you look for the channel header with &lt;code&gt;querySelector&lt;/code&gt;, you can get the previous channel's header. When you navigate inside YouTube, the page you left isn't removed. It stays in the DOM with a &lt;code&gt;hidden&lt;/code&gt; attribute so Back can swap it in again.&lt;/p&gt;

&lt;p&gt;So every anchor I mount on starts from the visible page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ANCHORS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ytd-browse:not([hidden]) yt-page-header-renderer yt-flexible-actions-view-model&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;video&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ytd-watch-flexy:not([hidden]) ytd-watch-metadata #owner&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same goes for "is my button still mounted?". Checking &lt;code&gt;isConnected&lt;/code&gt; isn't enough, because a node can be perfectly connected inside a hidden page. The question has to be "is it attached to the anchor on the page you can actually see?"&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The URL is the only thing that tells the truth
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt; tags in the head don't update when you navigate inside YouTube. The page you first landed on keeps describing itself while you watch something else.&lt;/p&gt;

&lt;p&gt;So the content script never reads page metadata to decide what to check. It takes &lt;code&gt;location.href&lt;/code&gt; at the moment of the click, normalizes it, and sends that.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. yt-navigate-finish isn't enough on its own
&lt;/h2&gt;

&lt;p&gt;YouTube fires &lt;code&gt;yt-navigate-finish&lt;/code&gt; after in-app navigation, and that's the obvious hook. But YouTube also re-renders the header without navigating anywhere, and when it does, my button goes with it.&lt;/p&gt;

&lt;p&gt;I ended up with two triggers: the event, plus a one-second poll that does two cheap checks. If the URL changed, remount. If the URL is the same but the button isn't on the visible anchor anymore, put it back.&lt;/p&gt;

&lt;p&gt;Mounting retries every 250 ms and gives up after 15 seconds. If YouTube ships a redesign and the anchor disappears, the button just doesn't show up. Nothing throws, and the toolbar popup still works. I care about that more than it might sound: an extension that starts throwing errors on every page load after a YouTube redesign gets uninstalled.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Results can arrive for a page you've already left
&lt;/h2&gt;

&lt;p&gt;A check takes a few seconds. People click Check and then click the next video. Without care, the first channel's result lands on the second channel's page.&lt;/p&gt;

&lt;p&gt;Every request carries an incrementing number, and navigation bumps it. When a result comes back, it's discarded unless its number is still the latest and the URL still matches.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. A closed shadow root, with all: initial
&lt;/h2&gt;

&lt;p&gt;YouTube's CSS is everywhere and very specific. Without isolation, a button you insert picks up YouTube's font sizes and line heights. Mine lives in a closed shadow root, and the host element gets &lt;code&gt;all: initial&lt;/code&gt; so nothing cascades in from the page. Positioning is the only thing set on the host.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Network calls go through the service worker
&lt;/h2&gt;

&lt;p&gt;The popup can call &lt;code&gt;fetch&lt;/code&gt; itself, but then closing the popup halfway through a check kills the request. The server has already counted it against the daily limit, the result never gets cached, and reopening the popup costs the user another check.&lt;/p&gt;

&lt;p&gt;So the popup and the in-page button both message the service worker, and it makes the request. That brought two more things with it. If the popup and the button ask about the same channel at the same moment, only one request goes out. And results are cached for ten minutes, so reopening one doesn't spend another check.&lt;/p&gt;

&lt;p&gt;The service worker only accepts messages from its own popup and from top-frame content scripts on youtube.com. Anything else is dropped. Requests go out with &lt;code&gt;credentials: "omit"&lt;/code&gt;, and a timeout is never retried automatically, because the server may have counted it already.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Updates orphan your content scripts
&lt;/h2&gt;

&lt;p&gt;When the extension updates or is reloaded, content scripts already running in open tabs keep running but can no longer talk to the extension. &lt;code&gt;chrome.runtime.id&lt;/code&gt; becomes &lt;code&gt;undefined&lt;/code&gt;. My scripts check for that first, and if it's gone they remove their button and stop polling. Otherwise you're left with a button that does nothing when clicked.&lt;/p&gt;

&lt;p&gt;The opposite case: Chrome doesn't inject content scripts into tabs that were already open when the extension was installed. So when you open the popup on a YouTube tab where the content script isn't answering, it tells you to refresh the tab. Without that line, "the button doesn't appear" would be the first bug report.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the check runs on a server
&lt;/h2&gt;

&lt;p&gt;The obvious design is to do it all in the browser. You're already on the page, and the data is right there.&lt;/p&gt;

&lt;p&gt;I didn't, for two reasons. One of the strongest public signals is whether ads are actually being served on a channel's recent long videos, and in the user's browser that signal is bent by things the extension can't see: YouTube Premium, ad blockers. A server check gives everyone the same answer for the same channel.&lt;/p&gt;

&lt;p&gt;The other reason is that the obvious marker in the page doesn't mean what people assume. Several extensions read one flag from the page source and print "monetized". In a small test it showed up on 5 of 19 channels that weren't in the program. YouTube has run ads on non-partner channels since late 2020 in the US and 2021 elsewhere, so seeing ads, or ad plumbing, proves little by itself.&lt;/p&gt;

&lt;p&gt;So the extension is a thin client. The server reads public pages, scores the signals and returns a verdict with each signal marked pass, fail or couldn't read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy, briefly
&lt;/h2&gt;

&lt;p&gt;Nothing is sent while you browse. A channel or video link goes out only when you click Check, open the popup on a channel or video page, or paste a link, and only after you've said yes once.&lt;/p&gt;

&lt;p&gt;Checks from the extension also don't feed the public statistics on the site. That one cost a separate table on the server. The shared cache also drives a weekly dataset and a refresh list, and I didn't want extension usage quietly reshaping either of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pack script that says no
&lt;/h2&gt;

&lt;p&gt;Chrome Web Store review takes long enough that I didn't want to find problems there. The pack script refuses to build if the manifest has keys it doesn't expect, if there's an inline script or inline event handler anywhere, or if anything looks like remote code. For the store build it also strips the development &lt;code&gt;key&lt;/code&gt; and checks that the API URL points at production. A rejection costs days, and the script costs seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm watching
&lt;/h2&gt;

&lt;p&gt;Two things: whether the button survives the next few YouTube header changes, and what share of real checks come back "unable to determine". That second number is the failure mode I care about most.&lt;/p&gt;

&lt;p&gt;If you've built something that lives inside YouTube's DOM, I'd like to know how you handle remounting. A one-second poll works, but it feels like there should be something better.&lt;/p&gt;

&lt;p&gt;The extension is free: &lt;a href="https://chromewebstore.google.com/detail/llmpghmjaefmbhfbcfcddaldmoglojfd" rel="noopener noreferrer"&gt;Monetization Checker for YouTube&lt;/a&gt;. More on how it works on &lt;a href="https://monetizationkit.com/extension/" rel="noopener noreferrer"&gt;the extension page&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>chrome</category>
      <category>showdev</category>
    </item>
    <item>
      <title>pg_dump to S3: a backup script that won't upload a truncated dump</title>
      <dc:creator>SuperLede</dc:creator>
      <pubDate>Thu, 01 Oct 2026 05:33:15 +0000</pubDate>
      <link>https://dev.to/superlede/pgdump-to-s3-a-backup-script-that-wont-upload-a-truncated-dump-e8i</link>
      <guid>https://dev.to/superlede/pgdump-to-s3-a-backup-script-that-wont-upload-a-truncated-dump-e8i</guid>
      <description>&lt;p&gt;The script below dumps one PostgreSQL database with pg_dump, reads the archive back to make sure it is complete, and uploads it to S3 or any S3-compatible bucket: Cloudflare R2, Backblaze B2, Wasabi, MinIO. It works the same against RDS, Neon, Railway, Supabase, or your own server. Run it daily and you have backups. Run the second script further down weekly and you know they restore.&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="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# pg-dump-to-s3.sh: dump one PostgreSQL database, check the archive, upload it&lt;/span&gt;
&lt;span class="c"&gt;# to S3 or any S3-compatible bucket (R2, B2, Wasabi, MinIO).&lt;/span&gt;
&lt;span class="c"&gt;# From https://backupdrill.com/guides/pg-dump-to-s3 (MIT licensed).&lt;/span&gt;
&lt;span class="c"&gt;#&lt;/span&gt;
&lt;span class="c"&gt;# Needs pg_dump and pg_restore of the same major version as the server, and&lt;/span&gt;
&lt;span class="c"&gt;# the AWS CLI v2. Configure with environment variables:&lt;/span&gt;
&lt;span class="c"&gt;#   DATABASE_URL     postgresql://user@host:5432/dbname (password in ~/.pgpass)&lt;/span&gt;
&lt;span class="c"&gt;#   S3_BUCKET        bucket name&lt;/span&gt;
&lt;span class="c"&gt;#   S3_PREFIX        folder inside the bucket (default: postgres)&lt;/span&gt;
&lt;span class="c"&gt;#   AGE_RECIPIENT    optional: age public key; the dump is encrypted before upload&lt;/span&gt;
&lt;span class="c"&gt;#   AWS_ENDPOINT_URL for anything that is not AWS S3, e.g.&lt;/span&gt;
&lt;span class="c"&gt;#                    https://&amp;lt;account-id&amp;gt;.r2.cloudflarestorage.com&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

: &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;:?DATABASE_URL&lt;span class="p"&gt; is not set&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
: &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;S3_BUCKET&lt;/span&gt;:?S3_BUCKET&lt;span class="p"&gt; is not set&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;prefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;S3_PREFIX&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;postgres&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;stamp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%Y-%m-%dT%H-%M-%SZ&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nv"&gt;workdir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;mktemp&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;trap&lt;/span&gt; &lt;span class="s1"&gt;'rm -rf "$workdir"'&lt;/span&gt; EXIT
&lt;span class="nb"&gt;umask &lt;/span&gt;077
&lt;span class="nv"&gt;dump&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$workdir&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$stamp&lt;/span&gt;&lt;span class="s2"&gt;.dump"&lt;/span&gt;

&lt;span class="c"&gt;# 1. Dump. Custom format is compressed, and pg_restore can restore&lt;/span&gt;
&lt;span class="c"&gt;#    single tables from it or run in parallel.&lt;/span&gt;
pg_dump &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;custom &lt;span class="nt"&gt;--file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dump&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--dbname&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DATABASE_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# 2. Read the whole archive back before it leaves the machine: --list checks&lt;/span&gt;
&lt;span class="c"&gt;#    the table of contents, --file=/dev/null decompresses every row. A truncated&lt;/span&gt;
&lt;span class="c"&gt;#    or corrupt file fails here instead of on the day you need it.&lt;/span&gt;
pg_restore &lt;span class="nt"&gt;--list&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dump&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$workdir&lt;/span&gt;&lt;span class="s2"&gt;/contents.txt"&lt;/span&gt;
pg_restore &lt;span class="nt"&gt;--file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev/null &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dump&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;tables&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;' TABLE DATA '&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$workdir&lt;/span&gt;&lt;span class="s2"&gt;/contents.txt"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"dump OK: &lt;/span&gt;&lt;span class="nv"&gt;$tables&lt;/span&gt;&lt;span class="s2"&gt; tables with data, &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &amp;lt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dump&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; bytes"&lt;/span&gt;

&lt;span class="c"&gt;# 3. Optionally encrypt. Only the holder of the matching private key can&lt;/span&gt;
&lt;span class="c"&gt;#    read it, so a leaked bucket key does not leak the database.&lt;/span&gt;
&lt;span class="nv"&gt;upload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dump&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;AGE_RECIPIENT&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&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;then
  &lt;/span&gt;age &lt;span class="nt"&gt;--recipient&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AGE_RECIPIENT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dump&lt;/span&gt;&lt;span class="s2"&gt;.age"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dump&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nv"&gt;upload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dump&lt;/span&gt;&lt;span class="s2"&gt;.age"&lt;/span&gt;
&lt;span class="k"&gt;fi
&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$upload&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# 4. Upload the file, then a checksum next to it.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nb"&gt;sha256sum&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;sum&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;sha256sum&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$upload&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; 1&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;else
  &lt;/span&gt;&lt;span class="nb"&gt;sum&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;shasum &lt;span class="nt"&gt;-a&lt;/span&gt; 256 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$upload&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; 1&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi
&lt;/span&gt;aws s3 &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$upload&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"s3://&lt;/span&gt;&lt;span class="nv"&gt;$S3_BUCKET&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$prefix&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--only-show-errors&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s  %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$sum&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | aws s3 &lt;span class="nb"&gt;cp&lt;/span&gt; - &lt;span class="s2"&gt;"s3://&lt;/span&gt;&lt;span class="nv"&gt;$S3_BUCKET&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$prefix&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="s2"&gt;.sha256"&lt;/span&gt; &lt;span class="nt"&gt;--only-show-errors&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"uploaded s3://&lt;/span&gt;&lt;span class="nv"&gt;$S3_BUCKET&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$prefix&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It needs &lt;code&gt;pg_dump&lt;/code&gt; and &lt;code&gt;pg_restore&lt;/code&gt; of the same major version as your server (check with &lt;code&gt;pg_dump --version&lt;/code&gt; and &lt;code&gt;select version()&lt;/code&gt;). A newer pg_dump can dump an older server, but its archive then needs a newer pg_restore, and restoring that into the older server version runs into &lt;a href="https://backupdrill.com/guides/pg-restore" rel="noopener noreferrer"&gt;errors covered in the pg_restore guide&lt;/a&gt;. Matching versions avoids all of it. It also needs the AWS CLI v2, which also talks to non-AWS buckets through &lt;code&gt;AWS_ENDPOINT_URL&lt;/code&gt;. We ran every command here against PostgreSQL 17 and a MinIO bucket before publishing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the script does not pipe pg_dump into aws s3 cp
&lt;/h2&gt;

&lt;p&gt;The one-liner you see everywhere, &lt;code&gt;pg_dump … | aws s3 cp - s3://…&lt;/code&gt;, has a failure mode worth knowing. If pg_dump dies partway (a dropped connection, a statement timeout, a full disk on the server), the pipe simply ends. The AWS CLI sees the end of its input and finishes the upload. We tested it: the pipeline exits with an error, and a truncated file is in the bucket under a normal timestamped name, looking like any other backup until the day you try to restore it.&lt;/p&gt;

&lt;p&gt;Writing to a temporary file first costs disk space equal to one compressed dump, and buys two things: nothing is uploaded unless pg_dump succeeded, and pg_restore reads the whole archive back before it leaves the machine. &lt;code&gt;--list&lt;/code&gt; alone is not enough for that: it only reads the table of contents at the start of the file, and in our test it passed a dump with its end cut off. &lt;code&gt;--file=/dev/null&lt;/code&gt; decompresses every row and failed on the same file. If you have no local disk to spare, stream to a key ending in &lt;code&gt;.partial&lt;/code&gt; and rename it only after the pipeline succeeds.&lt;/p&gt;

&lt;p&gt;A successful run prints two lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;bash pg-dump-to-s3.sh
&lt;span class="go"&gt;dump OK: 4 tables with data, 17730 bytes
uploaded s3://acme-db-backups/postgres/2026-09-29T14-06-30Z.dump
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Credentials: keep the password out of the connection string
&lt;/h2&gt;

&lt;p&gt;A password inside &lt;code&gt;DATABASE_URL&lt;/code&gt; ends up on pg_dump's command line, where any user on the machine can read it in the process list while the dump runs. Put it in a password file instead and leave it out of the URL:&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="c"&gt;# a dedicated system user runs the backups&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;useradd &lt;span class="nt"&gt;--system&lt;/span&gt; &lt;span class="nt"&gt;--create-home&lt;/span&gt; &lt;span class="nt"&gt;--home-dir&lt;/span&gt; /var/lib/pgbackup pgbackup

&lt;span class="c"&gt;# /etc/pg-backup.pgpass holds one line: host:port:database:user:password&lt;/span&gt;
&lt;span class="c"&gt;# read -rs keeps the password off the screen and out of shell history&lt;/span&gt;
&lt;span class="c"&gt;# IFS= keeps leading/trailing spaces; sed escapes \ and : as .pgpass requires&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'Password for backup_user: '&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-rs&lt;/span&gt; PGPASS_VALUE&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo
sudo install&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 600 &lt;span class="nt"&gt;-o&lt;/span&gt; pgbackup /dev/null /etc/pg-backup.pgpass
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'db.internal:5432:app:backup_user:%s\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&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'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PGPASS_VALUE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/[\\:]/\\&amp;amp;/g'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/pg-backup.pgpass &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null
&lt;span class="nb"&gt;unset &lt;/span&gt;PGPASS_VALUE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;libpq ignores the file if its permissions are wider than 0600. Point to it with &lt;code&gt;PGPASSFILE&lt;/code&gt;, as the environment file below does. The database user needs to be able to read every table you back up; on PostgreSQL 14 and later, granting it the &lt;code&gt;pg_read_all_data&lt;/code&gt; role is the simplest way to give a dedicated backup user exactly that. One exception: that role does not bypass row-level security, and pg_dump stops with an error on a table with RLS enabled unless the user owns it or has &lt;code&gt;BYPASSRLS&lt;/code&gt; (&lt;code&gt;ALTER ROLE backup_user BYPASSRLS&lt;/code&gt;, which needs a superuser or, on managed hosts, the platform's admin role).&lt;/p&gt;

&lt;p&gt;For the bucket, give the backup machine a key that can write to the backup prefix and nothing else. A key that can also delete means whoever takes over that server can take your backups with it. A write-only key still lets them overwrite an existing backup with a file of the same name, so also turn on bucket versioning (or S3 Object Lock, where your provider offers it): an overwritten backup then stays recoverable as an older version. Cloudflare R2 has neither; its &lt;a href="https://developers.cloudflare.com/r2/buckets/bucket-locks/" rel="noopener noreferrer"&gt;bucket lock rules&lt;/a&gt; do the job differently, by refusing overwrites and deletes under a prefix for a retention period. Deletion of old backups is handled by the lifecycle rule further down, which needs no key at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Schedule it: systemd timer, cron, or GitHub Actions
&lt;/h2&gt;

&lt;p&gt;All three read the same settings. On a server, keep them in one file that only the backup user can read:&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="c"&gt;# /etc/pg-backup.env (chmod 600, owned by pgbackup)&lt;/span&gt;
&lt;span class="c"&gt;# quote the URL: cron sources this with sh, where an unquoted &amp;amp; (as in&lt;/span&gt;
&lt;span class="c"&gt;# ?sslmode=verify-full&amp;amp;sslrootcert=...) would end the line&lt;/span&gt;
&lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"postgresql://backup_user@db.internal:5432/app"&lt;/span&gt;
&lt;span class="nv"&gt;PGPASSFILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/etc/pg-backup.pgpass
&lt;span class="nv"&gt;S3_BUCKET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;acme-db-backups
&lt;span class="nv"&gt;S3_PREFIX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgres/app
&lt;span class="nv"&gt;AWS_ACCESS_KEY_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;AKIA...
&lt;span class="nv"&gt;AWS_SECRET_ACCESS_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...
&lt;span class="nv"&gt;AWS_DEFAULT_REGION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;us-east-1
&lt;span class="c"&gt;# R2, B2, Wasabi: uncomment and set the provider's S3 endpoint&lt;/span&gt;
&lt;span class="c"&gt;# AWS_ENDPOINT_URL=https://ACCOUNT_ID.r2.cloudflarestorage.com&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  systemd timer (recommended on Linux servers)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/pg-backup.service
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;pg_dump to S3&lt;/span&gt;
&lt;span class="py"&gt;Wants&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&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;oneshot&lt;/span&gt;
&lt;span class="py"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;pgbackup&lt;/span&gt;
&lt;span class="py"&gt;EnvironmentFile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/etc/pg-backup.env&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/local/bin/pg-dump-to-s3.sh&lt;/span&gt;

&lt;span class="c"&gt;# /etc/systemd/system/pg-backup.timer
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Daily pg_dump to S3&lt;/span&gt;

&lt;span class="nn"&gt;[Timer]&lt;/span&gt;
&lt;span class="py"&gt;OnCalendar&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;*-*-* 03:15:00 UTC&lt;/span&gt;
&lt;span class="py"&gt;RandomizedDelaySec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10m&lt;/span&gt;
&lt;span class="py"&gt;Persistent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;timers.target&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;&lt;span class="nb"&gt;sudo install&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 0755 pg-dump-to-s3.sh /usr/local/bin/pg-dump-to-s3.sh
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; pg-backup.timer
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start pg-backup.service   &lt;span class="c"&gt;# one run now, to test&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; pg-backup.service &lt;span class="nt"&gt;-n&lt;/span&gt; 20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Persistent=true&lt;/code&gt; runs a backup that was missed while the machine was off as soon as it is back. &lt;code&gt;RandomizedDelaySec&lt;/code&gt; keeps a fleet of servers from dumping at the same second. Output goes to the journal, and a failed run shows up in &lt;code&gt;systemctl --failed&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  cron
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# /etc/cron.d/pg-backup: daily at 03:15 server time, as user pgbackup
PATH=/usr/local/bin:/usr/bin:/bin
15 3 * * * pgbackup set -a; . /etc/pg-backup.env; set +a; /usr/local/bin/pg-dump-to-s3.sh &amp;gt;&amp;gt; /var/log/pg-backup/backup.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two cron details break backup scripts. Cron starts jobs with a minimal &lt;code&gt;PATH&lt;/code&gt; that usually does not include &lt;code&gt;/usr/local/bin&lt;/code&gt;, where the AWS CLI installs, so set it at the top of the file. And cron does not read your environment file, so the line loads it with &lt;code&gt;set -a&lt;/code&gt;. Create &lt;code&gt;/var/log/pg-backup&lt;/code&gt; owned by the backup user first.&lt;/p&gt;

&lt;h3&gt;
  
  
  GitHub Actions
&lt;/h3&gt;

&lt;p&gt;Useful when there is no server to run it on, or the database is a managed service. Commit the script to &lt;code&gt;scripts/pg-dump-to-s3.sh&lt;/code&gt;, add the values as repository secrets, and use this workflow:&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="c1"&gt;# .github/workflows/pg-backup.yml&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;pg-backup&lt;/span&gt;

&lt;span class="na"&gt;on&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;15&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;3&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;# daily, 03:15 UTC; runs can start late under load&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pg-backup&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&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;jobs&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;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&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;actions/checkout@v4&lt;/span&gt;

      &lt;span class="c1"&gt;# Use the client that matches your server's major version (17 here).&lt;/span&gt;
      &lt;span class="c1"&gt;# Install it from the PostgreSQL apt repo and put it first on PATH;&lt;/span&gt;
      &lt;span class="c1"&gt;# age is only needed if you set AGE_RECIPIENT.&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;Install postgresql-client-17 (PGDG)&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;sudo install -d /usr/share/postgresql-common/pgdg&lt;/span&gt;
          &lt;span class="s"&gt;curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \&lt;/span&gt;
            &lt;span class="s"&gt;| sudo gpg --dearmor -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg&lt;/span&gt;
          &lt;span class="s"&gt;echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg] \&lt;/span&gt;
          &lt;span class="s"&gt;https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \&lt;/span&gt;
            &lt;span class="s"&gt;| sudo tee /etc/apt/sources.list.d/pgdg.list&lt;/span&gt;
          &lt;span class="s"&gt;sudo apt-get update&lt;/span&gt;
          &lt;span class="s"&gt;sudo apt-get install -y postgresql-client-17 age&lt;/span&gt;
          &lt;span class="s"&gt;echo "/usr/lib/postgresql/17/bin" &amp;gt;&amp;gt; "$GITHUB_PATH"&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;Dump and upload&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash scripts/pg-dump-to-s3.sh&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DATABASE_URL }}&lt;/span&gt;
          &lt;span class="na"&gt;S3_BUCKET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.S3_BUCKET }}&lt;/span&gt;
          &lt;span class="na"&gt;S3_PREFIX&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres/app&lt;/span&gt;
          &lt;span class="na"&gt;AWS_ACCESS_KEY_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_ACCESS_KEY_ID }}&lt;/span&gt;
          &lt;span class="na"&gt;AWS_SECRET_ACCESS_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_SECRET_ACCESS_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;AWS_DEFAULT_REGION&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;us-east-1&lt;/span&gt;
          &lt;span class="na"&gt;AGE_RECIPIENT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AGE_RECIPIENT }}&lt;/span&gt;
          &lt;span class="c1"&gt;# R2, B2, Wasabi only. Delete this line for AWS S3.&lt;/span&gt;
          &lt;span class="na"&gt;AWS_ENDPOINT_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_ENDPOINT_URL }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The runner image may come with an older PostgreSQL client, which is why the workflow installs a matching version and puts it first on &lt;code&gt;PATH&lt;/code&gt;. On a runner the password can stay in &lt;code&gt;DATABASE_URL&lt;/code&gt;: the machine is yours alone and discarded after the job. Scheduled runs can start late when GitHub is busy, and GitHub disables schedules in public repositories after 60 days without activity, so check the Actions tab now and then. The &lt;a href="https://backupdrill.com/guides/supabase-backup-github-actions" rel="noopener noreferrer"&gt;Supabase GitHub Actions guide&lt;/a&gt; covers the egress side of running backups from GitHub in more detail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Encrypt the dump before upload
&lt;/h2&gt;

&lt;p&gt;S3 server-side encryption protects the disks at Amazon. It does nothing against the more likely leak: someone with a read key for the bucket sees your database in plain text. Encrypting on the backup machine with a public key closes that gap, because the machine that writes backups cannot read them:&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="c"&gt;# once, on your own machine: create a key pair&lt;/span&gt;
age-keygen &lt;span class="nt"&gt;--output&lt;/span&gt; pg-backup-key.txt
age-keygen &lt;span class="nt"&gt;-y&lt;/span&gt; pg-backup-key.txt   &lt;span class="c"&gt;# prints the public key, age1...&lt;/span&gt;

&lt;span class="c"&gt;# on the backup server, add the public key to /etc/pg-backup.env&lt;/span&gt;
&lt;span class="nv"&gt;AGE_RECIPIENT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;age1...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;AGE_RECIPIENT&lt;/code&gt; set, the script uploads &lt;code&gt;…dump.age&lt;/code&gt; instead of the plain dump. Keep the private key somewhere that is not the backup server and not the bucket, such as a password manager. Losing it means losing every backup made with it, so test a restore with it now. The check script below takes it through &lt;code&gt;AGE_IDENTITY&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retention: delete old dumps with a lifecycle rule
&lt;/h2&gt;

&lt;p&gt;Let the bucket delete old backups, not the script. On S3, a rule that expires everything under the prefix after 35 days:&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Rules"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"ID"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"expire-old-dumps"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Enabled"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Filter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Prefix"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"postgres/app/"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Expiration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Days"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;35&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&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;Applying it replaces the bucket's whole lifecycle configuration, not just this rule, so any expiry or archive rules already on the bucket would silently disappear. Look first:&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="c"&gt;# prints the current rules, or NoSuchLifecycleConfiguration if there are none&lt;/span&gt;
aws s3api get-bucket-lifecycle-configuration &lt;span class="nt"&gt;--bucket&lt;/span&gt; acme-db-backups

&lt;span class="c"&gt;# no existing rules, or you merged them into lifecycle.json's Rules array:&lt;/span&gt;
aws s3api put-bucket-lifecycle-configuration &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; acme-db-backups &lt;span class="nt"&gt;--lifecycle-configuration&lt;/span&gt; file://lifecycle.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The checksum files sit under the same prefix, so they expire with their dumps. If the bucket has versioning turned on, expiring an object only hides it behind a delete marker and the old version is still stored and billed: add a &lt;code&gt;NoncurrentVersionExpiration&lt;/code&gt; rule as well, for example &lt;code&gt;{ "NoncurrentDays": 7 }&lt;/code&gt;. On R2, set the same rule under the bucket's Object Lifecycle Rules. On B2, lifecycle rules first hide a file and then delete it. For more history at little cost, keep dailies for a month and copy the first dump of each month to a second prefix with a longer rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same script on RDS, Neon, Railway, and Supabase
&lt;/h2&gt;

&lt;p&gt;Only the connection string changes. What to use on each platform:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AWS RDS and Aurora PostgreSQL.&lt;/strong&gt; Connect with TLS verified against the RDS certificate bundle: &lt;code&gt;?sslmode=verify-full&amp;amp;sslrootcert=/etc/ssl/rds-global-bundle.pem&lt;/code&gt;, with the bundle downloaded from &lt;code&gt;truststore.pki.rds.amazonaws.com&lt;/code&gt;. RDS's own "export snapshot to S3" feature is not a substitute: it writes Apache Parquet files for analytics tools, which no Postgres can restore. RDS snapshots themselves restore only into RDS. A pg_dump file in your own bucket is the copy you can restore anywhere, including outside AWS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Neon.&lt;/strong&gt; Use the direct connection string, the host without &lt;code&gt;-pooler&lt;/code&gt;. Neon's own pg_dump guide says pooled connections are not supported for dumps and restores and will cause errors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Railway.&lt;/strong&gt; Railway databases are private by default. To dump from outside Railway (a GitHub Actions runner, another server), turn on Public Access under the database's Networking settings, which creates a TCP proxy and a &lt;code&gt;DATABASE_PUBLIC_URL&lt;/code&gt; variable to use here. Traffic through that proxy counts as egress on your Railway bill. Running the script as a cron service inside the same Railway project avoids both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Supabase.&lt;/strong&gt; Use the Session pooler string on port 5432, and dump your own schemas, not the platform's. Storage files are not in any database dump. The &lt;a href="https://backupdrill.com/guides/supabase-backup-to-aws-s3" rel="noopener noreferrer"&gt;Supabase to S3 guide&lt;/a&gt; covers both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check that the backup restores
&lt;/h2&gt;

&lt;p&gt;A backup job that has run green for a year proves that pg_dump exited 0 every night. It does not prove the files restore. This second script downloads the newest dump, verifies its checksum, restores it into a throwaway Postgres container of your server's major version, and fails if any table in the archive did not come back. Run it weekly on any machine with Docker, using a separate key that can read and list the bucket, so the backup server's write-only key stays write-only.&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="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# pg-restore-check.sh: restore the newest dump from the bucket into a throwaway&lt;/span&gt;
&lt;span class="c"&gt;# Postgres container and check that every table in the archive came back.&lt;/span&gt;
&lt;span class="c"&gt;# From https://backupdrill.com/guides/pg-dump-to-s3 (MIT licensed).&lt;/span&gt;
&lt;span class="c"&gt;#&lt;/span&gt;
&lt;span class="c"&gt;# Needs Docker and the AWS CLI v2. Same variables as pg-dump-to-s3.sh, plus:&lt;/span&gt;
&lt;span class="c"&gt;#   PG_MAJOR       major version of the pg_dump that made the dumps, which&lt;/span&gt;
&lt;span class="c"&gt;#                  should be the same as your server's (default: 17)&lt;/span&gt;
&lt;span class="c"&gt;#   AGE_IDENTITY   path to the age private key, if the dumps are encrypted&lt;/span&gt;
&lt;span class="c"&gt;#   ROLES          space-separated roles your policies or grants name, e.g.&lt;/span&gt;
&lt;span class="c"&gt;#                  "app_reader app_writer"; created (NOLOGIN) before restoring&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

: &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;S3_BUCKET&lt;/span&gt;:?S3_BUCKET&lt;span class="p"&gt; is not set&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;prefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;S3_PREFIX&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;postgres&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;major&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PG_MAJOR&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;17&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;container&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"pg-restore-check-&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nv"&gt;workdir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;mktemp&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
cleanup&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="c"&gt;# --volumes: the postgres image keeps its data in an anonymous volume, which&lt;/span&gt;
  &lt;span class="c"&gt;# would otherwise outlive the container with a plain-text copy of the database&lt;/span&gt;
  docker &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nt"&gt;--volumes&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$container&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
  rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$workdir&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;trap &lt;/span&gt;cleanup EXIT
&lt;span class="nb"&gt;umask &lt;/span&gt;077

&lt;span class="c"&gt;# 1. Newest dump in the prefix. The timestamped names sort by date.&lt;/span&gt;
&lt;span class="nv"&gt;latest&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws s3 &lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="s2"&gt;"s3://&lt;/span&gt;&lt;span class="nv"&gt;$S3_BUCKET&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$prefix&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $4}'&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'\.dump(\.age)?$'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$latest&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"no dumps under s3://&lt;/span&gt;&lt;span class="nv"&gt;$S3_BUCKET&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$prefix&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
aws s3 &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"s3://&lt;/span&gt;&lt;span class="nv"&gt;$S3_BUCKET&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$prefix&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$latest&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$workdir&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$latest&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--only-show-errors&lt;/span&gt;
aws s3 &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"s3://&lt;/span&gt;&lt;span class="nv"&gt;$S3_BUCKET&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$prefix&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$latest&lt;/span&gt;&lt;span class="s2"&gt;.sha256"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$workdir&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$latest&lt;/span&gt;&lt;span class="s2"&gt;.sha256"&lt;/span&gt; &lt;span class="nt"&gt;--only-show-errors&lt;/span&gt;

&lt;span class="c"&gt;# 2. Checksum, so a damaged download is not mistaken for a bad backup.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nb"&gt;sha256sum&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$workdir&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sha256sum&lt;/span&gt; &lt;span class="nt"&gt;--check&lt;/span&gt; &lt;span class="nt"&gt;--quiet&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$latest&lt;/span&gt;&lt;span class="s2"&gt;.sha256"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
  &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$workdir&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; shasum &lt;span class="nt"&gt;-a&lt;/span&gt; 256 &lt;span class="nt"&gt;--check&lt;/span&gt; &lt;span class="nt"&gt;--quiet&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$latest&lt;/span&gt;&lt;span class="s2"&gt;.sha256"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nv"&gt;dump&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$workdir&lt;/span&gt;&lt;span class="s2"&gt;/latest.dump"&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;%.age&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$latest&lt;/span&gt;&lt;span class="s2"&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;then&lt;/span&gt;
  : &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;AGE_IDENTITY&lt;/span&gt;:?the&lt;span class="p"&gt; dump is encrypted&lt;/span&gt;:&lt;span class="p"&gt; set AGE_IDENTITY&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  age &lt;span class="nt"&gt;--decrypt&lt;/span&gt; &lt;span class="nt"&gt;--identity&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AGE_IDENTITY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dump&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$workdir&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$latest&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;else
  &lt;/span&gt;&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$workdir&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$latest&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dump&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# 3. A throwaway server of the same major version, with no network at all:&lt;/span&gt;
&lt;span class="c"&gt;#    it holds a copy of your data, and docker exec is all the checks need.&lt;/span&gt;
&lt;span class="c"&gt;#    It only listens on TCP once initialisation has finished, so wait for a&lt;/span&gt;
&lt;span class="c"&gt;#    TCP connection, not pg_isready.&lt;/span&gt;
docker run &lt;span class="nt"&gt;--detach&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$container&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--network&lt;/span&gt; none &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--env&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;throwaway &lt;span class="s2"&gt;"postgres:&lt;/span&gt;&lt;span class="nv"&gt;$major&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null
&lt;span class="k"&gt;for &lt;/span&gt;attempt &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 120&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  if &lt;/span&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$container&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; psql &lt;span class="nt"&gt;-h&lt;/span&gt; 127.0.0.1 &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'select 1'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;break
  &lt;/span&gt;&lt;span class="k"&gt;fi
  if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$attempt&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 120 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL: the throwaway Postgres did not start within 2 minutes:"&lt;/span&gt;
    docker logs &lt;span class="nt"&gt;--tail&lt;/span&gt; 20 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$container&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
  &lt;span class="k"&gt;fi
  &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;1
&lt;span class="k"&gt;done
&lt;/span&gt;docker &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dump&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$container&lt;/span&gt;&lt;span class="s2"&gt;:/tmp/latest.dump"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null

&lt;span class="c"&gt;# --no-owner and --no-privileges drop ownership and grants, but a row-level&lt;/span&gt;
&lt;span class="c"&gt;# security policy that names a role still needs that role to exist.&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;role &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ROLES&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$container&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; psql &lt;span class="nt"&gt;-h&lt;/span&gt; 127.0.0.1 &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"create role &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$role&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; nologin"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# 4. Restore. --exit-on-error turns the first failure into a failed check&lt;/span&gt;
&lt;span class="c"&gt;#    instead of a warning count at the end; --no-tablespaces because the&lt;/span&gt;
&lt;span class="c"&gt;#    throwaway server has none of the source's custom tablespaces.&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$container&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; pg_restore &lt;span class="nt"&gt;-h&lt;/span&gt; 127.0.0.1 &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;--dbname&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgres &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-owner&lt;/span&gt; &lt;span class="nt"&gt;--no-privileges&lt;/span&gt; &lt;span class="nt"&gt;--no-tablespaces&lt;/span&gt; &lt;span class="nt"&gt;--exit-on-error&lt;/span&gt; /tmp/latest.dump

&lt;span class="c"&gt;# 5. Every table that has data in the archive must exist after the restore.&lt;/span&gt;
&lt;span class="c"&gt;#    A TOC line ends in "TABLE DATA &amp;lt;schema&amp;gt; &amp;lt;table&amp;gt; &amp;lt;owner&amp;gt;"; the table name&lt;/span&gt;
&lt;span class="c"&gt;#    is everything between schema and owner, so table names with spaces&lt;/span&gt;
&lt;span class="c"&gt;#    survive. Schema or owner names with spaces are not handled.&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$container&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; pg_restore &lt;span class="nt"&gt;--list&lt;/span&gt; /tmp/latest.dump &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'/ TABLE DATA / {
      sub(/^.* TABLE DATA /, "")
      schema = $1; owner = $NF
      print schema "." substr($0, length(schema) + 2, length($0) - length(schema) - length(owner) - 2)
    }'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$workdir&lt;/span&gt;&lt;span class="s2"&gt;/expected.txt"&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$container&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; psql &lt;span class="nt"&gt;-h&lt;/span&gt; 127.0.0.1 &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-At&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
  select table_schema || '.' || table_name
  from information_schema.tables
  where table_type = 'BASE TABLE'
    and table_schema not in ('pg_catalog', 'information_schema')"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$workdir&lt;/span&gt;&lt;span class="s2"&gt;/restored.txt"&lt;/span&gt;
&lt;span class="nv"&gt;missing&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;comm&lt;/span&gt; &lt;span class="nt"&gt;-23&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$workdir&lt;/span&gt;&lt;span class="s2"&gt;/expected.txt"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$workdir&lt;/span&gt;&lt;span class="s2"&gt;/restored.txt"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$missing&lt;/span&gt;&lt;span class="s2"&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;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL: tables in the dump but not in the restored database:"&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$missing&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# 6. Row counts, so an empty table stands out.&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$container&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; psql &lt;span class="nt"&gt;-h&lt;/span&gt; 127.0.0.1 &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-At&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
  select table_schema || '.' || table_name,
         (xpath('/row/n/text()', query_to_xml(format('select count(*) as n from %I.%I',
           table_schema, table_name), false, true, '')))[1]::text
  from information_schema.tables
  where table_type = 'BASE TABLE'
    and table_schema not in ('pg_catalog', 'information_schema')
  order by 1"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"PASS: &lt;/span&gt;&lt;span class="nv"&gt;$latest&lt;/span&gt;&lt;span class="s2"&gt; restored, &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; &amp;lt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$workdir&lt;/span&gt;&lt;span class="s2"&gt;/expected.txt"&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; tables present"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A passing run lists every table with its exact row count:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;bash pg-restore-check.sh
&lt;span class="go"&gt;billing.invoices 250
public.customers 300
public.empty_audit 0
public.orders 1000
PASS: 2026-09-29T14-06-30Z.dump restored, 4 tables present
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We broke it on purpose before trusting it. A dump truncated in the bucket fails the checksum. A truncated dump with a matching checksum, which is what the piping failure above produces, fails in pg_restore with &lt;code&gt;could not read from input file: end of file&lt;/code&gt;. A zero next to a table that should have rows is your cue to look closer. If your row-level security policies name roles, list them in &lt;code&gt;ROLES&lt;/code&gt; so the throwaway server has them; otherwise the policies fail to restore. The pg_restore guide linked above explains the flags the script uses and the errors you may meet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a year of daily dumps costs on S3, R2, and B2
&lt;/h2&gt;

&lt;p&gt;Say the compressed dump is 2 GB and you keep 30 dailies plus 12 monthlies: 42 copies, about 84 GB stored at any time.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Storage&lt;/th&gt;
&lt;th&gt;Per GB-month&lt;/th&gt;
&lt;th&gt;84 GB, per month&lt;/th&gt;
&lt;th&gt;Reading it back&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AWS S3 Standard&lt;/td&gt;
&lt;td&gt;$0.023&lt;/td&gt;
&lt;td&gt;about $1.93&lt;/td&gt;
&lt;td&gt;$0.09/GB after 100 GB/month free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare R2&lt;/td&gt;
&lt;td&gt;$0.015 (first 10 GB free)&lt;/td&gt;
&lt;td&gt;about $1.11&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backblaze B2&lt;/td&gt;
&lt;td&gt;$6.95/TB (first 10 GB free)&lt;/td&gt;
&lt;td&gt;about $0.51&lt;/td&gt;
&lt;td&gt;Free up to 3x what you store&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Storage is the small line. The one that grows is reading backups back out: a weekly restore check downloads a full dump each time, which is free on R2, free within B2's allowance, and billed as internet egress on S3 once you pass the free 100 GB a month. Upload requests cost fractions of a cent at one dump a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can pg_dump write directly to S3?&lt;/strong&gt;&lt;br&gt;
No. pg_dump writes to a file or to standard output, and you upload the result. Piping it straight into &lt;code&gt;aws s3 cp -&lt;/code&gt; works, but if pg_dump fails halfway the upload still completes and a truncated dump sits in the bucket under a normal-looking name. Dumping to a local file, reading it back in full with pg_restore, and then uploading avoids that at the cost of temporary disk space.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is pg_dump safe to run while the database is in use?&lt;/strong&gt;&lt;br&gt;
Yes. pg_dump reads everything from a single snapshot, so the dump is consistent even while writes continue, and it does not block reads or writes. It does take a light lock on each table it dumps, which blocks schema changes such as &lt;code&gt;ALTER TABLE&lt;/code&gt; on those tables until the dump finishes. On a large database, schedule it away from migrations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How often should I back up a PostgreSQL database with pg_dump?&lt;/strong&gt;&lt;br&gt;
Daily is the usual starting point: a bad day then costs at most a day of data. If that is too much, pg_dump is the wrong tool to run every few minutes; continuous WAL archiving (point-in-time recovery) is the next step. Whatever the schedule, restore-test the dumps regularly, because the schedule only tells you how much you could lose if the backups work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;p&gt;Checked 2026-09-29.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/app-pgdump.html" rel="noopener noreferrer"&gt;PostgreSQL docs: pg_dump&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/libpq-pgpass.html" rel="noopener noreferrer"&gt;PostgreSQL docs: the password file (.pgpass)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/predefined-roles.html" rel="noopener noreferrer"&gt;PostgreSQL docs: predefined roles (pg_read_all_data)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/sdkref/latest/guide/feature-ss-endpoints.html" rel="noopener noreferrer"&gt;AWS CLI docs: service-specific endpoints (AWS_ENDPOINT_URL)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lifecycle-mgmt.html" rel="noopener noreferrer"&gt;Amazon S3 docs: lifecycle configuration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_ExportSnapshot.html" rel="noopener noreferrer"&gt;Amazon RDS docs: exporting DB snapshot data to Amazon S3&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html" rel="noopener noreferrer"&gt;Amazon RDS docs: using SSL/TLS with a DB instance&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://neon.com/docs/manage/backup-pg-dump" rel="noopener noreferrer"&gt;Neon docs: back up with pg_dump&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.railway.com/databases/postgresql" rel="noopener noreferrer"&gt;Railway docs: PostgreSQL&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/FiloSottile/age" rel="noopener noreferrer"&gt;age, a file encryption tool&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html" rel="noopener noreferrer"&gt;systemd.timer: Persistent=&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#schedule" rel="noopener noreferrer"&gt;GitHub Docs: schedule event&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This first appeared on &lt;a href="https://backupdrill.com/guides/pg-dump-to-s3" rel="noopener noreferrer"&gt;backupdrill.com&lt;/a&gt;. I build BackupDrill, which does the restore-test part of this for Supabase projects, but everything above is plain Postgres and works without it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>aws</category>
      <category>database</category>
      <category>devops</category>
    </item>
    <item>
      <title>Roblox has three DevEx rates now. One constant won't cut it.</title>
      <dc:creator>SuperLede</dc:creator>
      <pubDate>Sun, 27 Sep 2026 08:49:55 +0000</pubDate>
      <link>https://dev.to/superlede/roblox-has-three-devex-rates-now-one-constant-wont-cut-it-12ld</link>
      <guid>https://dev.to/superlede/roblox-has-three-devex-rates-now-one-constant-wont-cut-it-12ld</guid>
      <description>&lt;p&gt;For years every DevEx calculator, spreadsheet and in-game earnings panel I saw did the same thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;DEVEX_RATE&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;span class="mi"&gt;0035&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was fine while there was one rate. Since June 2026 there are three, and they apply to the same balance at the same time. If your game shows creators or group members what their Robux are worth, a single constant is now wrong in both directions.&lt;/p&gt;

&lt;p&gt;Here's what changed and how I ended up modelling it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three rates
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rate&lt;/th&gt;
&lt;th&gt;USD per earned Robux&lt;/th&gt;
&lt;th&gt;Applies to&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Legacy&lt;/td&gt;
&lt;td&gt;$0.0035&lt;/td&gt;
&lt;td&gt;Robux you already held before Sep 5, 2025&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Standard&lt;/td&gt;
&lt;td&gt;$0.0038&lt;/td&gt;
&lt;td&gt;Robux earned since then&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;U.S. 18+&lt;/td&gt;
&lt;td&gt;$0.0054&lt;/td&gt;
&lt;td&gt;Certain sales to US players who verified they're 18+&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The standard rate went up on September 5, 2025. Roblox didn't convert old balances at the new rate, though. Anything you held before that date stays at $0.0035, and it cashes out first.&lt;/p&gt;

&lt;p&gt;The 18+ rate showed up on June 8, 2026. It only covers developer products, passes, subscriptions and private servers, bought by US players who verified their age, in an experience that meets Roblox's character rules (no R6 avatars). It's a second rate running next to the standard one. It doesn't replace it.&lt;/p&gt;

&lt;p&gt;So a real creator balance is often a mix of all three.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mixed balances
&lt;/h2&gt;

&lt;p&gt;Say someone has 75,000 earned Robux. 20,000 were sitting there before September 2025, 50,000 came in since, and 5,000 came from qualifying 18+ purchases.&lt;/p&gt;

&lt;p&gt;With one constant at the new rate you'd tell them $285. With the old one, $262.50. The real number is neither:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="mi"&gt;20000&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;span class="mi"&gt;0035&lt;/span&gt; &lt;span class="c1"&gt;-- legacy:    $70&lt;/span&gt;
    &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;50000&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;span class="mi"&gt;0038&lt;/span&gt; &lt;span class="c1"&gt;-- standard: $190&lt;/span&gt;
    &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;5000&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;span class="mi"&gt;0054&lt;/span&gt; &lt;span class="c1"&gt;-- us18:      $27&lt;/span&gt;
&lt;span class="c1"&gt;-- $287&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference looks small at this size. For a group paying out a few million Robux a quarter it isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rounding
&lt;/h2&gt;

&lt;p&gt;Roblox pays in USD, so I round to the cent at the end, not per part. Floats make that slightly more interesting than it sounds. 12,345 Robux at $0.0038 is 46.911, so you show $46.91. Going the other way, $100 needs &lt;code&gt;math.ceil(100 / 0.0038)&lt;/code&gt; = 26,316 Robux. 26,315 gives 99.997, which rounds to $100.00 on screen but is still under $100 gross.&lt;/p&gt;

&lt;p&gt;I don't know exactly how Roblox rounds on its end, so I treat these as gross estimates and say so in the UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 30,000 minimum
&lt;/h2&gt;

&lt;p&gt;You need 30,000 earned Robux for a first cashout. That's $114 at the standard rate. A check is trivial, but it's worth showing players how far they are from it rather than just a dollar figure.&lt;/p&gt;

&lt;h2&gt;
  
  
  A module instead of a constant
&lt;/h2&gt;

&lt;p&gt;I got tired of retyping this, so I put the rates, the history and the arithmetic into one ModuleScript. No HttpService, no yielding, just a table and five functions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;DevExRates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ReplicatedStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DevExRates&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;DevExRates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;robuxToUsd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;--&amp;gt; 380 (standard)&lt;/span&gt;
&lt;span class="n"&gt;DevExRates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;robuxToUsd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"us18"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;--&amp;gt; 162&lt;/span&gt;
&lt;span class="n"&gt;DevExRates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usdToRobux&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;--&amp;gt; 26316&lt;/span&gt;
&lt;span class="n"&gt;DevExRates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rateOn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"2024-01-15"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;--&amp;gt; 0.0035&lt;/span&gt;
&lt;span class="n"&gt;DevExRates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meetsMinimum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;28500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;--&amp;gt; false&lt;/span&gt;

&lt;span class="n"&gt;DevExRates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mixedBalanceUsd&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="n"&gt;legacy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;standard&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;us18&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="c1"&gt;--&amp;gt; 287&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;rateOn&lt;/code&gt; returns the standard rate in force on a given date, which is handy if you store historical earnings and want to show what they were worth at the time. It skips the 18+ tier on purpose, since that's a parallel rate and not a change to the standard one.&lt;/p&gt;

&lt;p&gt;The code and the JSON behind it are on GitHub: &lt;a href="https://github.com/supertj/devex-rates" rel="noopener noreferrer"&gt;supertj/devex-rates&lt;/a&gt;. Code is MIT, data is CC BY 4.0. There's a check script in CI that fails if the numbers in the Luau file drift from the JSON, so the two can't quietly disagree.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping it current
&lt;/h2&gt;

&lt;p&gt;The part that actually breaks things is Roblox changing the rate and nobody updating the constant. I re-check the rates against Roblox's docs at least every 45 days and update the repo when anything moves. The full history with sources is on &lt;a href="https://loottally.com/devex-rates" rel="noopener noreferrer"&gt;LootTally's DevEx rates page&lt;/a&gt;, which is where the data comes from.&lt;/p&gt;

&lt;p&gt;If you only need a one-off number and don't want to touch code, there's also a &lt;a href="https://loottally.com/devex-calculator" rel="noopener noreferrer"&gt;DevEx calculator&lt;/a&gt; that handles the mixed-balance case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things I'm still not sure about
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;How Roblox handles the 10am PT cutoff on September 5, 2025 for Robux that landed that morning. The module treats the whole day as the new rate.&lt;/li&gt;
&lt;li&gt;Whether there'll be more tiers. The 18+ rate reads like a first step, not a final shape.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've run into either of these with real payouts, I'd like to hear how it went.&lt;/p&gt;

</description>
      <category>roblox</category>
      <category>luau</category>
      <category>gamedev</category>
      <category>lua</category>
    </item>
    <item>
      <title>pg_restore finished fine. One of my tables wasn't there.</title>
      <dc:creator>SuperLede</dc:creator>
      <pubDate>Sun, 27 Sep 2026 07:26:19 +0000</pubDate>
      <link>https://dev.to/superlede/pgrestore-finished-fine-one-of-my-tables-wasnt-there-2kaj</link>
      <guid>https://dev.to/superlede/pgrestore-finished-fine-one-of-my-tables-wasnt-there-2kaj</guid>
      <description>&lt;p&gt;A restore test on a real 105-table Supabase database failed a few weeks ago. The tool doing it restores in a strict mode, so it stopped on the first error: a function with a parameter defaulting to &lt;code&gt;auth.uid()&lt;/code&gt;. Fixing that was quick. The interesting part came when I reproduced it locally with plain &lt;code&gt;pg_restore&lt;/code&gt;, the way most people test a backup by hand. There it didn't stop at all. It ran to the end, and one table simply wasn't there.&lt;/p&gt;

&lt;p&gt;Here's why, how to reproduce it in two minutes, and the few lines that prevent it.&lt;/p&gt;

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

&lt;p&gt;Supabase gives you &lt;code&gt;auth.uid()&lt;/code&gt;, a function that returns the id of the logged-in user. It's everywhere in Supabase schemas, and a very natural place to use it is a column default:&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;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;    &lt;span class="nb"&gt;serial&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;owner&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;body&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Insert a row from the client and &lt;code&gt;owner&lt;/code&gt; fills itself in. Nice.&lt;/p&gt;

&lt;p&gt;Now take a normal backup of your application schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pg_dump &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;custom &lt;span class="nt"&gt;--schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;public &lt;span class="nt"&gt;-f&lt;/span&gt; app.dump &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SUPABASE_DB_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and restore it somewhere that isn't Supabase, which is exactly what you do when you test a backup: a throwaway &lt;code&gt;postgres&lt;/code&gt; container.&lt;/p&gt;

&lt;h2&gt;
  
  
  What goes wrong
&lt;/h2&gt;

&lt;p&gt;Plain Postgres has no &lt;code&gt;auth&lt;/code&gt; schema. So when pg_restore gets to &lt;code&gt;CREATE TABLE public.notes (... default auth.uid() ...)&lt;/code&gt;, the statement fails. The table isn't created. A few lines later the &lt;code&gt;COPY&lt;/code&gt; into &lt;code&gt;public.notes&lt;/code&gt; fails too, because there's no table to copy into. And then pg_restore keeps going, because by default it reports errors and carries on.&lt;/p&gt;

&lt;p&gt;You end up with every other table, all their rows, and no &lt;code&gt;notes&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The error lines are in the output, but they're easy to wave through. Anyone who has restored a Supabase dump into vanilla Postgres has learned to expect a pile of complaints about roles and schemas that "only exist on Supabase", and the tempting rule is to ignore everything mentioning &lt;code&gt;auth&lt;/code&gt;. I had written roughly that rule into my own notes. It's wrong for this case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

&lt;p&gt;Two containers, one pretending to be Supabase:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; src &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pw postgres:17
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; rt  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pw postgres:17
&lt;span class="c"&gt;# wait a few seconds for both to start&lt;/span&gt;

docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; src psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;SQL&lt;/span&gt;&lt;span class="sh"&gt;'
create schema auth;
create function auth.uid() returns uuid language sql stable as &lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="sh"&gt; select null::uuid &lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="sh"&gt;;
create table public.notes (id serial primary key, owner uuid default auth.uid(), body text);
insert into public.notes (body) select 'n' || g from generate_series(1, 500) g;
create table public.plain (id int);
insert into public.plain select generate_series(1, 100);
&lt;/span&gt;&lt;span class="no"&gt;SQL

&lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;src pg_dump &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;custom &lt;span class="nt"&gt;--schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;public &lt;span class="nt"&gt;-f&lt;/span&gt; /tmp/app.dump postgres
docker &lt;span class="nb"&gt;cp &lt;/span&gt;src:/tmp/app.dump &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker &lt;span class="nb"&gt;cp &lt;/span&gt;app.dump rt:/tmp/app.dump

docker &lt;span class="nb"&gt;exec &lt;/span&gt;rt pg_restore &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;--no-owner&lt;/span&gt; &lt;span class="nt"&gt;--no-privileges&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; postgres /tmp/app.dump
docker &lt;span class="nb"&gt;exec &lt;/span&gt;rt psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"select to_regclass('public.notes'), (select count(*) from public.plain)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What I get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;pg_restore: error: could not execute query: ERROR:  schema "auth" does not exist
pg_restore: error: could not execute query: ERROR:  relation "public.notes" does not exist
&lt;/span&gt;&lt;span class="c"&gt;...
&lt;/span&gt;&lt;span class="go"&gt; to_regclass | count
-------------+-------
             |   100
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;plain&lt;/code&gt; restored with all 100 rows. &lt;code&gt;notes&lt;/code&gt; doesn't exist.&lt;/p&gt;

&lt;p&gt;The same thing happens to a function whose parameter defaults to &lt;code&gt;auth.uid()&lt;/code&gt;, like &lt;code&gt;create function is_admin(p_user_id uuid default auth.uid())&lt;/code&gt;, which is the one that tripped the strict restore in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Before restoring, give the sandbox cheap stand-ins for what Supabase would have provided:&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;create&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;  &lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt; &lt;span class="k"&gt;stable&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="s1"&gt;'select null::uuid'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;role&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;  &lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt; &lt;span class="k"&gt;stable&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="s1"&gt;'select null::text'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;  &lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt; &lt;span class="k"&gt;stable&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="s1"&gt;'select null::text'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt; &lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt; &lt;span class="k"&gt;stable&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="s1"&gt;'select null::jsonb'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the same restore again and &lt;code&gt;notes&lt;/code&gt; comes back with all 500 rows.&lt;/p&gt;

&lt;p&gt;Two details matter here.&lt;/p&gt;

&lt;p&gt;First, don't create &lt;code&gt;auth.users&lt;/code&gt;. An empty users table would make every foreign key into it fail in a way that looks like your data is broken, when really the sandbox just doesn't have your users. Errors about &lt;code&gt;auth.users&lt;/code&gt; are the ones that genuinely are sandbox noise.&lt;/p&gt;

&lt;p&gt;Second, change what you ignore. The useful rule isn't "ignore anything mentioning auth". It's: errors about &lt;code&gt;auth.users&lt;/code&gt; are expected, and an error saying a role or an &lt;code&gt;auth&lt;/code&gt; function doesn't exist means a stub is missing and something didn't restore.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check that would have caught it anyway
&lt;/h2&gt;

&lt;p&gt;Count things. After any test restore, compare the number of tables and the row counts of your two or three most important tables against production:&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="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;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="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;select&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="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A restore that "worked" but is missing a table fails this in five seconds. The exit code won't tell you much here: pg_restore returns non-zero on any error, including the harmless ones every Supabase restore produces, so most people learn to ignore it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this ended up
&lt;/h2&gt;

&lt;p&gt;I build a small backup tool for Supabase, and restore-testing is the part I care most about, so this bug got a proper fix: the drill sandbox now creates these stand-ins before every restore and is strict about which errors count as noise. The CLI is MIT and does the whole drill locally if you want it: &lt;a href="https://github.com/backupdrill/cli" rel="noopener noreferrer"&gt;github.com/backupdrill/cli&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you'd rather do it by hand, the step-by-step version is in &lt;a href="https://backupdrill.com/guides/test-supabase-backup-restore" rel="noopener noreferrer"&gt;this restore-testing guide&lt;/a&gt;, and a stricter two-pass restore that separates schema-and-data errors from index and constraint errors is in the &lt;a href="https://backupdrill.com/guides/supabase-manual-backup" rel="noopener noreferrer"&gt;manual backup guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Either way, test the restore, and count what comes back.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>database</category>
      <category>devops</category>
    </item>
    <item>
      <title>I checked 1,000 YouTube channels to see which ones look monetized. Here's what the data says, and what it can't.</title>
      <dc:creator>SuperLede</dc:creator>
      <pubDate>Sat, 05 Sep 2026 16:59:36 +0000</pubDate>
      <link>https://dev.to/superlede/i-checked-1000-youtube-channels-to-see-which-ones-look-monetized-heres-what-the-data-says-and-4k0j</link>
      <guid>https://dev.to/superlede/i-checked-1000-youtube-channels-to-see-which-ones-look-monetized-heres-what-the-data-says-and-4k0j</guid>
      <description>&lt;p&gt;In November 2023 YouTube stopped exposing a public flag that said whether a channel was in the Partner Program. Every tool that answered "is this channel monetized" was reading that flag. They all still answer the question. None of them will tell you they're now guessing.&lt;/p&gt;

&lt;p&gt;I built one of those tools, so I want to lay out what mine actually reads, where it fails, and what happened when I pointed it at 1,000 channels across 30 countries.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can and can't see from outside
&lt;/h2&gt;

&lt;p&gt;"Monetized" means the channel is in the Partner Program. There are two tiers:&lt;/p&gt;

&lt;p&gt;Fan funding needs 500 subscribers, 3 public uploads in 90 days, and either 3,000 watch hours in a year or 3M Shorts views in 90 days. It unlocks memberships, Super Thanks and the merch shelf.&lt;/p&gt;

&lt;p&gt;Ad revenue needs 1,000 subscribers and either 4,000 watch hours in a year or 10M Shorts views in 90 days.&lt;/p&gt;

&lt;p&gt;Watch hours are private. Subscriber count usually isn't. So from outside you can see the eligibility line but not whether the creator applied, got in, or got kicked out later. Anyone quoting an accuracy percentage is quoting a number they have no way to compute.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five things I read
&lt;/h2&gt;

&lt;p&gt;Every check returns these five as pass, fail or unknown, and the result page shows all five with a sentence each.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Subscriber count&lt;/strong&gt;, from the official API. It's a clue about eligibility, nothing more. A 40,000-subscriber channel can be unmonetized and a 900-subscriber one can be in the fan funding tier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Join button.&lt;/strong&gt; Memberships are Partner Program only, so if it's there the channel is in. If it's missing that means almost nothing, because plenty of monetized channels never turn memberships on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ads on recent long-form uploads.&lt;/strong&gt; I sample several recent long videos and check whether ads are actually being served. Strongest signal for the ad revenue tier, with a caveat I'll get to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Merch shelf.&lt;/strong&gt; Also Partner Program only, also opt-in, also needs a linked store. Present is strong evidence, absent is normal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 500-subscriber floor.&lt;/strong&gt; Below it the channel can't be in the program at all. This is the one signal whose failure is a real negative.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap
&lt;/h2&gt;

&lt;p&gt;Since November 2020, YouTube runs ads on videos from channels that are not in the Partner Program. The creator gets nothing from those ads.&lt;/p&gt;

&lt;p&gt;So "I saw a pre-roll" proves nothing on its own, and any checker that only looks for ads will confidently call an unmonetized channel monetized. I require ads on at least two thirds of the videos I successfully sampled, and a channel with one lucky pre-roll doesn't clear that.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the verdict comes out
&lt;/h2&gt;

&lt;p&gt;Evidence has an order. Partner-Program-only features first, then ad delivery, then subscriber counts. Subscriber counts never override a product signal: if a channel shows a Join button at 480 subscribers, the Join button wins.&lt;/p&gt;

&lt;p&gt;Four outcomes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Likely monetized&lt;/strong&gt; — enough subscribers, memberships on, ads running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Possibly monetized&lt;/strong&gt; — one Partner Program feature or the ad signal is there, but the set isn't complete. Usually memberships on, ads not detected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Likely not monetized&lt;/strong&gt; — no positive signal at all, and either under 500 subscribers or clearing 1,000 with every readable product signal negative.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can't tell&lt;/strong&gt; — the evidence is missing or points both ways. I say so instead of picking.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two rules that took the longest to get right
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Unknown is not fail.&lt;/strong&gt; A blocked fetch, a hidden subscriber count, a channel with no long-form uploads: none of those are evidence of anything. They stay unknown and never push a verdict toward "not monetized". My first version got this wrong and produced confident false negatives for channels that were merely hard to read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failed samples stay out of the denominator.&lt;/strong&gt; If I try five videos and one loads, "1 of 1 has ads" is not a 100% ad rate, it's a sample of one. Below two successful fetches the ad signal stays unknown.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 1,000 channels looked like
&lt;/h2&gt;

&lt;p&gt;Guessing about aggregate behaviour is easy, so I measured it. For each of 30 countries I asked the API for the most popular videos right now, took the channels behind them, and checked 35 per country.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Country&lt;/th&gt;
&lt;th&gt;Look monetized&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Greece, Hungary&lt;/td&gt;
&lt;td&gt;51%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brazil, Romania&lt;/td&gt;
&lt;td&gt;49%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;France&lt;/td&gt;
&lt;td&gt;46%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Germany, Thailand&lt;/td&gt;
&lt;td&gt;40%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UK&lt;/td&gt;
&lt;td&gt;26%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;US&lt;/td&gt;
&lt;td&gt;23%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Japan&lt;/td&gt;
&lt;td&gt;20%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Korea&lt;/td&gt;
&lt;td&gt;9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;India&lt;/td&gt;
&lt;td&gt;3%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things surprised me. The US is nowhere near the top, despite being the market everyone assumes is most monetized. And the spread is enormous for channels that are all, by construction, big enough to be trending.&lt;/p&gt;

&lt;p&gt;I can't fully explain the US number from outside, which is the honest answer. My guess is that US trending skews to music and network content where memberships and merch simply aren't used, but I can't prove that, so it stays a guess in public too.&lt;/p&gt;

&lt;p&gt;The other number worth stating: I couldn't tell either way for 18% of channels. That's the number a tool selling certainty would quietly drop.&lt;/p&gt;

&lt;p&gt;Full table, method and the CSV are here: &lt;a href="https://monetizationkit.com/data/youtube-monetized-channels-by-country/" rel="noopener noreferrer"&gt;https://monetizationkit.com/data/youtube-monetized-channels-by-country/&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  One engineering note, and a bug worth stealing
&lt;/h2&gt;

&lt;p&gt;The first version read video data from YouTube's internal player endpoint. That's now gated by proof-of-origin tokens for automated callers, so it reads the public watch page instead. Slower, messier, doesn't pretend to be a browser.&lt;/p&gt;

&lt;p&gt;The bug I'd point at, because I suspect it's common: I had a rule saying a forced refresh must not overwrite good cached data with a degraded result. Sensible. But it also blocked the very first write for a channel that had never been cached, so every channel whose signals I couldn't read was silently dropped. The dataset could only ever show three of the four verdicts, and it made the tool look more confident than it was. Data that never gets written is much harder to notice than data that's wrong.&lt;/p&gt;

&lt;p&gt;The second one, same flavour: my collection run showed ad-fetch failures jumping from 1.2% in the first hour to 30% in the second. I read that as "these channels are unreadable" and nearly published it. It was my own rate limiting. Slowing down and re-running recovered nine in ten. If you scrape for a dataset, plot your failure rate against time before you believe any of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd like torn apart
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Is there a public signal I'm missing? Super Thanks and Super Chat are Partner Program features too. I surface them where visible but don't score them.&lt;/li&gt;
&lt;li&gt;Is one middle tier clearer than two for non-technical creators, or is "possibly" doing too much work?&lt;/li&gt;
&lt;li&gt;If you run a channel: does the breakdown match what Studio shows you? That's the only real validation this can get.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The checker is free and needs no login: &lt;a href="https://monetizationkit.com/" rel="noopener noreferrer"&gt;https://monetizationkit.com/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>youtube</category>
      <category>showdev</category>
    </item>
    <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>
