<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Reeve]]></title><description><![CDATA[Cross-posts from the Reeve blog on Supabase backups, API keys in the frontend and Row Level Security, for apps built with Lovable, Bolt and Cursor.]]></description><link>https://reeve-ai.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ac40b39199ba2600f6a96aa/544a6a62-6eac-4b3b-bbe9-3d1efda1b897.png</url><title>Reeve</title><link>https://reeve-ai.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 08 Oct 2026 11:06:27 GMT</lastBuildDate><atom:link href="https://reeve-ai.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Free Supabase backup with a GitHub Action, and the catch]]></title><description><![CDATA[Originally published at https://reeve.page/blog/supabase-backup-github-action
Your Supabase project is on the free plan, so nothing is being copied anywhere, or it is on Pro and every copy it takes li]]></description><link>https://reeve-ai.hashnode.dev/free-supabase-backup-with-a-github-action-and-the-catch</link><guid isPermaLink="true">https://reeve-ai.hashnode.dev/free-supabase-backup-with-a-github-action-and-the-catch</guid><category><![CDATA[supabase]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[GitHub Actions]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Backup]]></category><dc:creator><![CDATA[Vlad Tkachenko]]></dc:creator><pubDate>Tue, 06 Oct 2026 17:10:49 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ac40b39199ba2600f6a96aa/73f5bba8-6070-4e26-b314-24f261ba1f4e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Originally published at <a href="https://reeve.page/blog/supabase-backup-github-action">https://reeve.page/blog/supabase-backup-github-action</a></p>
<p>Your Supabase project is on the free plan, so nothing is being copied anywhere, or it is on Pro and every copy it takes lives inside the account you are worried about losing. In a thread about exactly that, somebody said the answer is a Supabase backup GitHub Action: one small file in a repository that dumps your database every night and costs nothing.</p>
<p>They were right. Supabase publishes the workflow itself, and for a lot of apps it is the thing to set up this week. Here is the part those threads leave out: <strong>it costs no money, and it still has a price.</strong> Every run downloads your whole database, and Supabase meters that download.</p>
<p>Think of it as the data on a phone contract. Your plan comes with a monthly allowance, everything your app does draws on it, and a backup is a large download on the same plan. On the free plan, a nightly copy of a database near its size limit uses the whole month's allowance in about ten days. That, and a connection string GitHub cannot reach, are the two things between you and a working backup, and neither is on Supabase's page.</p>
<h2>Can you back up Supabase with a GitHub Action for free?</h2>
<p>Yes, and Supabase documents how. Its page on <a href="https://supabase.com/docs/guides/deployment/ci/backups">automated backups using GitHub Actions</a> gives a workflow that installs the Supabase CLI, dumps your roles, your schema and your data into three files, and commits them back into the repository on a schedule.</p>
<p>GitHub does not charge for it either, within limits. A private repository on GitHub Free gets <a href="https://docs.github.com/en/billing/concepts/product-billing/github-actions">2,000 Actions minutes a month</a>, and a nightly dump of a small database uses a small part of them.</p>
<p>What you get is a copy that leaves Supabase every night and lands on another company's servers. That is the one thing Supabase's own backups cannot give you, because the daily copies on a paid plan <a href="https://reeve.page/blog/download-your-supabase-backup">stay inside the account they protect</a>.</p>
<p>It is the right answer when three things are true. Your app already lives in a GitHub repository, which it does if you switched on GitHub sync in Lovable or a builder like it. Editing a YAML file does not worry you. And your database is small next to your plan's egress allowance. You know the first two already. The third is arithmetic, and it has its own section below.</p>
<h2>The workflow, with the schedule and the secret</h2>
<p>Save this as <code>.github/workflows/supabase-backup.yml</code> in a private repository that holds nothing else:</p>
<pre><code class="language-yaml">name: supabase-backup

on:
  schedule:
    - cron: '17 3 * * *' # every day at 03:17 UTC
  workflow_dispatch:

