This stops its checks, takes down its status page and Wall listing, and permanently removes its history, alerts and AI spend data. This can't be undone.

↑ ↓ to move · Enter to run · Esc to close

Frankenstack — unified cockpit for chaotic stacks Frankenstack

Project Hub

More

Your free backend falls asleep after 7 days. Here's how I caught mine.

Last Tuesday I opened a side project I had not touched in a month. The login button did nothing. Not broken — silent. The network tab told the truth: my Supabase project had been paused for weeks, every visitor had been hitting a corpse, and I learned about it the way you learn about everything in this trade: too late.

This is a post about the rule that did it, the 20-line GitHub Action I shipped to stop it, and the trap hiding inside the fix. Everything is copy-paste. It is yours.

The rule nobody reads

Free tiers are not free trials — they are free tours. Supabase pauses free-plan projects that show low activity over a 7-day period. The contract in their docs is specific about the currency: a project counts as inactive if it does not receive sufficient user database activity over the past week — typically a few requests reaching the database each day is enough to stay awake. You signed that contract during an eight-second signup flow, and nobody ever reads the tour terms.

Supabase does send two emails before pausing: a warning and a confirmation. Mine are somewhere between the promotions tab and the void. After the pause you get a 1-year window to restore the project from the dashboard — and on the free plan there are no backups waiting for you when that window closes. So the pause is survivable, but only if you notice it. Your app will not tell you.

The fix I actually shipped

On one of my real projects (sdsflow.co.uk, a COSHH compliance tool) I put a keepalive into GitHub Actions. It pings the Supabase REST endpoint once a day — the docs say a few database requests a day is typically enough, and I did not want a cron firing twelve times a day at a database that gets read once a week. Three design decisions matter more than the code:

1. Ping the API, not the homepage. Pausing is about user database activity — real queries reaching the database, not visits to your site. A cron hitting your landing page keeps nothing alive. A request to /rest/v1/ with your anon key is the cheapest probe that reaches the REST layer — but a sparse, read-only ping is the weakest possible keepalive, and I learned that the hard way (postscript below). The belt-and-braces version makes the ping run a real database write, like an upsert into a one-row keepalive table.

2. Fail loud when config is missing. A keepalive that silently succeeds is the most dangerous kind. If the repo secrets are absent, my workflow exits with an error and a red X on the Actions tab — not a green checkmark for a ping that never happened.

3. Treat 5xx as a warning, not a success. A paused database does not answer 404. It answers 502 or 503 — a server error that looks transient but is not. The workflow prints a warning either way, so the Actions history becomes a poor man's uptime log.

Steal this workflow

Drop this in .github/workflows/supabase-keepalive.yml, add SUPABASE_URL and SUPABASE_ANON_KEY to your repo secrets, and you are done. On a private repo it costs a few Actions-minutes a month. Public repos, read the next section first — there is a catch.

name: Supabase Keep Alive

on:
  schedule:
    - cron: '30 5 * * *'    # once a day - docs say a few db requests a day is enough
  workflow_dispatch:          # manual trigger for testing

jobs:
  ping:
    runs-on: ubuntu-latest
    steps:
      - name: Ping the Supabase REST endpoint
        env:
          SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
          SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }}
        run: |
          # Fail LOUD if secrets are missing. A silently succeeding
          # keepalive is the most dangerous kind.
          if [ -z "$SUPABASE_URL" ] || [ -z "$SUPABASE_ANON_KEY" ]; then
            echo "::error::Missing SUPABASE_URL or SUPABASE_ANON_KEY secrets"
            exit 1
          fi

          URL="${SUPABASE_URL%/}/rest/v1/"

          # Real database WRITE via RPC: keepalive_ping() is a SECURITY DEFINER
          # function that upserts a one-row keepalive table. A write counts toward
          # Supabase's "user database activity" - a bare read may not (see below).
          HTTP_CODE=$(curl -sS -o /tmp/resp.txt -w "%{http_code}"             -X POST "${URL}rpc/keepalive_ping"             -H "apikey: $SUPABASE_ANON_KEY"             -H "Authorization: Bearer $SUPABASE_ANON_KEY"             -H "Content-Type: application/json"             -d "{}")

          echo "Supabase responded with HTTP $HTTP_CODE"

          if [ "$HTTP_CODE" = "200" ]; then
            echo "keep-alive WRITE successful"
          elif [ "$HTTP_CODE" = "404" ]; then
            echo "::error::keepalive_ping() missing. Create it in the SQL editor:"
            echo "::error::create or replace function public.keepalive_ping() returns timestamptz language sql security definer set search_path = public as $ insert into public.keepalive (id, last_ping) values (1, now()) on conflict (id) do update set last_ping = now() returning last_ping; $;"
            echo "::error::grant execute on function public.keepalive_ping() to anon, authenticated;"
            echo "::error::(plus: create table keepalive (id int primary key, last_ping timestamptz); insert into keepalive values (1, now());)"
            exit 1
          elif [ "$HTTP_CODE" -ge 500 ]; then
            echo "::error::Server error ($HTTP_CODE) - database may be paused"; exit 1
          else
            echo "::error::Write-ping failed (HTTP $HTTP_CODE) - check anon key"; exit 1
          fi

          # Optional but recommended: tell a monitor you are alive,
          # so the monitor can alert you when THIS workflow stops
          # curl -s -o /dev/null https://your-heartbeat-endpoint/ping/YOUR_ID

