Skip to content

Blog

Monitoring a Supabase-backed app: what to check and how

Upcertain itself runs on Supabase, so this is the setup we think about every day: watch the path your users take, the database connection your servers use, and the scheduled jobs inside Postgres.

Updated 25 October 2026 · By the Upcertain team

Check through your own app first

Supabase publishes its own status page for the platform. What it cannot tell you is whether your app can read its data: an expired key, a broken row-level security policy or a migration that locked a table all look fine from the outside. Expose a small health endpoint in your app that runs one cheap query through the same client your users go through, and monitor it with a keyword check:

import { createClient } from "@supabase/supabase-js";

export async function GET() {
  const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!);
  const { error } = await supabase.from("plans").select("id").limit(1);
  return error
    ? Response.json({ status: "error" }, { status: 503 })
    : Response.json({ status: "ok" });
}

Query a table that anonymous users may read, so the check needs no secret key and exercises the same policies as your app.

Check the database connection your servers use

Server-side code and workers usually connect to Postgres through Supabase's connection pooler. A port monitor on the pooler's host and port tells you the shared pooler accepts connections. It is shared by many projects, so only your health endpoint proves your own database answers. Use the host and port from your project's connection settings.

Heartbeats from pg_cron

If you schedule work inside the database with pg_cron, such as cleaning old rows or refreshing materialised views, a failing job only shows up in cron.job_run_details, which nobody reads. With the pg_net extension the database can report in after the job:

select cron.schedule(
  'nightly-cleanup',
  '0 3 * * *',
  $$
    delete from events where created_at < now() - interval '90 days';
    select net.http_get('https://upcertain.com/api/heartbeat/<token>');
  $$
);

Both statements run in the same job, so a failing delete stops the heartbeat. Create a heartbeat monitor with a 24-hour period and an hour of grace, and you hear if the cleanup ever stops.

What we watch for Upcertain

Our own public status page shows three services: the website, the dashboard and the API with the MCP server. The website and dashboard are checked with keyword and HTTP monitors. The API check sends an unknown key and expects 401, an answer that needs the database, so a database problem shows up as the service our users would notice.

A short checklist

  • Keyword monitor on a health endpoint that queries Supabase through your app.
  • Keyword monitor on the sign-in page, since auth failures hurt most.
  • Port monitor on the pooler, if servers connect directly.
  • Heartbeat per pg_cron job and per external worker.
  • Certificate warnings on your own domains; Supabase's domains are Supabase's to renew.

Questions

Should I monitor Supabase's own endpoints directly?
Your own health endpoint is more useful: it fails for the reasons your users would notice, including your keys and policies, not only when Supabase is down.
Does pg_net wait for the heartbeat request to finish?
No. pg_net sends requests asynchronously, so the job does not slow down. A failed request is recorded in pg_net's response table, and the missing heartbeat alerts you anyway.
What interval suits a database-backed health check?
Every 30 seconds to 1 minute. Use a query that touches an index, such as selecting one row by key, so the check stays cheap.