jobs:
  backup:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    env:
      SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }}
    steps:
      - uses: actions/checkout@v7
      - uses: supabase/setup-cli@v3
        with:
          version: latest
      - name: Back up roles
        run: supabase db dump --db-url "$SUPABASE_DB_URL" -f roles.sql --role-only
      - name: Back up schema
        run: supabase db dump --db-url "$SUPABASE_DB_URL" -f schema.sql
      - name: Back up data
        run: &gt;
          supabase db dump --db-url "$SUPABASE_DB_URL" -f data.sql --use-copy --data-only
          -x "storage.buckets_vectors" -x "storage.vector_indexes"
      - uses: stefanzweifel/git-auto-commit-action@v7
        with:
          commit_message: Supabase backup
</code></pre>
<p>It is Supabase's workflow with three changes.</p>
<ul>
<li><strong>It runs on the schedule and when you press the button, and at no other time.</strong> Supabase's version also runs on every push and every pull request to <code>main</code>. Each run is a full download of your database, so in a repository that also holds your app, every change you shipped would spend a backup's worth of allowance.</li>
<li><strong>It runs at 03:17, off the hour.</strong> GitHub says scheduled runs <a href="https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule">can be delayed at the start of every hour</a>, and that under enough load some queued jobs are dropped. Supabase's example uses <code>0 0 * * *</code>, which is on the hour. Times are UTC unless you add a <code>timezone</code>, and any minute other than <code>0</code> will do.</li>
<li><strong>The data line matches Supabase's current <a href="https://supabase.com/docs/guides/platform/migrating-within-supabase/backup-restore">backup and restore guide</a></strong>, which adds two <code>-x</code> exclusions the workflow page does not have. The files you keep are then the ones Supabase's restore instructions are written for.</li>
</ul>
<p>Then the secret. In the repository, open Settings, then Secrets and variables, then Actions, and add a repository secret called <code>SUPABASE_DB_URL</code>. What goes into it decides whether any of this works, so it gets the next section to itself.</p>
<p>Once it is saved, run the workflow by hand from the Actions tab, so the first run happens while you are watching. <code>workflow_dispatch</code> is the line that gives you the Run workflow button.</p>
<h2>Which connection string goes in the secret?</h2>
<p>The Session pooler string, from the Connect button at the top of your Supabase project. Use it even though Supabase's workflow page shows the direct connection in its example.</p>
<p>The reason is IPv6, the newer kind of internet address. Supabase's direct connection, the host that begins <code>db.</code> and ends <code>supabase.co</code>, <a href="https://supabase.com/docs/guides/platform/ipv4-address">uses IPv6 by default</a>, and the same page lists GitHub Actions among the services that only accept IPv4. The runner is handed an address it has no route to, and the first dump fails before a byte is written. The error says the host name could not be translated, or that the network is unreachable, often with a long address full of colons printed beside it. That address is the IPv6 one. It reads like a wrong password or a database that is down, and the real cause is a network the runner is not on.</p>
<p>Supabase's backup and restore guide says to use the Session pooler string by default, and the direct one only if your network supports IPv6 or you pay for the IPv4 add-on. You can recognise the pooler string by three things: its user name is <code>postgres.</code> followed by your project's reference, its host ends in <code>pooler.supabase.com</code>, and its port is <code>5432</code>.</p>
<p><img src="https://reeve.page/blog/supabase-backup-github-action/which-string-reaches-the-runner-1600x620.png" alt="A GitHub runner on the left with two routes out of it. The upper route carries the direct host db.[ref].supabase.co, is marked IPv6, and stops at a wall with a cross before the database. The lower route carries the pooler.supabase.com host, is marked IPv4, and reaches the database with a tick." /></p>
<p><em>The runner has no route to the direct connection's IPv6 address. The Session pooler answers on IPv4 and reaches the same database.</em></p>
<p>The password in it is your database password, the one set when the project was created, and not an API key. If nobody wrote it down, Supabase lets you reset it under Database Settings; anything else that connects with the old one stops working until you give it the new one.</p>
<p>That string can read and change every row in your database. It lives in the secret and nowhere else: never in the workflow file, never in a commit message, and never pasted into an AI assistant along with the error you were asking about.</p>
<h2>Does a Supabase backup count against my egress?</h2>
<p>Yes, all of it. Supabase counts as <a href="https://supabase.com/docs/guides/platform/manage-your-usage/egress">egress</a> the data any of its services sends out, and a dump through the pooler is filed as Shared Pooler Egress inside the same allowance as your API traffic, your file downloads and everything else your app serves.</p>
<p>Back to the phone contract. The allowance resets each billing month, every service your app uses draws on it, and a backup is one more large download. It is also a family plan: Supabase applies the quota to <a href="https://supabase.com/docs/guides/platform/billing-on-supabase">your whole organization</a>, so every project in it draws on the one allowance.</p>
<p>On 26 September 2026, Supabase's pricing page gave the free plan 5 GB of egress a month and a 500 MB limit on each project's database, and the Pro plan 250 GB of egress. There is a second 5 GB on the free plan for cached egress, which covers files served from Supabase's CDN, and a backup never touches it. The other free plan limits, and <a href="https://reeve.page/blog/supabase-free-plan-limits">what each one does when you cross it</a>, have an article of their own.</p>
<p>Going past the allowance on the free plan does not produce a bill. Supabase <a href="https://supabase.com/docs/guides/platform/billing-faq">notifies you and gives you a grace period</a>, and if you keep going over, it restricts every project in the organization. It says that can mean API requests answered with a 402 error, a database switched to read-only, or projects paused. For an app with customers, that is an outage your backup started.</p>
<p>This is true of every backup that leaves Supabase. A copy held outside the account has to be downloaded to get there.</p>
<h2>How often should the backup run?</h2>
<p>As often as your allowance pays for, and that comes down to one multiplication: your database size, times the runs in a month.</p>
<p>Supabase shows your database size on the Database report, under Observability in your project. A dump is not exactly that size. It leaves out indexes, which are rebuilt from their definitions, and it writes every value out as text. It is close enough to plan with, and it is the number you can look up.</p>
<table>
<thead>
<tr>
<th>Database size</th>
<th>Daily (30 runs)</th>
<th>Twice a week</th>
<th>Weekly</th>
</tr>
</thead>
<tbody><tr>
<td>50 MB</td>
<td>1.5 GB</td>
<td>0.4 GB</td>
<td>0.2 GB</td>
</tr>
<tr>
<td>100 MB</td>
<td>3 GB</td>
<td>0.9 GB</td>
<td>0.4 GB</td>
</tr>
<tr>
<td>250 MB</td>
<td>7.5 GB</td>
<td>2.2 GB</td>
<td>1.1 GB</td>
</tr>
<tr>
<td>500 MB</td>
<td>15 GB</td>
<td>4.3 GB</td>
<td>2.2 GB</td>
</tr>
</tbody></table>
<p>A month is about 4.3 weeks, which is where the last two columns come from.</p>
<p>On the free plan, the bottom two daily figures spend more than the whole month's allowance before your app has answered a single request. A daily schedule uses the full 5 GB at about 160 MB, and your app's own traffic comes out of the same allowance, so read last month's egress on your organization's Usage page before you pick. Weekly fits at every size the free plan allows.</p>
<p><img src="https://reeve.page/blog/supabase-backup-github-action/thirty-copies-four-copies-1600x680.png" alt="A database with a repeat mark beside two columns of blocks: thirty copies in a month on the left, four on the right. A dashed line marks the monthly allowance. The left column passes it a third of the way up and its blocks above the line are amber. The right column stays well below it, in teal." /></p>
<p><em>A database at the free plan's 500 MB limit, backed up daily and weekly. The dashed line is the free plan's 5 GB of egress for the month.</em></p>
<p>The cron line is the only thing you change:</p>
<pre><code>17 3 * * *      every day at 03:17 UTC
17 3 * * 1,4    Mondays and Thursdays
17 3 * * 0      Sundays
</code></pre>
<p>On Pro the arithmetic mostly stops mattering. 250 GB a month pays for a daily dump of one project up to about 8 GB, which is the disk space the Pro plan includes per project.</p>
<h2>Why does my workflow fail with a pg_dump version error?</h2>
<p>Because the <code>pg_dump</code> it ran is older than your database. That only happens to a workflow that calls <code>pg_dump</code> directly rather than through the Supabase CLI. The CLI brings its own <code>pg_dump</code>, which is one reason the workflow above uses it.</p>
<p>GitHub's <code>ubuntu-latest</code> runner comes with <a href="https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2404-Readme.md">PostgreSQL 16 installed</a>. Supabase projects can run Postgres 17, and Postgres documents that <code>pg_dump</code> <a href="https://www.postgresql.org/docs/current/app-pgdump.html">will not dump from a server newer than its own major version</a>, refusing rather than risking a bad file. The run stops with:</p>
<pre><code>pg_dump: error: aborting because of server version mismatch
pg_dump: detail: server version: 17.…; pg_dump version: 16.…
</code></pre>
<p>Your project's version is under Project Settings, then General, if you want to confirm it. Nothing about your credentials is wrong.</p>
<p>The fix is two steps. Add PostgreSQL's own package repository so the runner can install the version 17 client, then call that client by its full path:</p>
<pre><code class="language-yaml">- name: Install the Postgres 17 client
  run: |
    sudo apt-get install -y postgresql-common
    sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
    sudo apt-get install -y postgresql-client-17
