The keepalive returned 200 every day and the database paused anyway

4 min read SupabasePostgresGitHub Actions

A daily cron pinged Supabase, got a 200 every single run, and the free-tier project still went to sleep twice. The endpoint was answered by the gateway and never reached Postgres, so the inactivity scan counted nothing. Here is the ping that actually executes SQL.

TL;DR · THE FIX

A 200 from /auth/v1/health proves the API gateway is up, not that your database did anything. Supabase's inactivity timer counts database activity, so the keepalive has to run real SQL: a small rpc that writes a row, plus a PostgREST read that asserts the value came back. Status codes are not side effects.

The symptom

A free-tier Supabase project pauses after seven days with no activity. I had a daily GitHub Action pinging it, the workflow was green on every run, and I had written up the fix here previously in the Supabase free-tier keepalive post. Then Supabase emailed an inactivity warning, and two weeks later it did so again, while the workflow history showed an unbroken column of green ticks across the same period.

What I tried first

My first thought was that the schedule had stopped firing. GitHub disables cron on repos with no activity after 60 days, which is a real failure mode and worth ruling out. The runs were there, on time, every day.

My second thought was that the request was failing and the step was somehow not failing with it. I ran the exact command from the workflow by hand:

curl -si "https://<project>.supabase.co/auth/v1/health" \
  -H "apikey: <anon-key>"
# HTTP/2 200

A genuine 200. The key was valid, the endpoint answered, and the step was correct to pass. The keepalive was doing what I had told it to do, and the database was still going to sleep.

What was happening

The problem was what sits behind the endpoint. Supabase puts an API gateway in front of everything. /auth/v1/* is served by the auth service, and /auth/v1/health is that service’s own liveness check. It answers from the edge of the stack and never issues a query against your Postgres database, and neither does /auth/v1/settings, the other endpoint I was hitting.

The inactivity scan that decides whether to pause a project looks at database activity. From its point of view my project had received none for weeks, because every request I sent stopped short of Postgres. The 200 was real, and it answered a different question than the one I thought I was asking: it said the gateway was up, and I had read it as the project being alive.

This is the bug from the original post, one layer further in. There the naive ping returned 401 and I learned to check the status code. Here the status code is 200 and still proves nothing, because a status code tells you a request was answered and says nothing about what work it caused.

The fix

Make the keepalive execute SQL, and make the assertion about the data rather than the status line. A single-row table and a function that bumps it:

create table public.keepalive (
  id         int primary key default 1,
  pinged_at  timestamptz not null default now(),
  constraint keepalive_one_row check (id = 1)
);
insert into public.keepalive (id) values (1);

alter table public.keepalive enable row level security;
create policy "keepalive is readable" on public.keepalive
  for select to anon using (true);

create function public.keepalive_ping()
  returns timestamptz
  language sql
  security definer
  set search_path = public
as $$
  update public.keepalive set pinged_at = now() where id = 1
  returning pinged_at;
$$;

grant execute on function public.keepalive_ping() to anon;

The function is security definer so it can write while the table stays read-only to anon. Its entire surface is one row and one timestamp, and it takes no arguments, so there is nothing to inject and nothing to enumerate. security definer plus a grant to anon deserves a second look every time you write it, and it is only defensible here because the function cannot do anything else.

Then the workflow does a write and a read, and checks the payload:

name: Supabase keepalive
on:
  schedule:
    - cron: "17 7 * * *"
  workflow_dispatch:
jobs:
  ping:
    runs-on: ubuntu-latest
    steps:
      - name: Write (rpc)
        run: |
          curl -sf -X POST \
            "https://<project>.supabase.co/rest/v1/rpc/keepalive_ping" \
            -H "apikey: ${{ secrets.SUPABASE_ANON_KEY }}" \
            -H "Content-Type: application/json" \
            -d '{}'
      - name: Read it back and assert
        run: |
          body=$(curl -sf \
            "https://<project>.supabase.co/rest/v1/keepalive?select=pinged_at" \
            -H "apikey: ${{ secrets.SUPABASE_ANON_KEY }}")
          echo "$body"
          echo "$body" | grep -q pinged_at

Both calls go through PostgREST, so both reach the database. The read is there because it is the part that can fail in an interesting way: if the row is gone, the policy changed, or the function silently stopped writing, grep -q pinged_at goes red. curl -sf still catches transport and status failures, and it is no longer the only thing standing between you and a sleeping database.

To reset the timer by hand at any point, the same rpc call from your terminal does it. The auth endpoints will not.

The lesson

A green check is a claim about the job, and a 200 is a claim about the request. Neither is a claim about the effect you were trying to produce, and when those three drift apart the monitoring keeps reporting success while the thing you built it to prevent happens anyway.

When a health check is meant to prove a side effect, assert the side effect. Ask what component answers the endpoint you chose and whether that component is the one whose liveness you care about. I had a keepalive that was keeping the gateway alive.

The original version of this post recommended the auth health endpoint. It was wrong, it stayed up for two months, and this is the correction.

Related fixes

Discussion

Powered by GitHub. Sign in to leave a comment.