The answer. The official self-hosting Docker Compose publishes more than the gateway. Kong (8000/8443) is the only service that should be reachable from the internet, proxied behind TLS on 443, because it is where API keys, auth and rate limiting live. Postgres (5432), PgBouncer (6543), Studio (3000), the Meta API (8080), Realtime (4000) and Edge Functions (9000) all sit beside it on the host, and any of them exposed lets an attacker skip Kong entirely: Meta is an admin API, Postgres with the default password is game over. Default-deny inbound at the host firewall, allow 22/80/443, then verify from outside your network with nmap — an internal check tells you nothing about what the internet sees. Keep a read-only verifier on the box that reports secret lengths, never values.
The pattern.
# From a machine OUTSIDE the server's network. Expect only 80 and 443 "open".
nmap -p 80,443,3000,4000,5432,6543,8000,8080,8443,9000 my-supabase-host.example.com
# Host firewall (UFW): default deny, then the three you mean.
sudo ufw default deny incoming && sudo ufw default allow outgoing
sudo ufw allow 22/tcp && sudo ufw allow 80/tcp && sudo ufw allow 443/tcp
sudo ufw enable && sudo ufw status verbose
# verify.sh — read-only, run ON the box; secrets never reach stdout.
read_env_key() { grep -E "^${1}=" "$ENV_FILE" | head -1 | cut -d= -f2- | tr -d '"' | tr -d "'"; }
PG_PW=$(read_env_key POSTGRES_PASSWORD)
case "$PG_PW" in
postgres|password|admin|changeme) fail "POSTGRES_PASSWORD is a default" ;;
*) pass "POSTGRES_PASSWORD non-default (length ${#PG_PW} chars)" ;;
esac
JWT=$(read_env_key JWT_SECRET)
[ ${#JWT} -ge 32 ] || fail "JWT_SECRET too short (${#JWT} chars; minimum 32)"
The receipt. In our product (HarperFlow), the self-hosted instance we ran before moving to hosted Supabase got this runbook and verifier on 2026-05-19, ordered by severity; the port table above is item C-1, tier CRITICAL ("fix today"). The verifier runs on the VPS, is read-only, exits 1 on any FAIL, and prints only PASS/WARN/FAIL lines plus the length of each secret and a default/non-default marker, so its output is safe to paste into a chat. What we can't show you is the first external nmap result; it wasn't written down, and the instance was superseded by hosted Supabase on 2026-07-10 (hosted-cutover-verified-table-by-table). Treat the checklist as the receipt here, not a number.
Watch out.
- Rotating
JWT_SECRETinvalidates every session and requires regeneratingANON_KEYandSERVICE_ROLE_KEY. Plan the client redeploys before you rotate. - If a service-role key ever appears in git history, treat it as leaked and rotate; grepping the working tree isn't enough.
- Studio answering 401 or 403 from outside still means it's reachable. Only a timeout or a refused connection passes.
Related: hosted-cutover-verified-table-by-table · session-pooler-5432-for-ddl-direct-host-ipv6-only