- name: Back up
  run: /usr/lib/postgresql/17/bin/pg_dump "$SUPABASE_DB_URL" …
</code></pre>
<p>Keep the arguments you already had after the connection string. The full path matters: on Ubuntu, plain <code>pg_dump</code> is a wrapper that picks a version for you, and the runner already has a PostgreSQL 16 installation for it to pick.</p>
<p>The CLI's own <code>pg_dump</code> has a version as well, and it is not read from your project. With no <code>supabase/config.toml</code> beside the workflow, which is the case in a repository that holds nothing else, the CLI picks <code>pg_dump</code> 17. On a project still running Postgres 15, the <code>data.sql</code> it writes then carries <code>SET transaction_timeout = 0</code>, a setting Postgres 15 does not have, and the replay stops on that line in any Postgres 15 database, the project the file came from included. We reproduced it on 4 October 2026 with <code>pg_dump</code> 17.11 and Postgres 15.19. If your project is on 15, add one line to the job's <code>env</code> block so the CLI runs the matching <code>pg_dump</code>:</p>
<pre><code class="language-yaml">env:
  SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }}
  SUPABASE_DB_MAJOR_VERSION: '15'
</code></pre>
<p>The backup itself runs without an error either way, so without that line the mismatch only shows on the day the file is replayed.</p>
<h2>Can I commit the backup to my repository?</h2>
<p>To a private one, yes. That is what Supabase's workflow does, and its page says twice that you should never back up your data to a public repository.</p>
<p>Three things about a repository as a home for a database, none of them on that page:</p>
<ul>
<li><strong>Everyone who can read the repository can read your users.</strong> <code>data.sql</code> holds every row: email addresses, names, whatever your tables keep, and <a href="https://reeve.page/blog/supabase-backup-auth-users">your accounts as well</a>. That is why the workflow gets a repository of its own with nobody else on it, rather than the one your app's code lives in, which your builder may sync and a freelancer may one day be invited to.</li>
<li><strong>Every night's copy stays in the history.</strong> Git keeps every version of every file. When a customer asks you to delete their account, their row is still in every earlier commit of that repository until you rewrite its history.</li>
<li><strong>It stops working at 100 MiB.</strong> GitHub warns about files over 50 MiB and <a href="https://docs.github.com/en/repositories/working-with-files/managing-large-files/about-large-files-on-github">blocks files over 100 MiB</a>, and the same page says Git is not designed to handle large SQL files. The night <code>data.sql</code> crosses that line, the commit step fails, and every run after it fails the same way.</li>
</ul>
<p>As you get close, the same workflow can upload the three files to a storage bucket you own instead of committing them.</p>
<h2>What does the workflow not copy?</h2>
<p>Your uploaded files. Supabase Storage keeps each file outside the database and a row describing it inside, so the dump carries the row and not the picture. <a href="https://reeve.page/blog/supabase-storage-backup">Backing up Storage</a> is a job of its own, on every route there is.</p>
<p>Your users are in it. The data dump walks the <code>auth</code> schema where your accounts live, and searching <code>data.sql</code> for <code>auth.users</code> shows them. The project around the database is not: Edge Functions, auth provider settings, API keys and secrets are configuration rather than data, and <a href="https://reeve.page/blog/download-your-supabase-backup">the list of what neither copy holds</a> is the one to read before you need it.</p>
<h2>Does a green run mean the backup worked?</h2>
<p>It means the job finished without an error, which is a smaller claim. Three quite different results end in the same green tick:</p>
<ol>
<li>A good dump of the right project.</li>
<li>A dump of the wrong project, because the secret holds the string for a staging copy.</li>
<li>A dump with no rows in it, because somebody edited the data line and <code>--data-only</code> went missing, which turns it back into the schema dump.</li>
</ol>
<p>One result leaves no mark at all. A scheduled run GitHub drops under load does not show up as a failure, because there is no run to fail. And when a run does fail, GitHub sends the notice to <a href="https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule">the person who last edited the cron line</a>. If a freelancer set this up for you, the email about your backup goes to them.</p>
<p><img src="https://reeve.page/blog/supabase-backup-github-action/green-run-short-file-1600x740.png" alt="A finished job marked with a green tick. One line leaves it and forks into two identical file panels. The upper file's rows are all filled. The lower file's rows stop after two and the rest are empty dashed outlines." /></p>
<p><em>Both files came out of a run that finished without an error. Nothing in the run tells you which one you have.</em></p>
<p>A restore is the only thing that tells them apart. Replay the three files into a throwaway Supabase project or a local one, compare the row counts of the tables you care about with the live ones, and sign in as a real user. <a href="https://reeve.page/blog/how-to-restore-a-supabase-backup">How to restore a Supabase backup</a> has the commands, in the order Supabase gives them. Do it once now, and again every time you change the workflow.</p>
<p>You can also have every run do the restore and the counting for you. We published a version of this workflow as an open-source GitHub Action, <a href="https://github.com/Reeve-page/supabase-backup-action">Reeve-page/supabase-backup-action</a>. It takes the same three dumps, keeps the copy as a workflow artifact or in an S3 or R2 bucket, then replays it into a throwaway Supabase database on the runner and compares the row count of every table with the file. The run goes green only when every table came back with the rows the file holds:</p>
<pre><code class="language-yaml">- uses: Reeve-page/supabase-backup-action@v1
  with:
    db-url: ${{ secrets.SUPABASE_DB_URL }}
