Skip to content

Instantly share code, notes, and snippets.

@shsunmoonlee
Created October 20, 2026 09:52
Show Gist options
  • Select an option

  • Save shsunmoonlee/56b62d54347f6f550f99b88ce229779f to your computer and use it in GitHub Desktop.

Select an option

Save shsunmoonlee/56b62d54347f6f550f99b88ce229779f to your computer and use it in GitHub Desktop.
Read-only preflight and verify SQL for a self-hosted Supabase Postgres upgrade: sizes, extensions, event-trigger owners, cron jobs, slots, collation, ACL fingerprints, PASS/FAIL checks.

The maintained version of these files lives in the self-hosted-supabase handbook. This gist is a snapshot of 2026-10-20; check the repo for fixes.

Preflight and verify SQL for a Supabase Postgres upgrade

Both files only read catalogs. They print names, counts, owners, sizes and hashes, never secret values.

docker cp . supabase-db-<service-uuid>:/tmp/checks/
docker exec supabase-db-<service-uuid> psql -U supabase_admin -d postgres -X -f /tmp/checks/preflight.sql > preflight.out
# ... upgrade ...
docker exec supabase-db-<service-uuid> psql -U supabase_admin -d postgres -X \
  -v expect_major=17 -v expect_event_triggers=<n> -v expect_active_cron=<n> \
  -f /tmp/checks/verify.sql > verify.out
diff preflight.out verify.out

verify.sql includes preflight.sql (\ir), so keep both in the same directory. hardening-check.sql lists SECURITY DEFINER functions open to anon / authenticated, unsafe search_path settings, exposed tables with RLS off, and the default privileges new objects will get. Add -v count_schema=public for exact row counts of every table in one schema (psql 14+; slow on big tables).

What preflight.sql shows that is easy to miss:

  • Database sizes, including _supabase (Logflare can make it larger than your app database).
  • Event triggers whose owner is a superuser but whose function is not. supautils refuses these during pg_upgrade's restore; plan a temporary SUPERUSER for postgres.
  • Extensions with no Postgres 17 package (timescaledb, plv8, plcoffee, plls) and the ones to drop first (pgjwt, unused wrappers).
  • Logical replication slots, which pg_upgrade from 15 does not carry.
  • An ACL fingerprint per schema, to prove grants survived.

Tested against fresh supabase/postgres:15.14.1.163 and 17.6.1.163 containers. More context: https://github.com/shsunmoonlee/self-hosted-supabase.


Maintained by Seunghun Lee. I offer production self-hosted SaaS setup as a service: LinkedIn · X.