Use the anon key, never the service-role key — the RPC needs zero table permissions, and a key that can read your tables has no business in a cron that could one day be echoed into a public log. (Anon keys are designed to be public; a leaked service-role key is an incident.) The SECURITY DEFINER function does the write as the table owner, so it needs no row-level-security policies — which is exactly why the naive direct-table upsert keeps failing with RLS errors for people who try it. Let the function do the writing.

Want the stronger version? Create a one-row table (create table keepalive (id int primary key, last_ping timestamptz)), give the anon key an insert-only policy on that one table, and swap the curl for an upsert — INSERT ... ON CONFLICT DO UPDATE. A write is a database query by definition, so it counts toward the activity the pause timer watches. That is the version I now run in production.

Pixel-art robot waking a sleeping Supabase database before it pauses — Frankenstack article on free-tier keepalives

The trap inside the trap

Here is the part that should make you uncomfortable: my keepalive is itself an unattended cron. In public repositories, GitHub automatically disables schedule: workflows after 60 days without repo activity. No error, no email to you — just a dead workflow and a perfectly green feeling of safety.

A watchdog that dies silently is worse than no watchdog, because it manufactures confidence. So wrap your watchdog: every keepalive cron should also send a heartbeat to something that alerts you when it stops. That is exactly why Frankenstack has a heartbeat ingest — I built it because I needed it and could not find it. Any monitor works; the principle is what matters: the thing that watches your backend needs its own babysitter.

Postscript: the ping that did not count

A week before I wrote this, a different project of mine got paused anyway — with its keepalive green the whole time. That one pinged /rest/v1/ every few days and succeeded every time. It simply did not register as user database activity, which is what the pause timer actually measures. The workflow reported success; the database was a corpse. A monitor that watches the wrong thing is not a monitor.

So my current setup, on every free-tier Supabase project: a daily write ping into a keepalive table, with a loud fallback error if the table is missing, and a spool for writes that arrive while the database is down. Check your dashboard's query history — if the only activity in the last week comes from the keepalive itself, you are one policy change at Supabase away from the same story.

The checklist: 15 minutes, one evening

1. List every free service your app touches — database, email, cron, AI quota — and write down its idle/pause rules next to the name. Most pauses are documented in one paragraph you have never read.

2. Health-check the real dependency (the database API), not the marketing homepage.

3. Fail loud: missing config must produce an error, never a green checkmark.

4. Wrap the watchdog: any keepalive cron sends a heartbeat somewhere that can alert you when the cron itself dies.

5. Know your restore window. Supabase: 1 year, no backups on free. Write yours down before you need it.

6. If a project earns money, it can pay for its own paid plan. A few dollars a month is the cheapest insurance you will ever buy — and it deletes this entire category of problem.

For the vibe coders: hand this to your agent

You probably will not do this checklist yourself — you will paste this article into your coding agent and say "make it so." Fine. That is exactly how I set mine up. But know two things first.

First: the agent can write the workflow and the SQL, but it cannot see your Supabase dashboard. The verification steps — create the table, run the SQL, watch the workflow turn green, and see the last_ping timestamp actually move — are yours. An agent will happily declare victory on a green checkmark. So will you. Verify with your own eyes, in the dashboard, that the timestamp changed.

Second: expect one red run. Mine went red twice — first because the direct-table upsert hit a row-level-security error the agent had not foreseen, then because the first fix exposed the missing piece. That is not the agent failing. That is the loud failure mode you asked for, doing its job. A silent setup would have felt smoother and taught you nothing.

A prompt that carries the whole lesson:

Read this article and implement the keepalive for my Supabase project.

Requirements:
- Write-ping, not read-ping: a daily upsert into a one-row keepalive
  table, via a SECURITY DEFINER rpc function (keepalive_ping).
- Cron once a day, plus workflow_dispatch for manual testing.
- Fail LOUD: missing secrets, missing function, RLS errors, 5xx —
  all must fail the run with ::error:: and print the exact SQL I need.
- Include the SQL setup script in your reply (table + function + grant).
- Tell me the verification steps I must do MYSELF in the Supabase
  dashboard, and do not claim done until I confirm the last_ping
  timestamp moved.

Do not use the service-role key. Do not silently catch errors.

Your stack does not need more tools. It needs fewer silent ones. The problem is never the tool count — it is what happens in the seams.