</code></pre>
<p>It is free and MIT-licensed. Every run still downloads the whole database, so the egress arithmetic above applies to it unchanged, and the sign-in is still yours to test.</p>
<h2>When should I stop doing this myself?</h2>
<p>When the arithmetic stops working, when the file outgrows the repository, or when nobody is restoring the copies. Any one of them is enough.</p>
<ul>
<li><strong>Your database has outgrown the free allowance.</strong> Pro raises egress to 250 GB and adds Supabase's own daily backups, kept for seven days, which restore with a button. Keep the workflow running beside them, because those copies live inside the account.</li>
<li><strong><code>data.sql</code> is heading for 100 MiB.</strong> Moving it to a bucket is more YAML, and more for somebody to keep working.</li>
<li><strong>Nobody has restored a copy.</strong> The workflow will go on writing files whether or not they open.</li>
<li><strong>Your users upload things.</strong> The workflow does not reach them, and the job that does is a second one to keep alive.</li>
</ul>
<h2>What to do this week</h2>
<ul>
<li>Look up your database size and last month's egress, and pick the schedule from the table before you write the cron line.</li>
<li>Create a private repository that holds nothing but the workflow, and put the Session pooler string in its <code>SUPABASE_DB_URL</code> secret.</li>
<li>If you started from Supabase's example, delete the push and pull request triggers and move the cron off the hour.</li>
<li>Run it once by hand from the Actions tab, then search <code>data.sql</code> for <code>auth.users</code> and for a table you know has rows.</li>
<li>Restore one copy into a throwaway project and sign in as a real user.</li>
<li>Copy your Storage files on a job of their own.</li>
</ul>
<p>Before you close this tab, open your organization's Usage page in Supabase and read last month's egress. That figure, next to your database size, picks your cron line.</p>
]]></content:encoded></item></channel></rss>