-- hardening-check.sql: read-only checks for the API-facing roles of a self-hosted Supabase database.
--
-- docker cp scripts/. supabase-db-<service-uuid>:/tmp/checks/
-- docker exec supabase-db-<service-uuid> psql -U supabase_admin -d postgres -X -f /tmp/checks/hardening-check.sql
--
-- Sections 1 to 3 should return 0 rows, or only rows you have decided on and can explain. Section 3 always lists
-- views granted to anon/authenticated (a plain view runs as its owner and bypasses RLS): check each view's owner
-- and whether it sets security_invoker. Section 4 shows the
-- default privileges stock Supabase sets (expected); it is why sections 1 and 3 need checking after every migration.
-- Section 5 should list only the built-in admin roles.
-- It only reads catalogs and prints object names, never data. Schemas checked: `public` by default; pass every
-- schema PostgREST exposes with -v schemas="'public','api'".
\set ON_ERROR_STOP on
\pset pager off
set default_transaction_read_only = on;
\if :{?schemas}
\else
\set schemas '''public'''
\endif
\echo '== 1. SECURITY DEFINER functions that anon or authenticated may EXECUTE (reachable as /rest/v1/rpc/<name>)'
select p.oid::regprocedure as function,
has_function_privilege('anon', p.oid, 'EXECUTE') as anon,
has_function_privilege('authenticated', p.oid, 'EXECUTE') as authenticated
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where p.prosecdef
and n.nspname in (:schemas)
and (has_function_privilege('anon', p.oid, 'EXECUTE') or has_function_privilege('authenticated', p.oid, 'EXECUTE'))
order by 1::text;
\echo '== 2. SECURITY DEFINER functions without a safe search_path (must end in pg_temp, or be empty)'
select p.oid::regprocedure as function,
coalesce((select c from unnest(p.proconfig) c where c like 'search_path=%'), '(none)') as search_path
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where p.prosecdef
and n.nspname in (:schemas)
and not exists (
select 1 from unnest(coalesce(p.proconfig, '{}')) c
where c = 'search_path=' or c = 'search_path=""' or c ~ 'search_path=.*pg_temp$')
order by 1::text;
\echo '== 3. Tables and views anon or authenticated can read or write, with row level security OFF (views are always listed: check owner and security_invoker)'
select c.oid::regclass as relation,
c.relkind,
has_table_privilege('anon', c.oid, 'SELECT') as anon_select,
has_table_privilege('anon', c.oid, 'INSERT,UPDATE,DELETE') as anon_write,
has_table_privilege('authenticated', c.oid, 'SELECT') as auth_select,
has_table_privilege('authenticated', c.oid, 'INSERT,UPDATE,DELETE') as auth_write
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname in (:schemas)
and c.relkind in ('r', 'p', 'v', 'm', 'f')
and not c.relrowsecurity
and (has_table_privilege('anon', c.oid, 'SELECT,INSERT,UPDATE,DELETE')
or has_table_privilege('authenticated', c.oid, 'SELECT,INSERT,UPDATE,DELETE'))
order by 1::text;
\echo '== 4. Default privileges: what anon and authenticated get on objects created LATER (stock Supabase grants these; informational)'
select pg_get_userbyid(d.defaclrole) as creator,
d.defaclnamespace::regnamespace as schema,
case d.defaclobjtype when 'r' then 'tables' when 'S' then 'sequences' when 'f' then 'functions'
when 'T' then 'types' when 'n' then 'schemas' else d.defaclobjtype::text end as objects,
pg_get_userbyid(a.grantee) as grantee,
string_agg(a.privilege_type, ',' order by a.privilege_type) as privileges
from pg_default_acl d
cross join lateral aclexplode(d.defaclacl) a
where a.grantee in ('anon'::regrole, 'authenticated'::regrole)
and d.defaclnamespace::regnamespace::text in (:schemas)
group by 1, 2, 3, 4
order by 1, 2::text, 3, 4;
\echo '== 5. Roles that can log in with superuser or bypass RLS (expect only the built-in admin roles)'
select rolname, rolsuper, rolbypassrls, rolcanlogin
from pg_roles
where rolcanlogin and (rolsuper or rolbypassrls)
order by rolname;
\echo '== done'
-- preflight.sql: read-only inventory of a self-hosted Supabase Postgres before an upgrade.
--
-- Run as supabase_admin (or another superuser) against the `postgres` database:
-- docker cp scripts/. supabase-db-<service-uuid>:/tmp/checks/
-- docker exec supabase-db-<service-uuid> psql -U supabase_admin -d postgres -X -f /tmp/checks/preflight.sql > preflight.out
--
-- Add `-v count_schema=public` for exact row counts of every table in one schema (psql 14 or newer).
--
-- It only reads catalogs. It prints names, counts, owners and sizes, never secret values.
-- Keep the output: verify.sql prints the same sections, so the two files diff cleanly.
\set ON_ERROR_STOP on
\pset pager off
set default_transaction_read_only = on;
\echo '== server'
select version();
show server_version;
\echo '== databases (every one pg_upgrade will touch: datallowconn = true)'
select datname,
pg_get_userbyid(datdba) as owner,
datallowconn,
pg_size_pretty(pg_database_size(oid)) as size,
pg_database_size(oid) as size_bytes
from pg_database
order by pg_database_size(oid) desc;
\echo '== database ACLs (a database ACL whose grantor differs from its owner can fail the pg_upgrade restore)'
select datname, pg_get_userbyid(datdba) as owner, datacl from pg_database order by datname;
\echo '== extensions in this database'
select extname, extversion, extnamespace::regnamespace as schema from pg_extension order by extname;
\echo '== extensions with no Postgres 17 package in Supabase images (must be 0 rows)'
select extname from pg_extension where extname in ('timescaledb', 'plv8', 'plcoffee', 'plls');
\echo '== extensions dropped before a 15 -> 17 upgrade when unused (pgjwt, wrappers)'
select extname, extversion from pg_extension where extname in ('pgjwt', 'wrappers');
select count(*) as foreign_servers from pg_foreign_server;
\echo '== event triggers (count, owner, function owner)'
select count(*) as event_triggers from pg_event_trigger;
select e.evtname,
pg_get_userbyid(e.evtowner) as trigger_owner,
e.evtfoid::regproc as function,
pg_get_userbyid(p.proowner) as function_owner,
e.evtenabled
from pg_event_trigger e
join pg_proc p on p.oid = e.evtfoid
order by e.evtname;
\echo '== superuser trigger running a non-superuser function (supautils refuses these during the pg_upgrade restore)'
select e.evtname, e.evtfoid::regproc as function, pg_get_userbyid(p.proowner) as function_owner
from pg_event_trigger e
join pg_proc p on p.oid = e.evtfoid
join pg_roles tr on tr.oid = e.evtowner
join pg_roles fr on fr.oid = p.proowner
where tr.rolsuper and not fr.rolsuper
order by e.evtname;
\echo '== roles that matter for the upgrade'
select rolname, rolsuper, rolcreaterole, rolreplication, rolbypassrls
from pg_roles
where rolname in ('postgres', 'supabase_admin', 'authenticator', 'anon', 'authenticated', 'service_role')
order by rolname;
\echo '== replication slots (logical slots are not carried by pg_upgrade from 15)'
select slot_name, slot_type, plugin, database, active from pg_replication_slots order by slot_name;
\echo '== collation version mismatches (should be 0 rows)'
select datname, datcollversion, pg_database_collation_actual_version(oid) as actual
from pg_database
where datcollversion is distinct from pg_database_collation_actual_version(oid)
and datallowconn;
\echo '== objects in public owned by supabase_admin'
select
(select count(*) from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relowner = 'supabase_admin'::regrole)
+ (select count(*) from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proowner = 'supabase_admin'::regrole) as public_objects_owned_by_supabase_admin;
\echo '== ACL fingerprint per schema (compare before and after; a dump without grants changes these)'
select n.nspname as schema,
count(*) as relations,
md5(string_agg(c.relname || ':' || coalesce(c.relacl::text, '-'), ',' order by c.relname)) as relacl_md5
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where c.relkind in ('r', 'v', 'm', 'p', 'f', 'S')
and n.nspname not like 'pg_%' and n.nspname <> 'information_schema'
group by n.nspname
order by n.nspname;
\echo '== largest tables (how big the pg_upgrade copy will be)'
select n.nspname || '.' || c.relname as relation, pg_size_pretty(pg_total_relation_size(c.oid)) as total
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where c.relkind in ('r', 'p', 'm')
order by pg_total_relation_size(c.oid) desc
limit 15;
-- Optional sections: each runs only when the object exists.
select to_regclass('cron.job') is not null as has_cron \gset
select to_regclass('auth.users') is not null as has_auth \gset
select to_regnamespace('_realtime') is not null as has_realtime \gset
\if :has_cron
\echo '== pg_cron jobs (note the active ids; the upgrade pauses and re-enables exactly these)'
select jobid, jobname, username, database, active from cron.job order by jobid;
select count(*) filter (where active) as active_jobs, count(*) as all_jobs from cron.job;
\endif
\if :has_auth
\echo '== row counts'
select count(*) as auth_users from auth.users;
\endif
\if :has_realtime
\echo '== realtime tenant migrations'
select count(*) as realtime_migrations from _realtime.schema_migrations;
\endif
\if :{?count_schema}
\echo '== exact row counts for every table in schema' :'count_schema' '(can be slow on big tables)'
select format('select %L as relation, count(*) as rows from %I.%I', n.nspname || '.' || c.relname, n.nspname, c.relname)
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = :'count_schema' and c.relkind in ('r', 'p')
order by c.relname
\gexec
\endif
\echo '== done'
-- verify.sql: read-only checks after an upgrade, in the same shape as preflight.sql.
--
-- docker cp scripts/. supabase-db-<service-uuid>:/tmp/checks/ (verify.sql includes preflight.sql with \ir)
-- docker exec supabase-db-<service-uuid> psql -U supabase_admin -d postgres -X -v expect_major=17 -f /tmp/checks/verify.sql > verify.out
-- diff preflight.out verify.out # expected differences: version lines, extension versions, sizes
--
-- Set -v expect_event_triggers=<n> and -v expect_active_cron=<n> to the numbers from preflight.out to turn
-- those into hard checks. Every check prints PASS or FAIL; nothing is changed.
\set ON_ERROR_STOP on
\pset pager off
set default_transaction_read_only = on;
\if :{?expect_major}
\else
\set expect_major 17
\endif
\echo '== checks'
select case when current_setting('server_version_num')::int / 10000 = :expect_major::int
then 'PASS' else 'FAIL' end as server_major_is_expected,
current_setting('server_version') as server_version;
select case when not rolsuper then 'PASS' else 'FAIL' end as postgres_is_not_superuser
from pg_roles where rolname = 'postgres';
select case when count(*) = 0 then 'PASS' else 'FAIL' end as no_collation_mismatch, count(*) as mismatches
from pg_database
where datallowconn and datcollversion is distinct from pg_database_collation_actual_version(oid);
\if :{?expect_event_triggers}
select case when count(*) = :expect_event_triggers::int then 'PASS' else 'FAIL' end as event_trigger_count,
count(*) as event_triggers
from pg_event_trigger;
\endif
select to_regclass('cron.job') is not null as has_cron_v \gset
\if :has_cron_v
\if :{?expect_active_cron}
select case when count(*) filter (where active) = :expect_active_cron::int then 'PASS' else 'FAIL' end as active_cron_jobs,
count(*) filter (where active) as active
from cron.job;
\endif
\endif
select case when count(*) = 0 then 'PASS' else 'FAIL' end as no_inactive_logical_slots
from pg_replication_slots where slot_type = 'logical' and not active;
\echo '== the same inventory as preflight.sql, for the diff'
\ir preflight.sql
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment