10 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5 142a7fbaa9 Aggiungo a CLAUDE.md gli apprendimenti di sessione su tooling e ambiente locale
bun non è nel PATH in ambienti sandbox (usare npx/bunx), il repo non è
prettier-clean ovunque (filtrare i falsi positivi preesistenti), e la porta
Postgres locale può differire dal default 5432 nei .env di sviluppo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 15:41:14 +02:00
davideandClaude Sonnet 5 84313b1955 Aggiorno documentazione e memoria di progetto per il CMS Strapi
CLAUDE.md, docs/PORTABILITA.md e mem/features/cloud-efficienza.md riflettono
che rosa/classifica/storico partite vengono ora da Strapi (non più hardcoded)
e che lo scout finalizzato viene anche inviato a Strapi, oltre al localStorage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 15:40:08 +02:00
davideandClaude Sonnet 5 839db3d4ca Invio best-effort il risultato dello scout finalizzato a Strapi
Nuova route server /api/public/scout-finale: proxy verso il content type
scout-match-finale con un token Strapi di sola scrittura, mai esposto al
client. Il salvataggio in localStorage (già chiamato da finePartita() in
scout.tsx nel commit precedente) resta la fonte per le statistiche; questo
invio è additivo e non bloccante, se fallisce la partita resta comunque
salvata sul device come già accadeva prima.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 15:39:46 +02:00
davideandClaude Sonnet 5 953908388d Sposto rosa, classifica e storico partite dagli array hardcoded al CMS Strapi
Nuovi moduli src/lib/strapi-client.ts + rosa-base.ts/classifica-csi.ts/
storico-match.ts (hook TanStack Query, cache lunga, nessun polling). L'id
stabile gN diventa il campo `codice` su Strapi, per non rompere le FK verso
pagelle/cacche/mvp/palloni/presenze/azioni scout già chiavate su quella
stringa. isAdmin ora prende il Giocatore invece di un id, così non dipende
più dal roster. Rifattorizzati di conseguenza tutti i punti che importavano
`giocatori`/`classifica`/`storicoMatch` come array sincroni (componenti,
hook, due route server) per usare gli hook o un parametro esplicito.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 15:39:40 +02:00
davideandClaude Sonnet 5 3f61b32219 Aggiungo CMS Strapi self-hosted per gestire rosa, classifica e storico partite
Introduce lo stack Strapi dockerizzato (infra/strapi/), sullo stesso Postgres
dello stack Supabase con un database logico separato, dietro Caddy su /cms/*.
L'editing dei dati avviene solo nel pannello admin nativo di Strapi.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 15:39:20 +02:00
davide 3fdbfefa62 Documento setup dev e deploy in produzione self-hosted
Passo-passo per avviare lo stack Supabase in locale e per il deploy in
produzione (dominio unico con routing /api/*, migrazioni, build/avvio
Node, job pianificati), coerente con i file infra aggiunti nel commit
precedente.
2026-08-28 14:23:31 +02:00
davide f496eb2f88 Aggiungo stack Supabase self-hosted e config di produzione
Stack Docker completo in infra/supabase/docker/ (vendored da supabase/supabase)
per svincolare dev e produzione da Lovable Cloud. Include override di
produzione che non espone porte pubblicamente e disattiva i servizi non
usati da CrAPP (Realtime, Storage, imgproxy, Edge Functions, pooler),
un Caddyfile con routing /api/* per un solo dominio, e i template .env
per app e stack. Forzo inoltre il preset Nitro a node-server in
vite.config.ts, dato che il default sarebbe Cloudflare Workers.
2026-08-28 14:23:20 +02:00
davide eddee2ec44 Riscrivo il README per riflettere lo stato reale del repository
Il README precedente era la proposta iniziale del progetto (funzionalità
desiderate, comandi npm). Lo sostituisco con stack, funzionalità
effettivamente implementate e comandi bun corretti.
2026-08-28 13:17:44 +02:00
davide 49b6c8a7e9 Correggo documentazione: sync CSI e risultato scout non persistiti server-side
Il CLAUDE.md e la memory affermavano una sincronizzazione CSI e una
persistenza server-side dei risultati partita che non esistono nel
codice: la classifica è un array hardcoded (demo) e il risultato
scoutato vive solo nel localStorage di chi segna.
2026-08-28 13:15:06 +02:00
davide ebd3213a46 Aggiungo CLAUDE.md per tryhardare con claude 2026-08-28 12:09:31 +02:00
148 changed files with 36198 additions and 471 deletions
+22
View File
@@ -0,0 +1,22 @@
# TEMPLATE DI PRODUZIONE per l'app CrAPP — copia in .env sul server e compila.
# I valori Supabase vengono dal .env dello stack self-hosted in infra/supabase/docker/
# (ANON_KEY -> VITE_SUPABASE_PUBLISHABLE_KEY, SERVICE_ROLE_KEY -> SUPABASE_SERVICE_ROLE_KEY).
VITE_SUPABASE_URL=https://tuodominio.it/api
VITE_SUPABASE_PUBLISHABLE_KEY=
SUPABASE_SERVICE_ROLE_KEY=
# CMS Strapi (rosa, classifica, storico partite, scout finalizzato) — vedi infra/strapi/.
# VITE_STRAPI_URL è pubblico (letture aperte, come le tabelle Supabase "open RLS"), mentre
# STRAPI_WRITE_TOKEN è un token con permessi di sola creazione su scout-match-finale: NON deve
# mai avere il prefisso VITE_ (finirebbe nel bundle client) e va letto solo lato server.
VITE_STRAPI_URL=https://tuodominio.it/cms
STRAPI_WRITE_TOKEN=
# Notifiche push (genera con: npx web-push generate-vapid-keys)
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:tuamail@esempio.it
# Porta su cui ascolta il server Node generato da Nitro (preset node-server)
PORT=3000
+4
View File
@@ -39,3 +39,7 @@ dist-ssr
# Optional
.vercel
# Supabase self-host: dati runtime, mai versionati
infra/supabase/docker/volumes/db/data/
infra/supabase/docker/volumes/storage/
+151
View File
@@ -0,0 +1,151 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
CrAPP — a mobile-first web app for managing an amateur volleyball team (CRAP Volley): attendance,
matches/trainings/events, live match scouting, player stats/badges, CSI league standings, push
notifications. Italian-language codebase (routes, variables, comments). Built with Lovable
(lovable.dev); pushes to `main` sync back into the Lovable editor — avoid rewriting published git
history (force-push, rebase/amend/squash of pushed commits).
## Commands
Package manager is **bun** (`bun.lock` is the lockfile; `package-lock.json` also exists but bun is
primary — see `bunfig.toml`).
```sh
bun run dev # vite dev server
bun run build # production build (nitro/vite)
bun run build:dev # development-mode build
bun run preview # preview a build
bun run lint # eslint .
bun run format # prettier --write .
```
If `bun` isn't on PATH (sandboxed/CI environments), use the `npx`/`bunx` equivalents:
`npx tsc --noEmit -p tsconfig.json` (typecheck, no npm script exists for this),
`npx eslint src` (not `npx eslint .` — a full-repo scan chokes on the root-owned
`infra/supabase/docker/volumes/db/data` directory), `npx prettier --write <files>`.
This repo is not fully `prettier`-clean — a fresh `eslint`/`prettier --check` run surfaces
hundreds of pre-existing formatting errors unrelated to any given change. Filter with
`grep -v "prettier/prettier"` to see real lint issues, and run `prettier --write` only on the
files you touched, not the whole repo.
There is no test runner configured in this repo.
Adding a dependency: `bunfig.toml` enforces a 24h supply-chain guard (`minimumReleaseAge`) on new
packages; only pre-listed `@lovable.dev/*` packages bypass it. Confirm with the user before adding
new bypass entries.
## Architecture
**Stack**: TanStack Start (React 19, file-based router) + Vite, styled with Tailwind v4 +
shadcn/radix components, data via `@supabase/supabase-js`, TanStack Query for client cache.
### Routing
File-based routing under `src/routes/` (see `src/routes/README.md`). One root layout,
`src/routes/__root.tsx`, wraps every page — preserve its `<Outlet />`. `$id.tsx` = dynamic segment,
`{-$category}.tsx` = optional segment, `$.tsx` = splat (`_splat` param). Do not hand-create
`src/pages/` or Next/Remix-style layout files. `src/routeTree.gen.ts` is auto-generated — never
edit it directly.
Server-only HTTP endpoints live under `src/routes/api/public/*.ts` (e.g. push subscription,
palloni/presenze reminders) — plain HTTP handlers, not proprietary edge functions, so they can run
under any Node host or scheduler (system cron, pg_cron, etc).
### Server entry / SSR error handling
`src/start.ts` registers global middleware: `attachSupabaseAuth` (client-side function middleware
that attaches the Supabase bearer token to every server-fn RPC — see
`src/integrations/supabase/auth-attacher.ts`, which is **auto-generated**, do not hand-edit) and an
error middleware + explicit CSRF middleware (`createCsrfMiddleware`) for server functions. Defining
`src/start.ts` opts out of Start's automatic CSRF middleware, so this file must keep re-adding it.
`src/server.ts` wraps the generated TanStack Start server entry and normalizes a specific h3
failure mode: h3 swallows in-handler throws into a 500 JSON body
(`{"unhandled":true,"message":"HTTPError"}`) that a plain try/catch never sees, so it's detected by
inspecting the response body and converted into `renderErrorPage()`'s HTML error page.
### Data layer — portability rule
The project must remain deployable on plain Node.js + PostgreSQL, not locked into Lovable Cloud
(see `docs/PORTABILITA.md`). Concretely:
- **Never query the database (or the Strapi CMS) from components.** All data access goes through
modules in `src/lib/*.ts` (e.g. `palloni.ts`, `mvp-voti.ts`, `eventi.ts`, `presenze.ts`, `rosa.ts`,
`scout-*.ts`) — each exports TanStack Query hooks (`useX`) wrapping `supabase.from(...)` or, for
Strapi-backed data, `strapiFetch(...)` from `src/lib/strapi-client.ts` (see `rosa-base.ts`,
`classifica-csi.ts`, `storico-match.ts`). This keeps the backend swappable in one place.
- `src/integrations/lovable/*` (social login) is optional and unused by any screen — safe to
remove without impact.
- No provider-exclusive features: no edge functions, no Lovable-only auth as the sole login method,
no proprietary storage. Config only via standard env vars (`DATABASE_URL` / `SUPABASE_URL` +
keys, `VAPID_*`), never hardcoded.
- SQL migrations in `supabase/migrations/` must use standard PostgreSQL, no provider-exclusive
extensions.
### Cloud-efficiency rules (small paid-tier budget)
The Supabase/Lovable Cloud plan is metered (~20 credits/month, ~17 users), so these constraints are
load-bearing, not style preferences:
- No polling (`refetchInterval`) against the database; prefer local sync via
BroadcastChannel/storage.
- Global QueryClient (`src/router.tsx`) is deliberately configured for infrequent refetching:
`staleTime` 5 min, `gcTime` 30 min, `refetchOnWindowFocus/Mount/Reconnect` all off, `retry: 1`.
Don't override this per-query without a reason.
- After a mutation, update the query cache with `setQueryData` (see `useAssegnaTurno` in
`src/lib/palloni.ts` for the pattern) rather than `invalidateQueries`, to avoid an extra read.
- Stats/badges/standings are "write once, read many": computed and persisted once (e.g. at match
end), never recomputed on every page open.
- Live match scouting: only the scoring user writes; everyone else reads already-saved data.
- Player roster, CSI league standings and match history come from a self-hosted **Strapi CMS**
(`infra/strapi/`, its own Postgres database on the same cluster as Supabase) — editing happens
only in Strapi's own admin panel (`/cms/admin`), never in app UI. The app reads them read-only via
`src/lib/rosa-base.ts` / `classifica-csi.ts` / `storico-match.ts` (long `staleTime`, no polling —
same cloud-efficiency rules apply). Player identity is the stable `codice` field (`g1`, `g2`, …)
on the Strapi `giocatore` content type, **not** Strapi's own numeric id — every Supabase table
that references a player by id (`pagelle_voti`, `cacche_partita`, MVP votes, ball-duty turns,
presenze, scout actions) keys on that string, so it must never change for an existing player.
- A scouted match result (`ScoutMatch`, from `scout.tsx` / `scout-store.ts`) is saved to the
scoring device's `localStorage` (key `crapp-scout-v1`, still the source `useRosa`/
`giocatoriConScout` read for stats) **and** pushed best-effort to Strapi's `scout-match-finale`
content type via `/api/public/scout-finale` at match end, so it becomes visible cross-device in
the admin panel — no retry if that POST fails, the local save already succeeded either way. The
in-progress resume state still goes only to the `scout_live` Supabase table, unrelated to Strapi.
- Local dev quirk: this machine's `infra/supabase/docker/.env` may customize `POSTGRES_PORT`
away from the documented default `5432` (e.g. to avoid colliding with another project's
Postgres) — check that file before assuming the template's port when wiring up a new service
to the same database.
- Push notifications only for high-value events (convocations, training/match reminders, ball-duty
turn, final result) — no chat/photo/video features.
### Auth
Supabase Auth (GoTrue), attached via middleware rather than per-request boilerplate — see
`auth-attacher.ts` / `auth-middleware.ts` above. Self-hostable; not tied to Lovable-proprietary
auth.
## Project memory (`mem/`)
`mem/index.md` indexes feature-level design notes (`mem/features/*.md`) — currently the portability
rule and the cloud-efficiency rules summarized above. Check there before adding a feature that
might conflict with either constraint.
## Conventions
- **Language: Italian.** Code comments and git commit messages must be written in Italian, matching
the rest of the codebase (routes, identifiers, existing comments/commits are already Italian).
- Path alias `@/*``src/*` (see `tsconfig.json`).
- Prettier: 100-char width, double quotes (`singleQuote: false`), trailing commas everywhere.
- TypeScript strict mode plus extra strictness: `noUncheckedIndexedAccess`,
`exactOptionalPropertyTypes`, `noImplicitReturns`, `noImplicitOverride`,
`noPropertyAccessFromIndexSignature`.
- `vite.config.ts` is intentionally minimal: `@lovable.dev/vite-tanstack-config` already bundles
TanStack devtools, `tanstackStart`, `viteReact`, `tailwindcss`, `tsConfigPaths`, nitro, env
injection, and the `@` alias — do not re-add any of those plugins manually or the app breaks with
duplicate plugins.
+203 -106
View File
@@ -1,118 +1,215 @@
# CRAP Volley Hub
# CrAPP — CRAP Volley Hub
CrAPP App per CRAP Volley
App mobile-first per la gestione della squadra amatoriale di pallavolo **CRAP Volley**:
presenze, partite/allenamenti/eventi, scouting live, statistiche giocatori, badge,
classifica CSI, notifiche push.
Vorrei sviluppare unapp mobile per la squadra di pallavolo CRAP Volley, con nome CrAPP, disponibile per Android e iOS. Lobiettivo è creare unapp semplice da usare, moderna, bella da vedere e più coinvolgente rispetto a SportEasy, includendo anche funzionalità normalmente a pagamento in altre app.
Funzionalità principali
Gestione presenze/assenze
Partite
Allenamenti
Eventi extra
Stati rapidi: presente, assente, forse, in ritardo, indisponibile, infortunato
Statistiche giocatori
Presenze totali
Presenze consecutive
Gol/punti o altre statistiche specifiche della pallavolo
MVP, migliori performance, medie stagione
Statistiche partite
Risultati
Formazioni
Andamento set
Storico match
Campionato in tempo reale
Visualizzazione classifica e risultati
Dati presi direttamente dal sito del CSI
Aggiornamento automatico o importazione periodica
Calendario squadra
Allenamenti
Partite
Promemoria
Vista mensile e lista eventi
Profilo giocatore
Foto
Ruolo
Statistiche personali
Badge e obiettivi
Idea di stile
Interfaccia sportiva, pulita e moderna
Molto mobile-first
Design divertente, energico e più “premium”
Inserire in seguito il logo della squadra
Possibile uso di badge, livelli, premi e mini-gamification per rendere lapp più piacevole da usare
Extra che sarebbe bello aggiungere
Notifiche push per convocazioni e cambi orario
Chat o bacheca squadra
Report automatici dopo le partite
Sondaggi rapidi per disponibilità
Sezione “Best of the match”
Obiettivi di gruppo per presenza e continuità
Obiettivo finale
Realizzare una app che non sia solo utile per la gestione della squadra, ma anche piacevole, coinvolgente e bella da usare ogni giorno.
This project was built with [Lovable](https://lovable.dev).
Progetto avviato con [Lovable](https://lovable.dev): i push su `main` sincronizzano l'editor
Lovable, quindi si evita di riscrivere la history già pubblicata (niente force-push,
rebase/amend/squash di commit già pushati).
**Live app**: https://volley-cronos-app.lovable.app
## Build with Lovable
## Stack
Continue developing this project in the [Lovable editor](https://lovable.dev/projects/8d07b0e4-6bd2-4a17-9dd2-bb2cf13f9f7c).
- [TanStack Start](https://tanstack.com/start) (React 19, routing file-based) + Vite
- Tailwind v4 + componenti shadcn/radix
- [Supabase](https://supabase.com) (`@supabase/supabase-js`) per dati e auth
- TanStack Query per la cache client
- **Ship faster**: describe what you want to build and Lovable handles the code.
- **Stay in sync**: every change made in Lovable is committed straight to this repository.
- **Full ownership**: this code is yours. Push to `main` on GitHub and your changes sync back into Lovable, ready for your next prompt.
## Funzionalità
## Development
- **Presenze**: RSVP rapido (presente / assente / forse / in ritardo / infortunato) su ogni evento
- **Eventi**: partite, allenamenti, eventi sociali, con calendario e lista
- **Scouting live**: registrazione azioni punto-per-punto durante la partita (riservato agli
admin)
- **Statistiche giocatori**: presenze totali/consecutive, badge, obiettivi squadra, medie pagelle
- **Votazioni post-partita**: MVP, pagelle 1-10 tra compagni, voti "social"/goliardici
- **Turno palloni**: rotazione automatica di chi porta i palloni, con promemoria push
- **Classifica CSI**: al momento un dato demo hardcoded (vedi [CLAUDE.md](CLAUDE.md)), sync reale
ancora da implementare
- **Notifiche push**: convocazioni, promemoria allenamento/partita, turno palloni, esito finale
Prefer working locally? You need Node.js and npm — [install with nvm](https://github.com/nvm-sh/nvm#installing-and-updating).
## Sviluppo (locale)
Package manager: **bun** ([installazione](https://bun.sh)).
```sh
git clone <this-repository-url>
cd <repository-name>
npm i
npm run dev
git clone https://github.com/ivancacciari1995-a11y/CRAPP.git
cd CRAPP
bun install
```
### 1. Avvia un backend Supabase
Serve un'istanza Supabase (cloud o self-hosted) a cui puntare. Per lavorare in locale senza
toccare dati reali, nel repo c'è uno stack self-hosted pronto in `infra/supabase/docker/`:
```sh
cd infra/supabase/docker
cp .env.example .env
sh utils/generate-keys.sh --update-env # genera JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY, ecc.
```
Poi avvia lo stack (con `docker-compose.prod.yml` si tengono su solo i servizi che CrAPP usa
davvero — vedi la sezione Produzione più sotto):
```sh
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
docker compose ps # verifica che tutti i container siano "healthy"
```
Applica le migrazioni del progetto al database appena creato:
```sh
cd ../../.. # torna alla root del repo
for f in supabase/migrations/*.sql; do
docker exec -i supabase-db psql -U postgres -d postgres -v ON_ERROR_STOP=1 < "$f"
done
```
### 2. Configura `.env` di CrAPP
Nella root del repo, crea `.env` con le chiavi lette da `infra/supabase/docker/.env`:
```
VITE_SUPABASE_URL=http://localhost:8000
VITE_SUPABASE_PUBLISHABLE_KEY=<ANON_KEY dello stack self-hosted>
SUPABASE_SERVICE_ROLE_KEY=<SERVICE_ROLE_KEY dello stack self-hosted>
```
Facoltative per testare le notifiche push (senza non si rompe nulla, semplicemente niente push):
```
VAPID_PUBLIC_KEY=...
VAPID_PRIVATE_KEY=...
VAPID_SUBJECT=mailto:tuamail@esempio.it
```
### 3. Avvia l'app
```sh
bun run dev # http://localhost:8080
```
Le tabelle sono vuote al primo avvio: crea un giocatore/evento dall'app stessa per avere dati di
test. Dashboard Supabase Studio raggiungibile su `http://localhost:8000` (credenziali
`DASHBOARD_USERNAME`/`DASHBOARD_PASSWORD` in `infra/supabase/docker/.env`).
Altri comandi:
```sh
bun run build:dev # build in modalità development
bun run preview # anteprima di una build
bun run lint # eslint .
bun run format # prettier --write .
```
Non è configurato un test runner.
## Produzione (self-hosted, Node + Docker)
`bun run build` usa Nitro (tramite `@lovable.dev/vite-tanstack-config`), che di default
compilerebbe per Cloudflare Workers — in [vite.config.ts](vite.config.ts) il preset è forzato a
`node-server` per generare un server Node standard, deployabile su qualunque host (coerente con
[docs/PORTABILITA.md](docs/PORTABILITA.md)).
Passo passo, sul server di produzione:
0. **Dominio/DNS**: un solo hostname pubblico basta — `Caddyfile.example` instrada `/api/*`
verso il gateway Supabase (path stripped) e tutto il resto verso l'app, sullo stesso dominio.
- Con un dominio vero (DNS proprio): `tuodominio.it`.
- Con un dominio **gratuito No-IP** (`ddns.net`, ecc.): un hostname singolo basta e avanza
(il piano free ne dà fino a 3, ma qui ne serve uno solo), es. `crapp.ddns.net`.
- Serve un client DDNS attivo sul server (o supporto nel router) che aggiorni l'IP: gli
hostname No-IP gratuiti scadono dopo ~30 giorni di inattività se non confermati.
- Verifica di non essere dietro **CGNAT** (IP pubblico condiviso dall'ISP): in quel caso
nessuna porta è raggiungibile da internet e serve un tunnel (es. Cloudflare Tunnel) al
posto dell'esposizione diretta.
1. **Stack Supabase self-hosted**:
```sh
cp infra/supabase/docker/.env.production.example infra/supabase/docker/.env
cd infra/supabase/docker
sh utils/generate-keys.sh --update-env # secret NUOVI, non riusare quelli di sviluppo
```
Modifica nel `.env` appena creato: `SUPABASE_PUBLIC_URL`/`API_EXTERNAL_URL` (dominio del
punto 0 + `/api`), `SITE_URL` (dominio del punto 0), `DASHBOARD_PASSWORD`, e l'SMTP se
servono email vere. Poi avvia:
```sh
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
```
`docker-compose.prod.yml` fa due cose, pensate anche per girare su hardware limitato (es.
Raspberry Pi 4): non espone porte pubblicamente (solo `127.0.0.1`, dietro il reverse proxy —
vedi `Caddyfile.example` nella stessa cartella), e disattiva i servizi Supabase che CrAPP non
usa (Realtime, Storage, imgproxy, Edge Functions, pooler) — restano solo `db`, `auth`, `rest`,
`api-gw`, più `studio`+`meta` per guardare/gestire il database via interfaccia grafica
(raggiungibile su `<dominio>/api`, con le credenziali `DASHBOARD_USERNAME`/
`DASHBOARD_PASSWORD`). Da 11 container si scende a 6.
2. **Migrazioni**:
```sh
cd ../.. # root del repo
for f in supabase/migrations/*.sql; do
docker exec -i supabase-db psql -U postgres -d postgres -v ON_ERROR_STOP=1 < "$f"
done
```
3. **Stack Strapi** (CMS admin per rosa, classifica, storico partite, scout finalizzato — vedi
`infra/strapi/`): usa lo stesso Postgres dello stack Supabase, in un database logico separato:
```sh
docker exec -i supabase-db psql -U postgres -c "CREATE DATABASE strapi;"
docker exec -i supabase-db psql -U postgres -c "CREATE USER strapi WITH PASSWORD '...';"
docker exec -i supabase-db psql -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE strapi TO strapi;"
cp infra/strapi/.env.production.example infra/strapi/.env
```
Compila in `infra/strapi/.env` i secret (`APP_KEYS`, `JWT_SECRET`, ecc. — genera ognuno con
`openssl rand -base64 32`) e `DATABASE_PASSWORD` (la stessa scelta sopra), poi:
```sh
cd infra/strapi
docker compose up -d --build
```
Al primo accesso su `<dominio>/cms/admin` crea l'utente amministratore Strapi. Da lì, un solo
giro di setup manuale (nessuna automazione: i permessi Strapi si abilitano solo dall'admin UI):
in Settings → Users & Permissions Plugin → Roles → Public abilita `find`/`findOne` per
`Giocatore`, `Riga-classifica` e `Match-storico`; poi in Settings → API Tokens genera un token
con permesso `create` solo su `Scout-match-finale` (va in `STRAPI_WRITE_TOKEN` nell'env
dell'app, punto 5). Infine inserisci a mano rosa/classifica/storico nelle rispettive collezioni
(i valori di partenza sono nella cronologia git di `src/lib/crapp-data.ts`, prima che venissero
spostati qui).
4. **Reverse proxy TLS**: copia `infra/supabase/docker/Caddyfile.example` in `Caddyfile`,
sostituisci il dominio del punto 0, poi `caddy run --config Caddyfile` (o come container).
Gestisce automaticamente il certificato Let's Encrypt.
5. **Env dell'app**:
```sh
cp .env.production.example .env
```
Compila `VITE_SUPABASE_URL` (dominio del punto 0 + `/api`),
`VITE_SUPABASE_PUBLISHABLE_KEY` (= `ANON_KEY` dello stack), `SUPABASE_SERVICE_ROLE_KEY`
(= `SERVICE_ROLE_KEY`), `VITE_STRAPI_URL` (dominio del punto 0 + `/cms`), `STRAPI_WRITE_TOKEN`
(il token generato al punto 3), e le chiavi `VAPID_*` reali
(`npx web-push generate-vapid-keys`).
6. **Build e avvio**:
```sh
bun run build
node .output/server/index.mjs # ascolta su PORT (default 3000)
```
Tienilo vivo con un process manager (pm2/systemd) — il Caddy del punto 4 lo espone via TLS
sull'hostname app.
7. **Job pianificati** — gli endpoint `src/routes/api/public/promemoria-palloni.ts` e
`sollecita-presenze.ts` vanno richiamati periodicamente via HTTP POST da un cron di sistema o
`pg_cron`: non partono da soli.
## Continuare da Lovable
Il progetto resta modificabile anche dall'[editor Lovable](https://lovable.dev/projects/8d07b0e4-6bd2-4a17-9dd2-bb2cf13f9f7c):
le modifiche fatte lì vengono committate direttamente su questo repository, e viceversa i push
su `main` sincronizzano l'editor.
## Portabilità
Il progetto è pensato per restare deployabile su un normale server **Node.js + PostgreSQL**,
senza dipendenze esclusive da Lovable Cloud — vedi [docs/PORTABILITA.md](docs/PORTABILITA.md)
per lo stato attuale e le regole da rispettare.
## Note per chi sviluppa con Claude Code
Vedi [CLAUDE.md](CLAUDE.md) per architettura dettagliata, convenzioni del repo e vincoli di
efficienza sul piano cloud a consumo.
+1
View File
@@ -15,6 +15,7 @@ senza dipendere da servizi esclusivi di Lovable Cloud.
| Auth | Sì | GoTrue self-hosted oppure qualsiasi provider OIDC. |
| `src/integrations/lovable/*` | Opzionale | Login social gestito da Lovable: **non importato da nessuna schermata**, rimovibile senza impatti. |
| `@lovable.dev/vite-tanstack-config` | Solo build | Preset Vite; sostituibile con una config Vite/TanStack esplicita. |
| CMS Strapi (`infra/strapi/`) | Sì | Open source e self-hostable, Postgres come backend; l'app lo consuma solo via `src/lib/strapi-client.ts`. |
## Regole da rispettare nelle prossime modifiche
+8
View File
@@ -0,0 +1,8 @@
HOST=0.0.0.0
PORT=1337
APP_KEYS="toBeModified1,toBeModified2"
API_TOKEN_SALT=tobemodified
ADMIN_JWT_SECRET=tobemodified
TRANSFER_TOKEN_SALT=tobemodified
JWT_SECRET=tobemodified
ENCRYPTION_KEY=tobemodified
+23
View File
@@ -0,0 +1,23 @@
# TEMPLATE DI PRODUZIONE per il CMS Strapi (rosa, classifica, storico partite, scout finalizzato).
# Copia in .env accanto a docker-compose.yml e compila. Rigenera SEMPRE secret nuovi in produzione,
# non riusare quelli di sviluppo — un valore per riga, generabili con: openssl rand -base64 32
HOST=0.0.0.0
PORT=1337
APP_KEYS=
API_TOKEN_SALT=
ADMIN_JWT_SECRET=
JWT_SECRET=
TRANSFER_TOKEN_SALT=
ENCRYPTION_KEY=
# Stesso cluster Postgres dello stack Supabase self-hosted (infra/supabase/docker/), database
# logico separato — vedi README "Produzione", passo Strapi.
DATABASE_CLIENT=postgres
DATABASE_HOST=db
DATABASE_PORT=5432
DATABASE_NAME=strapi
DATABASE_USERNAME=strapi
DATABASE_PASSWORD=
DATABASE_SSL=false
+131
View File
@@ -0,0 +1,131 @@
############################
# OS X
############################
.DS_Store
.AppleDouble
.LSOverride
Icon
.Spotlight-V100
.Trashes
._*
############################
# Linux
############################
*~
############################
# Windows
############################
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/
*.cab
*.msi
*.msm
*.msp
############################
# Packages
############################
*.7z
*.csv
*.dat
*.dmg
*.gz
*.iso
*.jar
*.rar
*.tar
*.zip
*.com
*.class
*.dll
*.exe
*.o
*.seed
*.so
*.swo
*.swp
*.swn
*.swm
*.out
*.pid
############################
# Logs and databases
############################
.tmp
*.log
*.sql
*.sqlite
*.sqlite3
############################
# Misc.
############################
*#
ssl
.idea
nbproject
public/uploads/*
!public/uploads/.gitkeep
.tsbuildinfo
.eslintcache
############################
# Node.js
############################
lib-cov
lcov.info
pids
logs
results
node_modules
.node_history
############################
# Package managers
############################
.yarn/*
!.yarn/cache
!.yarn/unplugged
!.yarn/patches
!.yarn/releases
!.yarn/sdks
!.yarn/versions
.pnp.*
yarn-error.log
############################
# Tests
############################
coverage
############################
# Strapi
############################
.env
license.txt
exports
.strapi
dist
build
.strapi-updater.json
.strapi-cloud.json
+15
View File
@@ -0,0 +1,15 @@
# Immagine di produzione per il CMS admin (rosa, classifica, storico partite, scout finalizzato).
# Build multi-stage: installa e builda l'admin panel, poi copia solo l'output nell'immagine finale.
FROM node:20-slim AS build
WORKDIR /opt/app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM node:20-slim
WORKDIR /opt/app
ENV NODE_ENV=production
COPY --from=build /opt/app .
EXPOSE 1337
CMD ["npm", "run", "start"]
+61
View File
@@ -0,0 +1,61 @@
# 🚀 Getting started with Strapi
Strapi comes with a full featured [Command Line Interface](https://docs.strapi.io/dev-docs/cli) (CLI) which lets you scaffold and manage your project in seconds.
### `develop`
Start your Strapi application with autoReload enabled. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-develop)
```
npm run develop
# or
yarn develop
```
### `start`
Start your Strapi application with autoReload disabled. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-start)
```
npm run start
# or
yarn start
```
### `build`
Build your admin panel. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-build)
```
npm run build
# or
yarn build
```
## ⚙️ Deployment
Strapi gives you many possible deployment options for your project including [Strapi Cloud](https://cloud.strapi.io). Browse the [deployment section of the documentation](https://docs.strapi.io/dev-docs/deployment) to find the best solution for your use case.
```
yarn strapi deploy
```
## 📚 Learn more
- [Resource center](https://strapi.io/resource-center) - Strapi resource center.
- [Strapi documentation](https://docs.strapi.io) - Official Strapi documentation.
- [Strapi tutorials](https://strapi.io/tutorials) - List of tutorials made by the core team and the community.
- [Strapi blog](https://strapi.io/blog) - Official Strapi blog containing articles made by the Strapi team and the community.
- [Changelog](https://strapi.io/changelog) - Find out about the Strapi product updates, new features and general improvements.
Feel free to check out the [Strapi GitHub repository](https://github.com/strapi/strapi). Your feedback and contributions are welcome!
## ✨ Community
- [Discord](https://discord.strapi.io) - Come chat with the Strapi community including the core team.
- [Forum](https://forum.strapi.io/) - Place to discuss, ask questions and find answers, show your Strapi project and get feedback or just talk with other Community members.
- [Awesome Strapi](https://github.com/strapi/awesome-strapi) - A curated list of awesome things related to Strapi.
---
<sub>🤫 Psst! [Strapi is hiring](https://strapi.io/careers).</sub>
+21
View File
@@ -0,0 +1,21 @@
module.exports = ({ env }) => ({
auth: {
secret: env('ADMIN_JWT_SECRET'),
},
apiToken: {
salt: env('API_TOKEN_SALT'),
},
transfer: {
token: {
salt: env('TRANSFER_TOKEN_SALT'),
},
},
secrets: {
encryptionKey: env('ENCRYPTION_KEY'),
},
flags: {
nps: env.bool('FLAG_NPS', true),
promoteEE: env.bool('FLAG_PROMOTE_EE', true),
docLinks: env.bool('FLAG_DOC_LINKS', true),
},
});
+12
View File
@@ -0,0 +1,12 @@
module.exports = {
rest: {
defaultLimit: 25,
maxLimit: 100,
withCount: true,
strictParams: true,
},
documents: {
strictParams: true,
strictRelations: true,
},
};
+72
View File
@@ -0,0 +1,72 @@
/** @import { Core } from '@strapi/strapi' */
const path = require('path');
const { isDatabaseClientKind } = require('@strapi/database');
module.exports = ({ env }) => {
const client = env('DATABASE_CLIENT', 'sqlite');
if (!isDatabaseClientKind(client)) {
throw new Error(
`Unsupported DATABASE_CLIENT: ${client}. Use "postgres", "mysql", or "sqlite".`
);
}
/** @type {Record<Core.Config.Database.ClientKind, Core.Config.Database['connection']>} */
const connections = {
mysql: {
client: 'mysql',
connection: {
host: env('DATABASE_HOST', 'localhost'),
port: env.int('DATABASE_PORT', 3306),
database: env('DATABASE_NAME', 'strapi'),
user: env('DATABASE_USERNAME', 'strapi'),
password: env('DATABASE_PASSWORD', 'strapi'),
ssl: env.bool('DATABASE_SSL', false) && {
key: env('DATABASE_SSL_KEY', undefined),
cert: env('DATABASE_SSL_CERT', undefined),
ca: env('DATABASE_SSL_CA', undefined),
capath: env('DATABASE_SSL_CAPATH', undefined),
cipher: env('DATABASE_SSL_CIPHER', undefined),
rejectUnauthorized: env.bool('DATABASE_SSL_REJECT_UNAUTHORIZED', true),
},
},
pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) },
},
postgres: {
client: 'postgres',
connection: {
connectionString: env('DATABASE_URL'),
host: env('DATABASE_HOST', 'localhost'),
port: env.int('DATABASE_PORT', 5432),
database: env('DATABASE_NAME', 'strapi'),
user: env('DATABASE_USERNAME', 'strapi'),
password: env('DATABASE_PASSWORD', 'strapi'),
ssl: env.bool('DATABASE_SSL', false) && {
key: env('DATABASE_SSL_KEY', undefined),
cert: env('DATABASE_SSL_CERT', undefined),
ca: env('DATABASE_SSL_CA', undefined),
capath: env('DATABASE_SSL_CAPATH', undefined),
cipher: env('DATABASE_SSL_CIPHER', undefined),
rejectUnauthorized: env.bool('DATABASE_SSL_REJECT_UNAUTHORIZED', true),
},
schema: env('DATABASE_SCHEMA', 'public'),
},
pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) },
},
sqlite: {
client: 'sqlite',
connection: {
filename: path.join(__dirname, '..', env('DATABASE_FILENAME', '.tmp/data.db')),
},
useNullAsDefault: true,
},
};
return {
connection: {
...connections[client],
acquireConnectionTimeout: env.int('DATABASE_CONNECTION_TIMEOUT', 60000),
},
};
};
+12
View File
@@ -0,0 +1,12 @@
module.exports = [
'strapi::logger',
'strapi::errors',
'strapi::security',
'strapi::cors',
'strapi::poweredBy',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::favicon',
'strapi::public',
];
+41
View File
@@ -0,0 +1,41 @@
const allowedMediaTypes = [
'image/*',
'video/*',
'audio/*',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.*',
'text/plain',
'text/csv',
];
const deniedTypes = [
'image/svg+xml',
'application/vnd.microsoft.portable-executable',
'application/x-msdownload',
'application/x-msdos-program',
'application/x-executable',
'application/x-dosexec',
'application/x-sh',
'text/x-shellscript',
'application/x-mach-binary',
];
module.exports = () => ({
'users-permissions': {
config: {
jwtManagement: 'refresh',
sessions: {
httpOnly: true,
},
},
},
upload: {
config: {
security: {
allowedTypes: allowedMediaTypes,
deniedTypes,
},
},
},
});
+10
View File
@@ -0,0 +1,10 @@
module.exports = ({ env }) => ({
host: env('HOST', '0.0.0.0'),
port: env.int('PORT', 1337),
app: {
keys: env.array('APP_KEYS'),
},
webhooks: {
populateRelations: env.bool('WEBHOOKS_POPULATE_RELATIONS', false),
},
});
+38
View File
@@ -0,0 +1,38 @@
# CMS admin (rosa, classifica, storico partite, scout finalizzato) per CrAPP.
# Si appoggia allo stesso Postgres dello stack Supabase self-hosted (infra/supabase/docker/,
# progetto compose "supabase", rete di default "supabase_default" — verifica con
# `docker network ls` se il nome differisce) con un database logico separato ("strapi"), invece
# di un secondo container Postgres: stesso isolamento dei dati, metà del carico su hardware
# limitato (es. Raspberry Pi 4).
#
# Uso:
# cp .env.production.example .env # compila i secret, vedi README "Produzione"
# docker compose up -d
#
# Crea prima il database logico (una tantum, vedi README):
# docker exec -i supabase-db psql -U postgres -c "CREATE DATABASE strapi;"
# docker exec -i supabase-db psql -U postgres -c "CREATE USER strapi WITH PASSWORD '...';"
# docker exec -i supabase-db psql -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE strapi TO strapi;"
services:
strapi:
build: .
container_name: crapp-strapi
restart: unless-stopped
env_file:
- .env
ports:
# Solo locale, come api-gw dello stack Supabase — dietro il reverse proxy TLS (Caddyfile).
- "127.0.0.1:1337:1337"
volumes:
- strapi-uploads:/opt/app/public/uploads
networks:
- supabase
networks:
supabase:
name: supabase_default
external: true
volumes:
strapi-uploads:
Binary file not shown.

After

Width:  |  Height:  |  Size: 497 B

+9
View File
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "ES2021",
"checkJs": true,
"allowJs": true
}
}
+21579
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
{
"name": "strapi",
"version": "0.1.0",
"private": true,
"description": "A Strapi application",
"scripts": {
"build": "strapi build",
"console": "strapi console",
"deploy": "strapi deploy",
"dev": "strapi develop",
"develop": "strapi develop",
"start": "strapi start",
"strapi": "strapi",
"upgrade": "npx @strapi/upgrade latest",
"upgrade:dry": "npx @strapi/upgrade latest --dry"
},
"dependencies": {
"@strapi/database": "5.52.2",
"@strapi/plugin-cloud": "5.52.2",
"@strapi/plugin-users-permissions": "5.52.2",
"@strapi/strapi": "5.52.2",
"pg": "8.20.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-router-dom": "^6.30.3",
"styled-components": "^6.0.0"
},
"devDependencies": {},
"engines": {
"node": ">=20.0.0 <=26.x.x",
"npm": ">=6.0.0"
},
"strapi": {
"uuid": "7cc5aaf5-d55f-4ee8-9141-02e2dd523a1c",
"installId": "8e493229589483c80fc99464f570c7c84540d2c64f9fe878c9677834e48e6fa1"
},
"allowScripts": {
"esbuild@0.28.2": true,
"esbuild@0.21.5": true,
"@swc/core@1.16.1": true,
"core-js-pure@3.50.0": true
}
}
+3
View File
@@ -0,0 +1,3 @@
# To prevent search engines from seeing the site altogether, uncomment the next two lines:
# User-Agent: *
# Disallow: /
+39
View File
@@ -0,0 +1,39 @@
const config = {
locales: [
// 'ar',
// 'fr',
// 'cs',
// 'de',
// 'da',
// 'es',
// 'he',
// 'id',
// 'it',
// 'ja',
// 'ko',
// 'ms',
// 'nl',
// 'no',
// 'pl',
// 'pt-BR',
// 'pt',
// 'ru',
// 'sk',
// 'sv',
// 'th',
// 'tr',
// 'uk',
// 'vi',
// 'zh-Hans',
// 'zh',
],
};
const bootstrap = (app) => {
console.log(app);
};
export default {
config,
bootstrap,
};
@@ -0,0 +1,12 @@
const { mergeConfig } = require('vite');
module.exports = (config) => {
// Important: always return the modified config
return mergeConfig(config, {
resolve: {
alias: {
'@': '/src',
},
},
});
};
View File
@@ -0,0 +1,38 @@
{
"kind": "collectionType",
"collectionName": "giocatori",
"info": {
"singularName": "giocatore",
"pluralName": "giocatoris",
"displayName": "Giocatore",
"description": "Anagrafica rosa CRAP Volley. Le statistiche (presenze, MVP, ecc.) restano calcolate lato app, non vivono qui."
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {},
"attributes": {
"codice": {
"type": "string",
"required": true,
"unique": true,
"regex": "^g[0-9]+$"
},
"nome": {
"type": "string",
"required": true
},
"numero": {
"type": "integer",
"required": true
},
"ruolo": {
"type": "string",
"required": true
},
"nascita": {
"type": "date",
"required": true
}
}
}
@@ -0,0 +1,5 @@
'use strict';
const { createCoreController } = require('@strapi/strapi').factories;
module.exports = createCoreController('api::giocatore.giocatore');
@@ -0,0 +1,5 @@
'use strict';
const { createCoreRouter } = require('@strapi/strapi').factories;
module.exports = createCoreRouter('api::giocatore.giocatore');
@@ -0,0 +1,5 @@
'use strict';
const { createCoreService } = require('@strapi/strapi').factories;
module.exports = createCoreService('api::giocatore.giocatore');
@@ -0,0 +1,43 @@
{
"kind": "collectionType",
"collectionName": "match_storici",
"info": {
"singularName": "match-storico",
"pluralName": "match-storicos",
"displayName": "Match storico",
"description": "Storico partite di campionato CSI."
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {},
"attributes": {
"data": {
"type": "date",
"required": true
},
"avversario": {
"type": "string",
"required": true
},
"casa": {
"type": "boolean",
"required": true,
"default": true
},
"set_nostri": {
"type": "integer",
"required": true
},
"set_loro": {
"type": "integer",
"required": true
},
"parziali": {
"type": "json"
},
"mvp": {
"type": "string"
}
}
}
@@ -0,0 +1,5 @@
'use strict';
const { createCoreController } = require('@strapi/strapi').factories;
module.exports = createCoreController('api::match-storico.match-storico');
@@ -0,0 +1,5 @@
'use strict';
const { createCoreRouter } = require('@strapi/strapi').factories;
module.exports = createCoreRouter('api::match-storico.match-storico');
@@ -0,0 +1,5 @@
'use strict';
const { createCoreService } = require('@strapi/strapi').factories;
module.exports = createCoreService('api::match-storico.match-storico');
@@ -0,0 +1,54 @@
{
"kind": "collectionType",
"collectionName": "righe_classifica",
"info": {
"singularName": "riga-classifica",
"pluralName": "riga-classificas",
"displayName": "Riga classifica",
"description": "Classifica campionato CSI, una riga per squadra."
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {},
"attributes": {
"pos": {
"type": "integer",
"required": true
},
"squadra": {
"type": "string",
"required": true
},
"giocate": {
"type": "integer",
"required": true,
"default": 0
},
"vinte": {
"type": "integer",
"required": true,
"default": 0
},
"perse": {
"type": "integer",
"required": true,
"default": 0
},
"set_fatti": {
"type": "integer",
"required": true,
"default": 0
},
"set_subiti": {
"type": "integer",
"required": true,
"default": 0
},
"punti": {
"type": "integer",
"required": true,
"default": 0
}
}
}
@@ -0,0 +1,5 @@
'use strict';
const { createCoreController } = require('@strapi/strapi').factories;
module.exports = createCoreController('api::riga-classifica.riga-classifica');
@@ -0,0 +1,5 @@
'use strict';
const { createCoreRouter } = require('@strapi/strapi').factories;
module.exports = createCoreRouter('api::riga-classifica.riga-classifica');
@@ -0,0 +1,5 @@
'use strict';
const { createCoreService } = require('@strapi/strapi').factories;
module.exports = createCoreService('api::riga-classifica.riga-classifica');
@@ -0,0 +1,51 @@
{
"kind": "collectionType",
"collectionName": "scout_match_finales",
"info": {
"singularName": "scout-match-finale",
"pluralName": "scout-match-finales",
"displayName": "Scout match finale",
"description": "Risultato finale di una partita scoutata dal vivo (dato storico, di sola consultazione). L'azione-per-azione resta in scout_live/localStorage durante la partita."
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {},
"attributes": {
"match_id": {
"type": "string",
"required": true,
"unique": true
},
"data": {
"type": "date",
"required": true
},
"avversario": {
"type": "string",
"required": true
},
"casa": {
"type": "boolean",
"required": true,
"default": true
},
"set_nostri": {
"type": "integer",
"required": true
},
"set_loro": {
"type": "integer",
"required": true
},
"parziali": {
"type": "json"
},
"mvp": {
"type": "string"
},
"azioni": {
"type": "json"
}
}
}
@@ -0,0 +1,5 @@
'use strict';
const { createCoreController } = require('@strapi/strapi').factories;
module.exports = createCoreController('api::scout-match-finale.scout-match-finale');
@@ -0,0 +1,5 @@
'use strict';
const { createCoreRouter } = require('@strapi/strapi').factories;
module.exports = createCoreRouter('api::scout-match-finale.scout-match-finale');
@@ -0,0 +1,5 @@
'use strict';
const { createCoreService } = require('@strapi/strapi').factories;
module.exports = createCoreService('api::scout-match-finale.scout-match-finale');
+6
View File
@@ -0,0 +1,6 @@
'use strict';
module.exports = {
register(/*{ strapi }*/) {},
bootstrap(/*{ strapi }*/) {},
};
+389
View File
@@ -0,0 +1,389 @@
############
# Docker compose override files to layer on top of docker-compose.yml.
# Native docker compose COMPOSE_FILE: colon-separated list, base file first.
# Manage with: ./run.sh config add|remove <name>
#
# Examples:
# COMPOSE_FILE=docker-compose.yml
# COMPOSE_FILE=docker-compose.yml:docker-compose.pg17.yml
#
############
COMPOSE_FILE=docker-compose.yml
############
# Secrets
#
# YOU MUST CHANGE ALL THE DEFAULT VALUES BELOW BEFORE STARTING
# THE CONTAINERS FOR THE FIRST TIME!
#
# Documentation:
# https://supabase.com/docs/guides/self-hosting/docker#configuring-and-securing-supabase
#
# To generate secrets and API keys:
# 1. sh utils/generate-keys.sh
# 2. sh utils/add-new-auth-keys.sh
#
############
# Postgres
POSTGRES_PASSWORD=your-super-secret-and-long-postgres-password
# Legacy symmetric HS256 key
JWT_SECRET=your-super-secret-jwt-token-with-at-least-32-characters-long
# Legacy API keys (HS256-signed JWTs)
ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE
SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q
# Asymmetric key pair (ES256) and opaque API keys
#
# Documentation:
# https://supabase.com/docs/guides/self-hosting/self-hosted-auth-keys
#
# To generate:
# sh ./utils/add-new-auth-keys.sh
#
# Opaque API key for client-side use (anon role).
SUPABASE_PUBLISHABLE_KEY=
# Opaque API key for server-side use (service_role). Never expose in client code.
SUPABASE_SECRET_KEY=
# JSON array of signing JWKs (EC private + legacy symmetric).
# Used by Auth.
JWT_KEYS=
# JWKS for token verification (EC public + legacy symmetric).
# Used by PostgREST, Realtime, Storage to verify tokens.
JWT_JWKS=
# Access to Dashboard
DASHBOARD_USERNAME=supabase
DASHBOARD_PASSWORD=this_password_is_insecure_and_should_be_updated
# Encryption key for securing Realtime and Supavisor communications.
# (Must be at least 64 characters; generate with: openssl rand -base64 48)
SECRET_KEY_BASE=UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq
# Encryption key used by Realtime for sensitive fields in the `_realtime` schema.
# (Must be exactly 16 characters; generate with: `openssl rand -hex 8`)
REALTIME_DB_ENC_KEY=supabaserealtime
# Encryption key used by Supavisor for storing encrypted configuration.
# (Must be exactly 32 characters; generate with: openssl rand -hex 16)
VAULT_ENC_KEY=your-32-character-encryption-key
# Encryption key for securing connection strings used by Studio against postgres-meta.
# (Must be at least 32 characters; generate with openssl rand -base64 24)
PG_META_CRYPTO_KEY=your-encryption-key-32-chars-min
# API token for log ingestion used by Logflare and Vector.
# (Must be at least 32 characters; generate with openssl rand -base64 24)
LOGFLARE_PUBLIC_ACCESS_TOKEN=your-super-secret-and-long-logflare-key-public
# API token used for Logflare management operations. Never expose client-side.
# (Must be at least 32 characters; generate with openssl rand -base64 24)
LOGFLARE_PRIVATE_ACCESS_TOKEN=your-super-secret-and-long-logflare-key-private
# Access key ID (username-like) for accessing the S3 protocol endpoint in Storage.
# (Generate with: openssl rand -hex 16)
S3_PROTOCOL_ACCESS_KEY_ID=625729a08b95bf1b7ff351a663f3a23c
# Secret key (password-like) used with S3_PROTOCOL_ACCESS_KEY_ID.
# (Generate with: openssl rand -hex 32)
S3_PROTOCOL_ACCESS_KEY_SECRET=850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907
############
# URLs - Configure hostnames below to reflect your actual domain name
############
# Access to Dashboard and REST API
SUPABASE_PUBLIC_URL=http://localhost:8000
# Full external URL of the Auth service, used to construct OAuth callbacks,
# SAML endpoints, and email links
API_EXTERNAL_URL=http://localhost:8000/auth/v1
# See also the Auth section below for Site URL and Redirect URLs configuration
############
# Database - Postgres configuration
############
# Using default user (postgres)
POSTGRES_HOST=db
POSTGRES_DB=postgres
# Default configuration includes Supavisor exposing POSTGRES_PORT
# Postgres uses POSTGRES_PORT inside the container
# Documentation:
# https://supabase.com/docs/guides/self-hosting/accessing-postgres
POSTGRES_PORT=5432
############
# Database pooler
############
# Self-hosted Supabase uses Supavisor as the default database pooler.
# If you use the PgBouncer docker-compose override, Supavisor is disabled
# and the pooler settings below are used to configure PgBouncer instead.
#
# Supavisor exposes POSTGRES_PORT and POOLER_PROXY_PORT_TRANSACTION,
# POSTGRES_PORT is used for session mode pooling
# PgBouncer only exposes POOLER_PROXY_PORT_TRANSACTION.
#
# Port to use for transaction mode pooling connections
POOLER_PROXY_PORT_TRANSACTION=6543
# Maximum number of PostgreSQL connections Supavisor or PgBouncer opens per pool
POOLER_DEFAULT_POOL_SIZE=20
# Maximum number of client connections Supavisor or PgBouncer accepts per pool
POOLER_MAX_CLIENT_CONN=100
# Unique Supavisor tenant identifier
# Documentation:
# https://supabase.com/docs/guides/self-hosting/accessing-postgres
POOLER_TENANT_ID=your-tenant-id
# Pool size for internal metadata storage used by Supavisor
# This is separate from client connections and used only by Supavisor itself
POOLER_DB_POOL_SIZE=5
############
# Studio - Configuration for the Dashboard
############
STUDIO_DEFAULT_ORGANIZATION=Default Organization
STUDIO_DEFAULT_PROJECT=Default Project
# Add your OpenAI API key to enable AI Assistant
OPENAI_API_KEY=sk-proj-xxxxxxxx
############
# Auth - Configuration for the authentication server
############
## General settings
# Equivalent to "Site URL" and "Redirect URLs" platform configuration options
# Documentation: https://supabase.com/docs/guides/auth/redirect-urls
SITE_URL=http://localhost:3000
ADDITIONAL_REDIRECT_URLS=
JWT_EXPIRY=3600
DISABLE_SIGNUP=false
## Mailer Config
MAILER_URLPATHS_CONFIRMATION="/auth/v1/verify"
MAILER_URLPATHS_INVITE="/auth/v1/verify"
MAILER_URLPATHS_RECOVERY="/auth/v1/verify"
MAILER_URLPATHS_EMAIL_CHANGE="/auth/v1/verify"
## Email auth
ENABLE_EMAIL_SIGNUP=true
ENABLE_EMAIL_AUTOCONFIRM=false
SMTP_ADMIN_EMAIL=admin@example.com
SMTP_HOST=supabase-mail
SMTP_PORT=2500
SMTP_USER=fake_mail_user
SMTP_PASS=fake_mail_password
SMTP_SENDER_NAME=fake_sender
ENABLE_ANONYMOUS_USERS=false
## Phone auth
ENABLE_PHONE_SIGNUP=true
ENABLE_PHONE_AUTOCONFIRM=true
## OAuth / Social login providers
# Uncomment and fill in the providers you want to enable.
# You must ALSO uncomment the matching GOTRUE_EXTERNAL_* lines in docker-compose.yml
# Documentation: https://supabase.com/docs/guides/self-hosting/self-hosted-oauth
# GOOGLE_ENABLED=false
# GOOGLE_CLIENT_ID=
# GOOGLE_SECRET=
# GITHUB_ENABLED=false
# GITHUB_CLIENT_ID=
# GITHUB_SECRET=
# AZURE_ENABLED=false
# AZURE_CLIENT_ID=
# AZURE_SECRET=
# Phone / SMS provider configuration
# Uncomment to configure SMS delivery for phone auth and phone MFA.
# You must ALSO uncomment the matching GOTRUE_SMS_* lines in docker-compose.yml
# Documentation: https://supabase.com/docs/guides/self-hosting/self-hosted-phone-mfa
# SMS_PROVIDER=twilio
# SMS_OTP_EXP=60
# SMS_OTP_LENGTH=6
# SMS_MAX_FREQUENCY=60s
# SMS_TEMPLATE=Your code is {{ .Code }}
# SMS_TWILIO_ACCOUNT_SID=
# SMS_TWILIO_AUTH_TOKEN=
# SMS_TWILIO_MESSAGE_SERVICE_SID=
# Test OTP: map phone numbers to fixed OTP codes for development
# Format: phone1:code1,phone2:code2
# SMS_TEST_OTP=
# Multi-factor authentication (MFA)
# Uncomment to change MFA defaults.
# You must ALSO uncomment the matching GOTRUE_MFA_* lines in docker-compose.yml
# App Authenticator (TOTP) - enabled by default
# MFA_TOTP_ENROLL_ENABLED=true
# MFA_TOTP_VERIFY_ENABLED=true
# Phone MFA - disabled by default (opt-in)
# MFA_PHONE_ENROLL_ENABLED=false
# MFA_PHONE_VERIFY_ENABLED=false
# Maximum MFA factors a user can enroll
# MFA_MAX_ENROLLED_FACTORS=10
## SAML SSO
# You must ALSO uncomment the matching GOTRUE_* lines in docker-compose.yml
# Documentation: https://supabase.com/docs/guides/self-hosting/self-hosted-saml-sso
# SAML_ENABLED=true
# SAML_PRIVATE_KEY=<your-base64-encoded-private-key>
# Optional: accept encrypted SAML assertions from IdPs (default: false)
# SAML_ALLOW_ENCRYPTED_ASSERTIONS=false
# Optional: how long relay state tokens remain valid (default: 2m0s)
# SAML_RELAY_STATE_VALIDITY_PERIOD=2m0s
# Optional: override the SAML entity ID / ACS base URL
# Defaults to API_EXTERNAL_URL if not set
# SAML_EXTERNAL_URL=https://supabase.example.com:8000/auth/v1
# Optional: rate limit on the ACS endpoint (requests per second, default: 15)
# SAML_RATE_LIMIT_ASSERTION=15
############
# Storage - Configuration for Storage
############
# Check the S3_PROTOCOL_ACCESS_KEY_ID/SECRET above, and
# refer to the documentation at:
# https://supabase.com/docs/guides/self-hosting/self-hosted-s3
# to learn how to configure the S3 protocol endpoint
# S3 bucket when using S3 backend, directory name when using 'file'
GLOBAL_S3_BUCKET=stub
# Used for S3 protocol endpoint configuration
REGION=stub
# Used by MinIO when added via:
# docker compose -f docker-compose.yml -f docker-compose.s3.yml up -d
MINIO_ROOT_USER=supa-storage
# Root administrator password for the RustFS or MinIO server.
# (Must be 8+ characters; generate with: openssl rand -hex 16)
MINIO_ROOT_PASSWORD=secret1234
# Equivalent to project_ref as described here:
# https://supabase.com/docs/guides/storage/s3/authentication#session-token
STORAGE_TENANT_ID=stub
############
# Functions - Configuration for Edge functions
############
# Documentation:
# https://supabase.com/docs/guides/self-hosting/self-hosted-functions
# NOTE: VERIFY_JWT applies to all functions
FUNCTIONS_VERIFY_JWT=false
############
# API - Configuration for PostgREST
############
# Postgres schemas exposed via the REST API
PGRST_DB_SCHEMAS=public,graphql_public
# Max number of rows returned by a request
PGRST_DB_MAX_ROWS=1000
# Extra schemas added to the search_path of every request
PGRST_DB_EXTRA_SEARCH_PATH=public
############
# Logs and Analytics
############
## Vector log collection and routing
# Docker socket location - required for proper Vector operation
DOCKER_SOCKET_LOCATION=/var/run/docker.sock
# For Podman use the following:
# DOCKER_SOCKET_LOCATION=/run/podman/podman.sock
## Analytics (Logflare)
# Check the LOGFLARE_* access token configuration _above_.
# If Logflare has to be externally exposed - configure securely!
# Google Cloud Project details
# Documentation:
# https://supabase.com/docs/reference/self-hosting-analytics/introduction
GOOGLE_PROJECT_ID=GOOGLE_PROJECT_ID
GOOGLE_PROJECT_NUMBER=GOOGLE_PROJECT_NUMBER
############
# API gateway
############
# Host port the API gateway (Envoy by default) listens on.
API_GW_HTTP_PORT=8000
# Kong gateway override only (sh run.sh config add kong). KONG_HTTPS_PORT is
# Kong's built-in HTTPS listener; KONG_HTTP_PORT is kept as a fallback for
# API_GW_HTTP_PORT so existing .env files continue to work.
KONG_HTTP_PORT=8000
KONG_HTTPS_PORT=8443
# Used internally by the API gateway - DO NOT use in any client or server code.
# Pre-signed ES256 JWT "API key" for anon role.
ANON_KEY_ASYMMETRIC=
# Pre-signed ES256 JWT "API key" for service_role.
SERVICE_ROLE_KEY_ASYMMETRIC=
############
# imgproxy
############
# Enable webp support
IMGPROXY_AUTO_WEBP=true
############
# TLS Proxy - Optional Caddy or Nginx reverse proxy with Let's Encrypt
############
# Documentation:
# https://supabase.com/docs/guides/self-hosting/self-hosted-proxy-https
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d
# docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
# Domain name for the proxy (must point to your server)
PROXY_DOMAIN=your-domain.example.com
# Email for Let's Encrypt certificate notifications (nginx only, Caddy uses PROXY_DOMAIN).
# This should be a valid email, not a placeholder (otherwise Certbot may fail to start).
CERTBOT_EMAIL=admin@example.com
@@ -0,0 +1,402 @@
# TEMPLATE DI PRODUZIONE — non usare i valori cosi' come sono.
#
# 1. Copia questo file in .env: cp .env.production.example .env
# 2. Rigenera TUTTI i secret (non riusare quelli di sviluppo):
# sh utils/generate-keys.sh --update-env
# 3. Sostituisci i placeholder tuodominio.it con il dominio reale (un solo hostname: le API
# Supabase sono servite sotto /api, instradate per path da Caddy — vedi Caddyfile.example).
# Con No-IP gratuito, es. crapp.ddns.net per tutto.
# 4. Imposta una DASHBOARD_PASSWORD robusta e, se servono email
# (reset password, inviti), un SMTP reale al posto di quello finto.
# 5. Avvia con l'override che non espone porte pubblicamente:
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
#
############
# Docker compose override files to layer on top of docker-compose.yml.
# Native docker compose COMPOSE_FILE: colon-separated list, base file first.
# Manage with: ./run.sh config add|remove <name>
#
# Examples:
# COMPOSE_FILE=docker-compose.yml
# COMPOSE_FILE=docker-compose.yml:docker-compose.pg17.yml
#
############
COMPOSE_FILE=docker-compose.yml
############
# Secrets
#
# YOU MUST CHANGE ALL THE DEFAULT VALUES BELOW BEFORE STARTING
# THE CONTAINERS FOR THE FIRST TIME!
#
# Documentation:
# https://supabase.com/docs/guides/self-hosting/docker#configuring-and-securing-supabase
#
# To generate secrets and API keys:
# 1. sh utils/generate-keys.sh
# 2. sh utils/add-new-auth-keys.sh
#
############
# Postgres
POSTGRES_PASSWORD=your-super-secret-and-long-postgres-password
# Legacy symmetric HS256 key
JWT_SECRET=your-super-secret-jwt-token-with-at-least-32-characters-long
# Legacy API keys (HS256-signed JWTs)
ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE
SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q
# Asymmetric key pair (ES256) and opaque API keys
#
# Documentation:
# https://supabase.com/docs/guides/self-hosting/self-hosted-auth-keys
#
# To generate:
# sh ./utils/add-new-auth-keys.sh
#
# Opaque API key for client-side use (anon role).
SUPABASE_PUBLISHABLE_KEY=
# Opaque API key for server-side use (service_role). Never expose in client code.
SUPABASE_SECRET_KEY=
# JSON array of signing JWKs (EC private + legacy symmetric).
# Used by Auth.
JWT_KEYS=
# JWKS for token verification (EC public + legacy symmetric).
# Used by PostgREST, Realtime, Storage to verify tokens.
JWT_JWKS=
# Access to Dashboard
DASHBOARD_USERNAME=supabase
DASHBOARD_PASSWORD=CAMBIAMI-password-robusta
# Encryption key for securing Realtime and Supavisor communications.
# (Must be at least 64 characters; generate with: openssl rand -base64 48)
SECRET_KEY_BASE=UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq
# Encryption key used by Realtime for sensitive fields in the `_realtime` schema.
# (Must be exactly 16 characters; generate with: `openssl rand -hex 8`)
REALTIME_DB_ENC_KEY=supabaserealtime
# Encryption key used by Supavisor for storing encrypted configuration.
# (Must be exactly 32 characters; generate with: openssl rand -hex 16)
VAULT_ENC_KEY=your-32-character-encryption-key
# Encryption key for securing connection strings used by Studio against postgres-meta.
# (Must be at least 32 characters; generate with openssl rand -base64 24)
PG_META_CRYPTO_KEY=your-encryption-key-32-chars-min
# API token for log ingestion used by Logflare and Vector.
# (Must be at least 32 characters; generate with openssl rand -base64 24)
LOGFLARE_PUBLIC_ACCESS_TOKEN=your-super-secret-and-long-logflare-key-public
# API token used for Logflare management operations. Never expose client-side.
# (Must be at least 32 characters; generate with openssl rand -base64 24)
LOGFLARE_PRIVATE_ACCESS_TOKEN=your-super-secret-and-long-logflare-key-private
# Access key ID (username-like) for accessing the S3 protocol endpoint in Storage.
# (Generate with: openssl rand -hex 16)
S3_PROTOCOL_ACCESS_KEY_ID=625729a08b95bf1b7ff351a663f3a23c
# Secret key (password-like) used with S3_PROTOCOL_ACCESS_KEY_ID.
# (Generate with: openssl rand -hex 32)
S3_PROTOCOL_ACCESS_KEY_SECRET=850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907
############
# URLs - Configure hostnames below to reflect your actual domain name
############
# Access to Dashboard and REST API (dietro Caddy: /api/* -> questo gateway, path stripped)
SUPABASE_PUBLIC_URL=https://tuodominio.it/api
# Full external URL of the Auth service, used to construct OAuth callbacks,
# SAML endpoints, and email links
API_EXTERNAL_URL=https://tuodominio.it/api/auth/v1
# See also the Auth section below for Site URL and Redirect URLs configuration
############
# Database - Postgres configuration
############
# Using default user (postgres)
POSTGRES_HOST=db
POSTGRES_DB=postgres
# Default configuration includes Supavisor exposing POSTGRES_PORT
# Postgres uses POSTGRES_PORT inside the container
# Documentation:
# https://supabase.com/docs/guides/self-hosting/accessing-postgres
POSTGRES_PORT=5432 # non esporre su internet, vedi docker-compose.prod.yml
############
# Database pooler
############
# Self-hosted Supabase uses Supavisor as the default database pooler.
# If you use the PgBouncer docker-compose override, Supavisor is disabled
# and the pooler settings below are used to configure PgBouncer instead.
#
# Supavisor exposes POSTGRES_PORT and POOLER_PROXY_PORT_TRANSACTION,
# POSTGRES_PORT is used for session mode pooling
# PgBouncer only exposes POOLER_PROXY_PORT_TRANSACTION.
#
# Port to use for transaction mode pooling connections
POOLER_PROXY_PORT_TRANSACTION=6543
# Maximum number of PostgreSQL connections Supavisor or PgBouncer opens per pool
POOLER_DEFAULT_POOL_SIZE=20
# Maximum number of client connections Supavisor or PgBouncer accepts per pool
POOLER_MAX_CLIENT_CONN=100
# Unique Supavisor tenant identifier
# Documentation:
# https://supabase.com/docs/guides/self-hosting/accessing-postgres
POOLER_TENANT_ID=your-tenant-id
# Pool size for internal metadata storage used by Supavisor
# This is separate from client connections and used only by Supavisor itself
POOLER_DB_POOL_SIZE=5
############
# Studio - Configuration for the Dashboard
############
STUDIO_DEFAULT_ORGANIZATION=Default Organization
STUDIO_DEFAULT_PROJECT=Default Project
# Add your OpenAI API key to enable AI Assistant
OPENAI_API_KEY=sk-proj-xxxxxxxx
############
# Auth - Configuration for the authentication server
############
## General settings
# Equivalent to "Site URL" and "Redirect URLs" platform configuration options
# Documentation: https://supabase.com/docs/guides/auth/redirect-urls
SITE_URL=https://tuodominio.it
ADDITIONAL_REDIRECT_URLS=
JWT_EXPIRY=3600
DISABLE_SIGNUP=false
## Mailer Config
MAILER_URLPATHS_CONFIRMATION="/auth/v1/verify"
MAILER_URLPATHS_INVITE="/auth/v1/verify"
MAILER_URLPATHS_RECOVERY="/auth/v1/verify"
MAILER_URLPATHS_EMAIL_CHANGE="/auth/v1/verify"
## Email auth
ENABLE_EMAIL_SIGNUP=true
ENABLE_EMAIL_AUTOCONFIRM=false
SMTP_ADMIN_EMAIL=admin@example.com
SMTP_HOST=supabase-mail
SMTP_PORT=2500
SMTP_USER=fake_mail_user
SMTP_PASS=fake_mail_password
SMTP_SENDER_NAME=fake_sender
ENABLE_ANONYMOUS_USERS=false
## Phone auth
ENABLE_PHONE_SIGNUP=true
ENABLE_PHONE_AUTOCONFIRM=true
## OAuth / Social login providers
# Uncomment and fill in the providers you want to enable.
# You must ALSO uncomment the matching GOTRUE_EXTERNAL_* lines in docker-compose.yml
# Documentation: https://supabase.com/docs/guides/self-hosting/self-hosted-oauth
# GOOGLE_ENABLED=false
# GOOGLE_CLIENT_ID=
# GOOGLE_SECRET=
# GITHUB_ENABLED=false
# GITHUB_CLIENT_ID=
# GITHUB_SECRET=
# AZURE_ENABLED=false
# AZURE_CLIENT_ID=
# AZURE_SECRET=
# Phone / SMS provider configuration
# Uncomment to configure SMS delivery for phone auth and phone MFA.
# You must ALSO uncomment the matching GOTRUE_SMS_* lines in docker-compose.yml
# Documentation: https://supabase.com/docs/guides/self-hosting/self-hosted-phone-mfa
# SMS_PROVIDER=twilio
# SMS_OTP_EXP=60
# SMS_OTP_LENGTH=6
# SMS_MAX_FREQUENCY=60s
# SMS_TEMPLATE=Your code is {{ .Code }}
# SMS_TWILIO_ACCOUNT_SID=
# SMS_TWILIO_AUTH_TOKEN=
# SMS_TWILIO_MESSAGE_SERVICE_SID=
# Test OTP: map phone numbers to fixed OTP codes for development
# Format: phone1:code1,phone2:code2
# SMS_TEST_OTP=
# Multi-factor authentication (MFA)
# Uncomment to change MFA defaults.
# You must ALSO uncomment the matching GOTRUE_MFA_* lines in docker-compose.yml
# App Authenticator (TOTP) - enabled by default
# MFA_TOTP_ENROLL_ENABLED=true
# MFA_TOTP_VERIFY_ENABLED=true
# Phone MFA - disabled by default (opt-in)
# MFA_PHONE_ENROLL_ENABLED=false
# MFA_PHONE_VERIFY_ENABLED=false
# Maximum MFA factors a user can enroll
# MFA_MAX_ENROLLED_FACTORS=10
## SAML SSO
# You must ALSO uncomment the matching GOTRUE_* lines in docker-compose.yml
# Documentation: https://supabase.com/docs/guides/self-hosting/self-hosted-saml-sso
# SAML_ENABLED=true
# SAML_PRIVATE_KEY=<your-base64-encoded-private-key>
# Optional: accept encrypted SAML assertions from IdPs (default: false)
# SAML_ALLOW_ENCRYPTED_ASSERTIONS=false
# Optional: how long relay state tokens remain valid (default: 2m0s)
# SAML_RELAY_STATE_VALIDITY_PERIOD=2m0s
# Optional: override the SAML entity ID / ACS base URL
# Defaults to API_EXTERNAL_URL if not set
# SAML_EXTERNAL_URL=https://supabase.example.com:8000/auth/v1
# Optional: rate limit on the ACS endpoint (requests per second, default: 15)
# SAML_RATE_LIMIT_ASSERTION=15
############
# Storage - Configuration for Storage
############
# Check the S3_PROTOCOL_ACCESS_KEY_ID/SECRET above, and
# refer to the documentation at:
# https://supabase.com/docs/guides/self-hosting/self-hosted-s3
# to learn how to configure the S3 protocol endpoint
# S3 bucket when using S3 backend, directory name when using 'file'
GLOBAL_S3_BUCKET=stub
# Used for S3 protocol endpoint configuration
REGION=stub
# Used by MinIO when added via:
# docker compose -f docker-compose.yml -f docker-compose.s3.yml up -d
MINIO_ROOT_USER=supa-storage
# Root administrator password for the RustFS or MinIO server.
# (Must be 8+ characters; generate with: openssl rand -hex 16)
MINIO_ROOT_PASSWORD=secret1234
# Equivalent to project_ref as described here:
# https://supabase.com/docs/guides/storage/s3/authentication#session-token
STORAGE_TENANT_ID=stub
############
# Functions - Configuration for Edge functions
############
# Documentation:
# https://supabase.com/docs/guides/self-hosting/self-hosted-functions
# NOTE: VERIFY_JWT applies to all functions
FUNCTIONS_VERIFY_JWT=false
############
# API - Configuration for PostgREST
############
# Postgres schemas exposed via the REST API
PGRST_DB_SCHEMAS=public,graphql_public
# Max number of rows returned by a request
PGRST_DB_MAX_ROWS=1000
# Extra schemas added to the search_path of every request
PGRST_DB_EXTRA_SEARCH_PATH=public
############
# Logs and Analytics
############
## Vector log collection and routing
# Docker socket location - required for proper Vector operation
DOCKER_SOCKET_LOCATION=/var/run/docker.sock
# For Podman use the following:
# DOCKER_SOCKET_LOCATION=/run/podman/podman.sock
## Analytics (Logflare)
# Check the LOGFLARE_* access token configuration _above_.
# If Logflare has to be externally exposed - configure securely!
# Google Cloud Project details
# Documentation:
# https://supabase.com/docs/reference/self-hosting-analytics/introduction
GOOGLE_PROJECT_ID=GOOGLE_PROJECT_ID
GOOGLE_PROJECT_NUMBER=GOOGLE_PROJECT_NUMBER
############
# API gateway
############
# Host port the API gateway (Envoy by default) listens on.
API_GW_HTTP_PORT=8000
# Kong gateway override only (sh run.sh config add kong). KONG_HTTPS_PORT is
# Kong's built-in HTTPS listener; KONG_HTTP_PORT is kept as a fallback for
# API_GW_HTTP_PORT so existing .env files continue to work.
KONG_HTTP_PORT=8000
KONG_HTTPS_PORT=8443
# Used internally by the API gateway - DO NOT use in any client or server code.
# Pre-signed ES256 JWT "API key" for anon role.
ANON_KEY_ASYMMETRIC=
# Pre-signed ES256 JWT "API key" for service_role.
SERVICE_ROLE_KEY_ASYMMETRIC=
############
# imgproxy
############
# Enable webp support
IMGPROXY_AUTO_WEBP=true
############
# TLS Proxy - Optional Caddy or Nginx reverse proxy with Let's Encrypt
############
# Documentation:
# https://supabase.com/docs/guides/self-hosting/self-hosted-proxy-https
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d
# docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
# Domain name for the proxy (must point to your server)
PROXY_DOMAIN=your-domain.example.com
# Email for Let's Encrypt certificate notifications (nginx only, Caddy uses PROXY_DOMAIN).
# This should be a valid email, not a placeholder (otherwise Certbot may fail to start).
CERTBOT_EMAIL=admin@example.com
+17
View File
@@ -0,0 +1,17 @@
* text=auto
*.md eol=lf
*.env eol=lf
.env.example eol=lf
*.sh eol=lf
*.sql eol=lf
*.yml eol=lf
*.yaml eol=lf
*.ts eol=lf
*.exs eol=lf
*.conf eol=lf
*.tpl eol=lf
Caddyfile eol=lf
Dockerfile* eol=lf
+16
View File
@@ -0,0 +1,16 @@
volumes/db/data
volumes/storage
volumes/snippets
volumes/functions/**
!volumes/functions/deno.json*
!volumes/functions/main/
volumes/functions/main/**
!volumes/functions/main/index.ts
!volumes/functions/hello/
volumes/functions/hello/**
!volumes/functions/hello/index.ts
.env
test.http
docker-compose.override.yml
.supabase-version
backups
+676
View File
@@ -0,0 +1,676 @@
# Changelog
All notable changes to the Supabase self-hosted Docker configuration.
Changes are grouped by service rather than by change type. See [versions.md](./versions.md) for complete image version history and rollback information.
See per-service updates below for details. Only the most important changes relevant to [self-hosted Supabase](https://supabase.com/docs/guides/self-hosting) are included here. For the full list of changes, refer to the release notes and changelogs of each individual service.
**Note:** Configuration updates marked with "requires [...] update" are already included in the latest version of the repository. Pull the latest changes or refer to the linked PR for manual updates. After updating `docker-compose.yml`, pull the latest images and recreate containers - use `docker compose pull && docker compose down && docker compose up -d`.
---
## [0.8.0](https://github.com/supabase/supabase/releases/tag/self-hosted/v0.8.0) - 2026-08-11
⚠️ **Note:** This update contains **breaking changes**. Make sure to read the **important** details below:
- Envoy is now the default API gateway, replacing Kong. Kong stays available as an opt-in override with `sh run.sh config add kong`. See the [heads-up discussion](https://github.com/orgs/supabase/discussions/48048) and [Update Your Self-Hosted Deployment](https://supabase.com/docs/guides/self-hosting/updating) - PR [#48153](https://github.com/supabase/supabase/pull/48153)
### Configuration
- ⚠️ Added `API_GW_HTTP_PORT` (falls back to `KONG_HTTP_PORT`; requires `.env` and `docker-compose.yml` update) - PR [#48153](https://github.com/supabase/supabase/pull/48153)
### Documentation
- Updated several self-hosting how-to guides to reflect the current configuration - PR [#48153](https://github.com/supabase/supabase/pull/48153)
- Updated architecture diagram for self-hosted Supabase - PR [#48763](https://github.com/supabase/supabase/pull/48763)
### Utils and tests
- Updated `tests/` to match the API gateway switch to Envoy - PR [#48153](https://github.com/supabase/supabase/pull/48153)
### API gateway
- ⚠️ Changed the default API gateway from Kong to Envoy; the `kong` service is now `api-gw` (requires `docker-compose.yml` update) - PR [#48153](https://github.com/supabase/supabase/pull/48153)
- Changed `docker-compose.envoy.yml` to a no-op shim now that Envoy is the default (requires `docker-compose.envoy.yml` update) - PR [#48153](https://github.com/supabase/supabase/pull/48153)
- Added an optional override for Kong (requires new `docker-compose.kong.yml`) - PR [#48153](https://github.com/supabase/supabase/pull/48153)
- Updated the Caddy and nginx reverse proxies to forward to `api-gw` (requires `docker-compose.caddy.yml`, `docker-compose.nginx.yml`, `volumes/proxy/caddy/Caddyfile`, and `volumes/proxy/nginx/supabase-nginx.conf.tpl` update) - PR [#48153](https://github.com/supabase/supabase/pull/48153)
---
## [0.7.2](https://github.com/supabase/supabase/releases/tag/self-hosted/v0.7.2) - 2026-08-04
### Utils and tests
- Fixed `update.sh` overwriting itself during an update; the new `update.sh` is now staged as `update.sh.new` for review instead of replacing the running script - PR [#48690](https://github.com/supabase/supabase/pull/48690)
- `update.sh` now fetches only the `docker/` directory (partial clone), making updates substantially faster and lighter - PR [#48690](https://github.com/supabase/supabase/pull/48690)
---
## [0.7.1](https://github.com/supabase/supabase/releases/tag/self-hosted/v0.7.1) - 2026-08-03
### Configuration
- Added `SUPABASE_JWKS` configuration for Edge Functions to `docker-compose.yml` - PR [#45635](https://github.com/supabase/supabase/pull/45635)
- Added `upgrades.json` - a version-keyed manifest to gate breaking changes (required for `update.sh`) - PR [#47851](https://github.com/supabase/supabase/pull/47851)
- Updated `.gitignore` (required for `update.sh`)
### Documentation
- Added new how-to guides ([Custom Postgres Extensions](https://supabase.com/docs/guides/self-hosting/custom-postgres-extensions) and [Update Your Self-Hosted Deployment](https://supabase.com/docs/guides/self-hosting/updating)) - PR [#48203](https://github.com/supabase/supabase/pull/48203), PR [#48535](https://github.com/supabase/supabase/pull/48535)
### Utils and tests
- Added base version stamp to `setup.sh` (saved in `.supabase-version`, required for `update.sh`) - PR [#47848](https://github.com/supabase/supabase/pull/47848)
- Added `update.sh`. Refer to [Update Your Self-Hosted Deployment](https://supabase.com/docs/guides/self-hosting/updating) - PR [#47851](https://github.com/supabase/supabase/pull/47851)
- Added `SUPABASE_JWKS` configuration for Edge Functions to `utils/add-new-auth-keys.sh` - PR [#45635](https://github.com/supabase/supabase/pull/45635)
- Updated `tests/test-s3.sh` and `test-s3-backend.sh` - PR [#48500](https://github.com/supabase/supabase/pull/48500)
### API gateway
- Updated Kong to `3.9.3`
- Added `KONG_DNS_VALID_TTL` configuration environment variable (requires `docker-compose.yml` update) - PR [#47846](https://github.com/supabase/supabase/pull/47846)
- Updated Envoy to `1.39.0` (requires `docker-compose.envoy.yml` update)
- Updated [nginx-certbot](https://github.com/JonasAlfredsson/docker-nginx-certbot) to `6.2.0-nginx1.31.3` (requires `docker-compose.nginx.yml` update)
### Studio
- Updated to `2026.08.03-sha-022b374`
- Fixed URL generation for Edge Functions - PR [#47861](https://github.com/supabase/supabase/pull/47861) (via [@7ttp](https://github.com/7ttp))
- Fixed the Logs tab visibility in **Auth > Users** - PR [#48122](https://github.com/supabase/supabase/pull/48122) (via [@luizfelmach](https://github.com/luizfelmach/))
### Storage
- Changed RustFS image to `1.0.0-beta.11` temporarily (requires `docker-compose.rustfs.yml` update) - PR [#48500](https://github.com/supabase/supabase/pull/48500)
### Edge Runtime
- Changed JWKS configuration mechanism for main worker (requires `docker-compose.yml` and `volumes/functions/main/index.ts` update) - PR [#45635](https://github.com/supabase/supabase/pull/45635)
---
## [0.7.0](https://github.com/supabase/supabase/releases/tag/self-hosted/v0.7.0) - 2026-07-07
⚠️ **Note:** This update contains **breaking changes**:
- Access to the OpenAPI spec at `/rest/v1/` via the anon (publishable) key has been removed. Requests using the service role or new secret API key are unaffected, and data access via `/rest/v1/your_table` or any client library continues to work as-is. See discussion [#42949](https://github.com/orgs/supabase/discussions/42949)
- `API_EXTERNAL_URL` has been updated to include the `/auth/v1` path prefix (e.g. `http://localhost:8000/auth/v1`), aligning self-hosted with the platform and CLI. This makes custom OAuth providers work out of the box and moves SAML SSO endpoints to `/auth/v1/sso/saml/*`. See discussion [#47093](https://github.com/orgs/supabase/discussions/47093) and PR [#47640](https://github.com/supabase/supabase/pull/47640)
### Configuration
- ⚠️ Added `KONG_ROUTER_FLAVOR` to the compose configuration for Kong (requires `docker-compose.yml` update) - PR [#45462](https://github.com/supabase/supabase/pull/45462)
- ⚠️ Changed the default `API_EXTERNAL_URL` in `.env.example` to contain `/auth/v1` - PR [#47640](https://github.com/supabase/supabase/pull/47640)
- ⚠️ Changed the default `PGRST_DB_SCHEMAS` to `public,graphql_public` in `.env.example` to avoid exposing `storage` (a protected schema)
### Documentation
- Minor updates to the how-to guides following the configuration changes
### Utils and tests
- Updated `setup.sh` to match the new `API_EXTERNAL_URL` configuration
- Updated `utils/generate-keys.sh` to also generate a unique `REALTIME_DB_ENC_KEY`
- Updated `tests/test-self-hosted.sh` and `tests/test-auth-keys.sh` to reflect the changes in the API gateway configuration
### API gateway
- ⚠️ Updated Kong and Envoy configuration to restrict access to PostgREST `/rest/v1/` (requires `docker-compose.yml`, `volumes/api/kong.yml` and `volumes/api/envoy` update) - PR [#45462](https://github.com/supabase/supabase/pull/45462) (via [@luizfelmach](https://github.com/luizfelmach/))
- ⚠️ Updated Kong and Envoy configuration to match the new `/auth/v1/sso` routing for SAML SSO (requires `docker-compose.yml`, `volumes/api/kong.yml` and `volumes/api/envoy` update) - PR [#47640](https://github.com/supabase/supabase/pull/47640)
### Studio
- Updated to `2026.07.07-sha-a6a04f2`
- Fixed the local SQL snippets not being shown in the SQL Editor - PR [#47403](https://github.com/supabase/supabase/pull/47403), PR [#47409](https://github.com/supabase/supabase/pull/47409)
- Fixed the exposed schemas and tables UI to properly reflect non-platform configuration (**Data API > Settings**) - PR [#47511](https://github.com/supabase/supabase/pull/47511)
- Fixed the behavior of the type generator (**Data API > Docs**) - PR [#47577](https://github.com/supabase/supabase/pull/47577)
### Auth
- ⚠️ Changed Auth configuration placeholders to match the new default `API_EXTERNAL_URL` (requires `docker-compose.yml` update) - PR [#47640](https://github.com/supabase/supabase/pull/47640)
- ⚠️ Changed `GOTRUE_JWT_ISSUER` to match the new default `API_EXTERNAL_URL` (requires `docker-compose.yml` update) - PR [#47640](https://github.com/supabase/supabase/pull/47640)
### Realtime
- ⚠️ Added a new configuration variable `REALTIME_DB_ENC_KEY` for Realtime with a fallback to the default value (requires `docker-compose.yml` update) - PR [#46021](https://github.com/supabase/supabase/pull/46021)
---
## [0.6.0](https://github.com/supabase/supabase/releases/tag/self-hosted/v0.6.0) - 2026-06-17
⚠️ **Note:** This update contains **breaking changes**. Make sure to read the **important** details below:
- **Postgres 17 is now the default**. Do not start Postgres 17 on an existing Postgres 15 data directory. See the [Upgrade to Postgres 17](https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17) guide. Check the **Configuration** and **Postgres** sections for additional information
- API gateway configuration includes a **security fix** for Realtime routes - it is **strongly recommended** to add this update to any self-hosted Supabase instance running Realtime
- Studio and Postgres Meta configuration now use `postgres` and not `supabase_admin` to connect to Postgres
### Configuration
- ⚠️ Changed the default Postgres image to `supabase/postgres:17.6.1.136` - PR [#46981](https://github.com/supabase/supabase/pull/46981)
- ⚠️ Added `docker-compose.pg15.yml` - for deployments not yet upgraded, and as the rollback target for `utils/upgrade-pg17.sh` - PR [#46981](https://github.com/supabase/supabase/pull/46981)
- Updated `docker-compose.pg17.yml` to match the new default - PR [#46981](https://github.com/supabase/supabase/pull/46981)
### Documentation
- Updated the [Upgrade to Postgres 17](https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17) how-to - PR [#46989](https://github.com/supabase/supabase/pull/46989)
- Updated the [New API Keys](https://supabase.com/docs/guides/self-hosting/self-hosted-auth-keys) and [Envoy API Gateway](https://supabase.com/docs/guides/self-hosting/self-hosted-envoy) how-to guides - PR [#46856](https://github.com/supabase/supabase/pull/46856)
- Updated [CONFIG.md](CONFIG.md) - PR [#47022](https://github.com/supabase/supabase/pull/47022)
### Utils and tests
- Updated `utils/upgrade-pg17.sh` (bumped Postgres image, added additional migrations), and `tests/test-pg17-upgrade.sh` (added tests for pg_cron) - PR [#46981](https://github.com/supabase/supabase/pull/46981)
- Updated `tests/test-self-hosted.sh` (added tests for resumable upload, modified tests for Realtime and GraphQL) - PR [#46731](https://github.com/supabase/supabase/pull/46731), PR [#46856](https://github.com/supabase/supabase/pull/46856), PR [#46981](https://github.com/supabase/supabase/pull/46981)
- Updated `tests/test-auth-keys.sh` (modified tests for Realtime) - PR [#46856](https://github.com/supabase/supabase/pull/46856)
### API gateway
- ⚠️ Updated Kong and Envoy configuration to block access to Realtime `/api/tenants` and `/api/openapi` endpoints. This is a **security fix** (requires `volumes/api/kong.yml` and `volumes/api/envoy` update) - PR [#46856](https://github.com/supabase/supabase/pull/46856)
- Updated entrypoint for Kong to use `/bin/sh` (requires `docker-compose.yml` update) - PR [#46873](https://github.com/supabase/supabase/pull/46873)
### Studio
- ⚠️ Updated `studio` configuration to use `postgres` instead of `supabase_admin` to connect to Postgres (requires `docker-compose.yml` update). See discussion [#46081](https://github.com/orgs/supabase/discussions/46081) and the [how-to guide](https://supabase.com/docs/guides/self-hosting/remove-superuser-access) for important information - PR [#47022](https://github.com/supabase/supabase/pull/47022)
### PostgREST
- Added healthcheck for `rest` (requires `docker-compose.yml` update) - PR [#46658](https://github.com/supabase/supabase/pull/46658)
### Postgres Meta
- ⚠️ Updated `meta` configuration to use `postgres` instead of `supabase_admin` to connect to Postgres (requires `docker-compose.yml` update) - PR [#47022](https://github.com/supabase/supabase/pull/47022)
### Edge Runtime
- Added healthcheck for `functions` (requires `docker-compose.yml` update) - PR [#46655](https://github.com/supabase/supabase/pull/46655)
### Postgres
- ⚠️ Updated the default image to `17.6.1.136` (from `15.8.1.085`). `pg_graphql` is now **disabled by default** on fresh installs. Databases that already use GraphQL keep it after an upgrade. See discussion [#46080](https://github.com/orgs/supabase/discussions/46080) and the [how-to guide](https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17) for more information - PR [#46981](https://github.com/supabase/supabase/pull/46981)
---
## [0.5.0](https://github.com/supabase/supabase/releases/tag/self-hosted/v0.5.0) - 2026-06-03
⚠️ **Note:** This update includes **important changes**. Please check the details below.
### Configuration
- ⚠️ Logs and analytics are now [optional](https://github.com/orgs/supabase/discussions/46084) and were removed from the default `docker-compose.yml`. A new `docker-compose.logs.yml` override has been added. Check the main [configuration guide](https://supabase.com/docs/guides/self-hosting/docker#enabling-analytics) and the changes to Studio below for more information - PR [#45327](https://github.com/supabase/supabase/pull/45327) (via [@luizfelmach](https://github.com/luizfelmach/))
- ⚠️ Added `COMPOSE_FILE` to `.env.example` for configuring compose overrides (also used by `run.sh`) - PR [#45603](https://github.com/supabase/supabase/pull/45603)
### Documentation
- Added a new [reference list](https://github.com/supabase/supabase/blob/master/docker/CONFIG.md) of all configuration environment variables - PR [#46124](https://github.com/supabase/supabase/pull/46124)
- Updated the main installation and configuration [guide](https://supabase.com/docs/guides/self-hosting/docker) (added "quick start" path and opt-in for logs and analytics; removed the legacy JWT secrets generator) - PR [#46416](https://github.com/supabase/supabase/pull/46416), PR [#45359](https://github.com/supabase/supabase/pull/45359)
- Updated the logs and analytics [how-to guide](https://supabase.com/docs/reference/self-hosting-analytics/introduction) - PR [#46452](https://github.com/supabase/supabase/pull/46452)
### Utils
- Added `setup.sh` and `run.sh` to support quick start and easier management of the compose configuration - PR [#45603](https://github.com/supabase/supabase/pull/45603)
- Updated `utils/add-new-auth-keys.sh` and `utils/rotate-new-api-key.sh` to remove the dependency on OpenSSL and Node.js - PR [#45941](https://github.com/supabase/supabase/pull/45941)
- Updated `tests/test-container-logs.sh` to skip checks for `kong`, `analytics` and `vector` when the services are not running - PR [#46099](https://github.com/supabase/supabase/pull/46099)
### API gateway
- Updated Envoy version to `1.38.0` (see `docker-compose.envoy.yml`) - PR [#46023](https://github.com/supabase/supabase/pull/46023)
- Updated Envoy configuration to address a discrepancy in API key checking (requires `volumes/api/envoy` update) - PR [#46023](https://github.com/supabase/supabase/pull/46023)
### Studio
- Updated to `2026.06.03-sha-0bca601`
- ⚠️ Added `ENABLED_FEATURES_LOGS_ALL` to Studio service configuration (requires `docker-compose.yml` update) - PR [#45327](https://github.com/supabase/supabase/pull/45327)
- ⚠️ Added `SUPABASE_PUBLISHABLE_KEY` and `SUPABASE_SECRET_KEY` to Studio service configuration (requires `docker-compose.yml` update) - PR [#46173](https://github.com/supabase/supabase/pull/46173)
- ⚠️ Added `start_period` to Studio healthcheck for more reliable cold-boot on slower hosts (requires `docker-compose.yml` update) - PR [#45327](https://github.com/supabase/supabase/pull/45327)
- Fixed incorrect connection strings in the connect sheet for self-hosted environments - PR [#46217](https://github.com/supabase/supabase/pull/46217)
- Updated project home and functions page, and added a minimal project settings implementation - PR [#46544](https://github.com/supabase/supabase/pull/46544), PR [#46550](https://github.com/supabase/supabase/pull/46550), PR [#46554](https://github.com/supabase/supabase/pull/46554)
### Auth
- Updated to `v2.189.0` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md) | [Release](https://github.com/supabase/auth/releases/tag/v2.189.0)
- ⚠️ Added `GOTRUE_JWT_ISSUER` to Auth service configuration (requires `docker-compose.yml` update) - PR [#46020](https://github.com/supabase/supabase/pull/46020)
### PostgREST
- Updated to `v14.12` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.12)
### Realtime
- Updated to `v2.102.3` - [Release](https://github.com/supabase/realtime/releases/tag/v2.102.3)
### Storage
- Updated to `v1.60.4` - [Release](https://github.com/supabase/storage/releases/tag/v1.60.4)
### Postgres Meta
- Updated to `v0.96.6` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.96.6)
### Edge Runtime
- Updated to `v1.74.0` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.74.0)
### Supavisor
- Updated to `2.9.5` - [Release](https://github.com/supabase/supavisor/releases/tag/v2.9.5)
- Added `POSTGRES_HOST` to Supavisor service configuration (requires `docker-compose.yml` and `volumes/pooler/pooler.exs` update) - PR [#41273](https://github.com/supabase/supabase/pull/41273)
### Analytics (Logflare)
- Updated to `1.43.1` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.43.1)
- ⚠️ Changed default `docker-compose.yml` to no longer include logs & analytics. Read more in Supabase's [changelog](https://github.com/orgs/supabase/discussions/46084) - PR [#45327](https://github.com/supabase/supabase/pull/45327)
---
## 2026-04-27
### Configuration
- ⚠️ Added `docker-compose.envoy.yml` and `volumes/api/envoy`. See also the API gateway updates below - PR [#43838](https://github.com/supabase/supabase/pull/43838)
- ⚠️ Changed Studio healthcheck and some other configuration for better compatibility with Podman (requires `docker-compose.yml` update) - PR [#44754](https://github.com/supabase/supabase/pull/44754)
- ⚠️ Changed Studio configuration to bind to all IPv4 interfaces only (requires `docker-compose.yml` update) - PR [#44772](https://github.com/supabase/supabase/pull/44772)
### Documentation
- Added a new [how-to](https://supabase.com/docs/guides/self-hosting/remove-superuser-access) describing how to switch from `supabase_admin` to `postgres` role for Studio - PR [#42975](https://github.com/supabase/supabase/pull/42975) (via [@singh-inder](https://github.com/singh-inder/))
- Added a new [how-to](https://github.com/supabase/supabase/pull/45152) for configuring Envoy as the new API gateway - PR [#45152](https://github.com/supabase/supabase/pull/45152)
- Updated the main [setup guide](https://supabase.com/docs/guides/self-hosting/docker) and the how-tos to reflect the state of the self-hosted Supabase configuration - PR [#45011](https://github.com/supabase/supabase/pull/45011)
### Utils
- ⚠️ Added `utils/reassign-owner.sh` to update database objects. Read more in the "[Remove superuser access](https://supabase.com/docs/guides/self-hosting/remove-superuser-access)" how-to guide - PR [#42975](https://github.com/supabase/supabase/pull/42975)
- ⚠️ Changed `utils/add-new-auth-keys.sh` to also update `docker-compose.yml` - PR [#45056](https://github.com/supabase/supabase/pull/45056)
### API gateway
- ⚠️ Added Envoy as the new optional API gateway (requires `docker-compose.envoy.yml`, `volumes/api/envoy`, and `volumes/logs/vector.yml` update) - PR [#43838](https://github.com/supabase/supabase/pull/43838) (via [@luizfelmach](https://github.com/luizfelmach/))
### Studio
- Updated to `2026.04.27-sha-5f60601`
- ⚠️ Added 4 new lints to the Security Advisor. Read more about lint rules 0026 - 0029 in the [Performance and Security Advisors](https://supabase.com/docs/guides/database/database-advisors?queryGroups=lint&lint=0026_pg_graphql_anon_table_exposed) section of the Supabase documentation - PR [#45253](https://github.com/supabase/supabase/pull/45253), PR [#45260](https://github.com/supabase/supabase/pull/45260)
---
## 2026-04-08
### Documentation
- Added new how-to guides for configuring [custom email templates](https://supabase.com/docs/guides/self-hosting/custom-email-templates), setting up [SAML SSO](https://supabase.com/docs/guides/self-hosting/self-hosted-saml-sso), and [using Postgres 17](https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17) - PR [#42832](https://github.com/supabase/supabase/pull/42832), PR [#43386](https://github.com/supabase/supabase/pull/43386), PR [#44147](https://github.com/supabase/supabase/pull/44147)
### Utils
- ⚠️ Added `utils/upgrade-pg17.sh`. Read more in the "[Upgrade to Postgres 17](https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17)" how-to guide - PR [#44147](https://github.com/supabase/supabase/pull/44147)
### API gateway
- ⚠️ Added configuration for SAML SSO (requires `.env`, `docker-compose.yml` and `volumes/api/kong.yml` update) - PR [#43385](https://github.com/supabase/supabase/pull/43385) (via [@luizfelmach](https://github.com/luizfelmach/))
### Studio
- Updated to `2026.04.08-sha-205cbe7`
### PostgREST
- Updated to `v14.8` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.8)
### Storage
- Updated to `v1.48.26` - [Release](https://github.com/supabase/storage/releases/tag/v1.48.26)
### imgproxy
- Changed `IMGPROXY_ENABLE_WEBP_DETECTION` environment variable to `IMGPROXY_AUTO_WEBP` (requires `.env` and `docker-compose.yml` update) - PR [#43919](https://github.com/supabase/supabase/pull/43919)
### Postgres Meta
- Updated to `v0.96.3` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.96.3)
### Analytics (Logflare)
- Updated to `1.36.1` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.36.1)
### Postgres
- ⚠️ Added `docker-compose.pg17.yml` override - PR [#44147](https://github.com/supabase/supabase/pull/44147)
- ⚠️ Added `utils/upgrade-pg17.sh` - PR [#44147](https://github.com/supabase/supabase/pull/44147)
- ⚠️ Added [documentation](https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17) explaining the upgrade to Postgres 17
---
## 2026-03-16
⚠️ **Note:** This update includes **important changes**. Please check the details below. The following configuration files have been added/updated: `utils/add-new-auth-keys.sh`, `utils/rotate-new-api-keys.sh`, `docker-compose.yml`, `.env.example`, `docker-compose.s3.yml`, `docker-compose.rustfs.yml`, `volumes/api/kong.yml`, `volumes/api/kong-entrypoint.sh`, `docker-compose.caddy.yml`, `docker-compose.nginx.yml`, `volumes/functions/main/index.ts`, and `volumes/proxy`.
### Configuration
- ⚠️ Added scripts and templates to support the new API key format (`sb_` API keys) and the new asymmetric authentication. Check the [how-to guide](https://supabase.com/docs/guides/self-hosting/self-hosted-auth-keys) for detailed instructions - PR [#43554](https://github.com/supabase/supabase/pull/43554)
- Added optional proxy configuration for Caddy and nginx. Read the [how-to guide](https://supabase.com/docs/guides/self-hosting/self-hosted-proxy-https) to learn more - PR [#43291](https://github.com/supabase/supabase/pull/43291)
### Documentation
- Added several new how-to guides to the self-hosted Supabase [documentation](https://supabase.com/docs/guides/self-hosting) - PR [#42745](https://github.com/supabase/supabase/pull/42745), PR [#42953](https://github.com/supabase/supabase/pull/42953), PR [#43177](https://github.com/supabase/supabase/pull/43177), PR [#43286](https://github.com/supabase/supabase/pull/43286), PR [#43293](https://github.com/supabase/supabase/pull/43293)
### Utils and tests
- Added `utils/add-new-auth-keys.sh` and `utils/rotate-new-api-keys.sh` - PR [#43554](https://github.com/supabase/supabase/pull/43554)
- Added `tests/` with 100+ test cases - PR [#43573](https://github.com/supabase/supabase/pull/43573)
### Studio
- Updated to `2026.03.16-sha-5528817`
- ⚠️ Added the link to the Data API page in Integrations - PR [#43268](https://github.com/supabase/supabase/pull/43268)
- ⚠️ Added `PGRST_DB_SCHEMAS`, `PGRST_DB_EXTRA_SEARCH_PATH`, and `PGRST_DB_MAX_ROWS` to Studio configuration (requires `docker-compose.yml` update) - PR [#43268](https://github.com/supabase/supabase/pull/43268)
### MCP Server
- Updated to `v0.7.0` - [Release](https://github.com/supabase/mcp/releases/tag/v0.7.0)
### API gateway
- ⚠️ Updated Kong to `3.9.1` - PR [#43554](https://github.com/supabase/supabase/pull/43554)
### PostgREST
- Updated to `v14.6` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.6)
### Realtime
- ⚠️ Added **mandatory** `METRICS_JWT_SECRET` environment variable (requires `docker-compose.s3.yml` update) - PR [realtime#1729](https://github.com/supabase/realtime/pull/1729)
### Storage
- Updated to `v1.44.2` - [Release](https://github.com/supabase/storage/releases/tag/v1.44.2)
- ⚠️ Added `STORAGE_PUBLIC_URL` environment variable to simplify proxy configuration (requires `docker-compose.s3.yml` update) - PR [storage#900](https://github.com/supabase/storage/pull/900)
- ⚠️ Added RustFS as an optional S3 backend - PR [#42935](https://github.com/supabase/supabase/pull/42935)
- ⚠️ Changed Docker Compose configuration for S3 backends to use named volumes - PR [#43815](https://github.com/supabase/supabase/pull/43815)
### Edge Runtime
- Updated to `v1.71.2` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.71.2)
- ⚠️ Added `SUPABASE_PUBLISHABLE_KEYS`, `SUPABASE_SECRET_KEYS`, and `SUPABASE_PUBLIC_URL` environment variables (requires `docker-compose.yml` update)
- ⚠️ Added an option for a "hybrid" JWT verification following the addition of the new API keys and the new asymmetric authentication (requires `volumes/functions/main/index.ts` update) - PR [#42130](https://github.com/supabase/supabase/pull/42130)
- ⚠️ Added optional rate limiter - PR [edge-runtime#670](https://github.com/supabase/edge-runtime/pull/670)
---
## 2026-02-18
### Storage
- Changed MinIO image to use Chainguard [minio](https://images.chainguard.dev/directory/image/minio/overview) and [minio-client](https://images.chainguard.dev/directory/image/minio-client/overview) (requires `docker-compose.s3.yml` update) - PR [#42942](https://github.com/supabase/supabase/pull/42942)
- Updated Storage image version to `v1.37.8` in `docker-compose.s3.yml`
- Removed `imgproxy` service from `docker-compose.s3.yml` to minimize redundancy - PR [#42942](https://github.com/supabase/supabase/pull/42942)
- Fixed inconsistent `storage` service entry ordering in `docker-compose.yml` and `docker-compose.s3.yml` to improve diff readability - PR [#42942](https://github.com/supabase/supabase/pull/42942)
### Edge Runtime
- Added a `deno-cache` named volume to avoid re-downloading dependencies (requires `docker-compose.yml` and `volumes/functions/*` update) - PR [#40822](https://github.com/supabase/supabase/pull/40822)
---
## 2026-02-16
⚠️ **Note:** This update includes several breaking changes, including a security fix for Analytics. Please check the details below. The following configuration files have been updated: `docker-compose.yml`, `.env.example`, `docker-compose.s3.yml`, `volumes/api/kong.yml`, and `volumes/logs/vector.yml`.
### Studio
- Updated to `2026.02.16-sha-26c615c`
- Added Edge Functions management UI (requires `docker-compose.yml` update) - PR [#40690](https://github.com/supabase/supabase/pull/40690), PR [#42322](https://github.com/supabase/supabase/pull/42322), PR [#42349](https://github.com/supabase/supabase/pull/42349), PR [#42350](https://github.com/supabase/supabase/pull/42350)
### MCP Server
- Updated to `v0.6.3` - [Release](https://github.com/supabase/mcp/releases/tag/v0.6.3)
### Auth
- Updated to `v2.186.0` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md) | [Release](https://github.com/supabase/auth/releases/tag/v2.186.0)
### PostgREST
- Updated to `v14.5` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.5)
### Realtime
- Updated to `v2.76.5` - [Release](https://github.com/supabase/realtime/releases/tag/v2.76.5)
### Storage
- Updated to `v1.37.8` - [Release](https://github.com/supabase/storage/releases/tag/v1.37.8)
- ⚠️ Changed environment variable configuration for Storage (requires `docker-compose.yml`, `.env.example` and `.env` update) - PR [#37185](https://github.com/supabase/supabase/pull/37185), PR [#42862](https://github.com/supabase/supabase/pull/42862)
- ⚠️ Added **default** configuration to access buckets via `/storage/v1/s3` endpoint (requires `docker-compose.yml` and `.env` update) - PR [#37185](https://github.com/supabase/supabase/pull/37185)
- ⚠️ Changed MinIO configuration for the S3 backend (requires `docker-compose.s3.yml` and `.env` update) - PR [#37185](https://github.com/supabase/supabase/pull/37185)
### Edge Runtime
- Updated to `v1.70.3` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.70.3)
### Analytics (Logflare)
- Updated to `1.31.2` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.31.2)
- ⚠️ Changed default configuration to disable Logflare on `0.0.0.0:4000` to prevent access to `/dashboard` (requires `docker-compose.yml` update). Read more in the "Production Recommendations" section of Logflare [documentation](https://supabase.com/docs/reference/self-hosting-analytics/introduction) - PR [#42857](https://github.com/supabase/supabase/pull/42857)
- ⚠️ Changed Kong routes to not include `/analytics/v1` by default (requires `/volumes/api/kong.yml` update) - PR [#42857](https://github.com/supabase/supabase/pull/42857)
### Vector
- Updated to `0.53.0-alpine` - [Changelog](https://vector.dev/releases/0.53.0/) | [Release](https://github.com/vectordotdev/vector/releases/tag/v0.53.0)
- ⚠️ Major version jump from `0.28.1` (requires `volumes/logs/vector.yml` update) - PR [#42525](https://github.com/supabase/supabase/pull/42525)
- ⚠️ Changed Postgres sink configuration to bypass Kong (requires `volumes/logs/vector.yml` update) - PR [#42857](https://github.com/supabase/supabase/pull/42857)
- ⚠️ Changed retry settings for all sinks to increase timeouts (requires `volumes/logs/vector.yml` update) - PR [#42857](https://github.com/supabase/supabase/pull/42857)
---
## 2026-02-05
### Storage
- Updated to `v1.37.1` - [Release](https://github.com/supabase/storage/releases/tag/v1.37.1)
- Fixed an issue with Storage not starting because of an issue with migrations - PR [storage#845](https://github.com/supabase/storage/pull/845)
---
## 2026-01-27
### Studio
- Updated to `2026.01.27-sha-6aa59ff`
- Added SQL snippets (requires `docker-compose.yml` update) - PR [#41112](https://github.com/supabase/supabase/pull/41112), PR [#41557](https://github.com/supabase/supabase/pull/41557), discussion [#42031](https://github.com/orgs/supabase/discussions/42031)
- Fixed type generator - PR [#40481](https://github.com/supabase/supabase/pull/40481)
- Fixed minor UI discrepancies - PR [#40579](https://github.com/supabase/supabase/pull/40579), PR [#41936](https://github.com/supabase/supabase/pull/41936), PR [#41970](https://github.com/supabase/supabase/pull/41970), PR [#41971](https://github.com/supabase/supabase/pull/41971), PR [#41972](https://github.com/supabase/supabase/pull/41972), PR [#42015](https://github.com/supabase/supabase/pull/42015)
### Auth
- Updated to `v2.185.0` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md) | [Release](https://github.com/supabase/auth/releases/tag/v2.185.0)
- ⚠️ Fixed security-related issues
### PostgREST
- Updated to `v14.3` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.3)
### Realtime
- Updated to `v2.72.0` - [Release](https://github.com/supabase/realtime/releases/tag/v2.72.0)
- Changed healthchecks logging to off by default (requires `docker-compose.yml` update) - PR [realtime#1677](https://github.com/supabase/realtime/pull/1677), PR [#42156](https://github.com/supabase/supabase/pull/42156)
- Changed logging configuration and healthcheck frequency to reduce log volume (requires `docker-compose.yml` update) - PR [#42112](https://github.com/supabase/supabase/pull/42112)
### Storage
- Updated to `v1.33.5` - [Release](https://github.com/supabase/storage/releases/tag/v1.33.5)
### imgproxy
- Updated to `v3.30.1` - [Changelog](https://github.com/imgproxy/imgproxy/blob/master/CHANGELOG.md) | [Release](https://github.com/imgproxy/imgproxy/releases/tag/v3.30.1)
### Postgres Meta
- Updated to `v0.95.2` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.95.2)
### Edge Runtime
- Updated to `v1.70.0` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.70.0)
### Analytics (Logflare)
- Updated to `1.30.3` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.30.3)
### Postgres
- No image update
- Fixed Postgres logging configuration (requires `volumes/logs/vector.yml` update) - PR [#41800](https://github.com/supabase/supabase/pull/41800)
---
## 2025-12-18
### Documentation
- Updated self-hosting installation and configuration guide - PR [#40901](https://github.com/supabase/supabase/pull/40901), PR [#41438](https://github.com/supabase/supabase/pull/41438)
### Utils
- Added `utils/generate-keys.sh` - PR [#41363](https://github.com/supabase/supabase/pull/41363)
- Added `utils/db-passwd.sh` - PR [#41432](https://github.com/supabase/supabase/pull/41432)
- Changed `reset.sh` to POSIX and added more checks - PR [#41361](https://github.com/supabase/supabase/pull/41361)
### Studio
- Updated to `2025.12.17-sha-43f4f7f`
- ⚠️ Fixed additional issues related to [React2Shell](https://vercel.com/kb/bulletin/react2shell)
- Fixed an issue with the Users page not being updated on changes - PR [#41254](https://github.com/supabase/supabase/pull/41254)
### MCP Server
- Updated to `v0.5.10` - [Release](https://github.com/supabase/mcp/releases/tag/v0.5.10)
### Auth
- Updated to `v2.184.0` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md) | [Release](https://github.com/supabase/auth/releases/tag/v2.184.0)
### Postgres Meta
- Updated to `v0.95.1` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.95.1)
### Analytics (Logflare)
- Updated to `1.27.0` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.27.0)
- Fixed multiple issues, including a race condition
---
## 2025-12-10
### Studio
- Updated to `2025.12.09-sha-434634f`
- ⚠️ Fixed security issues related to [React2Shell](https://vercel.com/kb/bulletin/react2shell)
### MCP Server
- Updated to `v0.5.9` - [Release](https://github.com/supabase/mcp/releases/tag/v0.5.9)
- ⚠️ Changed MCP tool `get_anon_key` to `get_publishable_keys`
### PostgREST
- Updated to `v14.1` - [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md) | [Release](https://github.com/PostgREST/postgrest/releases/tag/v14.1)
- ⚠️ **Major upgrade from v13.x to v14.x** - please report any unexpected behavior
### Realtime
- Updated to `v2.68.0` - [Release](https://github.com/supabase/realtime/releases/tag/v2.68.0)
### Storage
- Updated to `v1.33.0` - [Release](https://github.com/supabase/storage/releases/tag/v1.33.0)
### Edge Runtime
- Updated to `v1.69.28` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.69.28)
### Analytics (Logflare)
- Updated to `1.26.25` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.26.25)
---
## 2025-12-08
### Realtime
- No image update
- Changed boolean values to strings in Docker Compose for better compatibility with Podman - PR [#40994](https://github.com/supabase/supabase/pull/40994), also PR [realtime#1614](https://github.com/supabase/realtime/pull/1614)
- Changed healthcheck in Docker Compose for better compatibility with Podman - PR [#41159](https://github.com/supabase/supabase/pull/41159)
---
## 2025-11-26
### Studio
- Updated to `2025.11.26-sha-8f096b5`
- Fixed MCP `get_advisors` tool - PR [#40783](https://github.com/supabase/supabase/pull/40783)
- Fixed AI Assistant request schema - PR [#40830](https://github.com/supabase/supabase/pull/40830)
- Fixed log drains page - PR [#40835](https://github.com/supabase/supabase/pull/40835)
### Realtime
- Updated to `v2.65.3` - [Release](https://github.com/supabase/realtime/releases/tag/v2.65.3)
### Analytics (Logflare)
- Updated to `1.26.13` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.26.13)
- Fixed crashdump when `POSTGRES_BACKEND_URL` is malformed - PR [logflare#2954](https://github.com/Logflare/logflare/pull/2954)
---
## 2025-11-25
### Studio
- Updated to `2025.11.24-sha-d990ae8` - [Dashboard updates](https://github.com/orgs/supabase/discussions/40734)
- Fixed Queues configuration UI and added [documentation for exposed queue schema](https://supabase.com/docs/guides/queues/expose-self-hosted-queues) - PR [#40078](https://github.com/supabase/supabase/pull/40078)
- Fixed parameterized SQL queries in MCP tools - PR [#40499](https://github.com/supabase/supabase/pull/40499)
- Fixed Studio showing paid options for log drains - PR [#40510](https://github.com/supabase/supabase/pull/40510)
- Fixed AI Assistant authentication - PR [#40654](https://github.com/supabase/supabase/pull/40654)
### Auth
- Updated to `v2.183.0` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md) | [Release](https://github.com/supabase/auth/releases/tag/v2.183.0)
### Realtime
- Updated to `v2.65.2` - [Release](https://github.com/supabase/realtime/releases/tag/v2.65.2)
- Fixed handling of boolean configuration options - PR [realtime#1614](https://github.com/supabase/realtime/pull/1614)
### Storage
- Updated to `v1.32.0` - [Release](https://github.com/supabase/storage/releases/tag/v1.32.0)
### Edge Runtime
- Updated to `v1.69.25` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.69.25)
### Analytics (Logflare)
- Updated to `1.26.12` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.26.12)
- Fixed Auth logs query - PR [logflare#2936](https://github.com/Logflare/logflare/pull/2936)
- Fixed build configuration to prevent crashes with "Illegal instruction (core dumped)" - PR [logflare#2942](https://github.com/Logflare/logflare/pull/2942)
---
## 2025-11-17
### Storage
- No image update
- Fixed resumable uploads for files larger than 6MB (requires `docker-compose.yml` update) - PR [#40500](https://github.com/supabase/supabase/pull/40500)
---
## 2025-11-12
### Studio
- Updated to `2025.11.10-sha-5291fe3` - [Dashboard updates](https://github.com/orgs/supabase/discussions/40083)
- Added log drains - PR [#28297](https://github.com/supabase/supabase/pull/28297)
- Fixed Studio using `postgres` role instead of `supabase_admin` - PR [#39946](https://github.com/supabase/supabase/pull/39946)
### Auth
- Updated to `v2.182.1` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md#21821-2025-11-05) | [Release](https://github.com/supabase/auth/releases/tag/v2.182.1)
### Realtime
- Updated to `v2.63.0` - [Release](https://github.com/supabase/realtime/releases/tag/v2.63.0)
### Storage
- Updated to `v1.29.0` - [Release](https://github.com/supabase/storage/releases/tag/v1.29.0)
### Edge Runtime
- Updated to `v1.69.23` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.69.23)
### Supavisor
- Updated to `2.7.4` - [Release](https://github.com/supabase/supavisor/releases/tag/v2.7.4)
---
## 2025-11-05
### Studio
- No image update
- Fixed Studio failing to connect to Postgres with non-default settings (requires `docker-compose.yml` update) - PR [#40169](https://github.com/supabase/supabase/pull/40169)
### Realtime
- No image update
- Fixed realtime logs not showing in Studio (requires `volumes/logs/vector.yml` update) - PR [#39963](https://github.com/supabase/supabase/pull/39963)
---
## 2025-10-28
### Studio
- Updated to `2025.10.27-sha-85b84e0` - [Dashboard updates](https://github.com/orgs/supabase/discussions/40083)
- Fixed broken authentication when uploading files to Storage - PR [#39829](https://github.com/supabase/supabase/pull/39829)
### Realtime
- Updated to `v2.57.2` - [Release](https://github.com/supabase/realtime/releases/tag/v2.57.2)
### Storage
- Updated to `v1.28.2` - [Release](https://github.com/supabase/storage/releases/tag/v1.28.2)
### Postgres Meta
- Updated to `v0.93.1` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.93.1)
### Edge Runtime
- Updated to `v1.69.15` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.69.15)
---
## 2025-10-27
### Studio
- No image update
- Added Kong configuration for MCP server routes (requires `volumes/api/kong.yml` update) - PR [#39849](https://github.com/supabase/supabase/pull/39849)
- Added [documentation page](https://supabase.com/docs/guides/self-hosting/enable-mcp) for MCP server configuration - PR [#39952](https://github.com/supabase/supabase/pull/39952)
---
## 2025-10-21
### Studio
- Updated to `2025.10.20-sha-5005fc6` - [Dashboard updates](https://github.com/orgs/supabase/discussions/39709)
- Fixed issues with Edge Functions and cron logs not being visible in Studio - PR [#39388](https://github.com/supabase/supabase/pull/39388), PR [#39704](https://github.com/supabase/supabase/pull/39704), PR [#39711](https://github.com/supabase/supabase/pull/39711)
### Realtime
- Updated to `v2.56.0` - [Release](https://github.com/supabase/realtime/releases/tag/v2.56.0)
### Storage
- Updated to `v1.28.1` - [Release](https://github.com/supabase/storage/releases/tag/v1.28.1)
### Postgres Meta
- Updated to `v0.93.0` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.93.0)
### Edge Runtime
- Updated to `v1.69.14` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.69.14)
### Supavisor
- Updated to `2.7.3` - [Release](https://github.com/supabase/supavisor/releases/tag/v2.7.3)
---
## 2025-10-13
### Analytics (Logflare)
- Updated to `1.22.6` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.22.6)
---
## 2025-10-08
### Studio
- Updated to `2025.10.01-sha-8460121` - [Dashboard updates](https://github.com/orgs/supabase/discussions/39709)
- Added "local" remote MCP server - PR [#38797](https://github.com/supabase/supabase/pull/38797), PR [#39041](https://github.com/supabase/supabase/pull/39041)
- ⚠️ Changed Studio connection method to `postgres-meta` - affects non-standard database port configurations
### Auth
- Updated to `v2.180.0` - [Release](https://github.com/supabase/auth/releases/tag/v2.180.0)
### PostgREST
- Updated to `v13.0.7` - [Release](https://github.com/PostgREST/postgrest/releases/tag/v13.0.7) | [Changelog](https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md)
### Realtime
- Updated to `v2.51.11` - [Release](https://github.com/supabase/realtime/releases/tag/v2.51.11)
### Storage
- Updated to `v1.28.0` - [Release](https://github.com/supabase/storage/releases/tag/v1.28.0)
### Postgres Meta
- Updated to `v0.91.6` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.91.6)
### Analytics (Logflare)
- Updated to `1.22.4` - [Release](https://github.com/Logflare/logflare/releases/tag/v1.22.4)
### Postgres
- Updated to `15.8.1.085` - [Release](https://github.com/supabase/postgres/releases/tag/15.8.1.085)
### Supavisor
- Updated to `2.7.0` - [Release](https://github.com/supabase/supavisor/releases/tag/v2.7.0)
---
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
# Reverse proxy di produzione, TLS automatico via Let's Encrypt.
# Copia in Caddyfile, sostituisci il dominio, poi:
# caddy run --config Caddyfile
# (o come container: docker run -p 80:80 -p 443:443 -v ./Caddyfile:/etc/caddy/Caddyfile caddy)
#
# Un solo hostname: /api/* va al gateway Supabase (envoy accetta qualsiasi Host, instrada per
# path), /cms/* va al CMS Strapi (infra/strapi/), tutto il resto va all'app. handle_path toglie
# il prefisso prima di inoltrare, quindi VITE_SUPABASE_URL nell'app deve essere
# https://<dominio>/api (supabase-js aggiunge da solo /rest/v1, /auth/v1, ecc.) e VITE_STRAPI_URL
# deve essere https://<dominio>/cms.
crapp.ddns.net {
handle_path /api/* {
reverse_proxy 127.0.0.1:8000
}
handle_path /cms/* {
reverse_proxy 127.0.0.1:1337
}
handle {
reverse_proxy 127.0.0.1:3000
}
}
+96
View File
@@ -0,0 +1,96 @@
<div align="center">
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/supabase/supabase/3-self-hosted-deployment)
</div>
# Self-Hosted Supabase with Docker
This is the official Docker Compose setup for self-hosted Supabase. It provides a complete stack with all Supabase services running locally or on your infrastructure.
## Getting Started
Follow the detailed setup guide in our documentation: [Self-Hosting with Docker](https://supabase.com/docs/guides/self-hosting/docker)
The guide covers:
- Prerequisites (Git and Docker)
- Initial setup and configuration
- Securing your installation
- Accessing services
- Updating your instance
## What's Included
This Docker Compose configuration includes the following services:
- **[Studio](https://github.com/supabase/supabase/tree/master/apps/studio)** - A dashboard for managing your self-hosted Supabase project
- **[Envoy](https://www.envoyproxy.io/)** - API gateway (default; Kong is available as an optional override via `sh run.sh config add kong`)
- **[Auth](https://github.com/supabase/auth)** - JWT-based authentication API for user sign-ups, logins, and session management
- **[PostgREST](https://github.com/PostgREST/postgrest)** - Web server that turns your PostgreSQL database directly into a RESTful API
- **[Realtime](https://github.com/supabase/realtime)** - Elixir server that listens to PostgreSQL database changes and broadcasts them over websockets
- **[Storage](https://github.com/supabase/storage)** - RESTful API for managing files in S3, with Postgres handling permissions
- **[imgproxy](https://github.com/imgproxy/imgproxy)** - Fast and secure image processing server
- **[postgres-meta](https://github.com/supabase/postgres-meta)** - RESTful API for managing Postgres (fetch tables, add roles, run queries)
- **[PostgreSQL](https://github.com/supabase/postgres)** - Object-relational database with over 30 years of active development
- **[Edge Runtime](https://github.com/supabase/edge-runtime)** - Web server based on Deno runtime for running JavaScript, TypeScript, and WASM services
- **[Logflare](https://github.com/Logflare/logflare)** - Log management and event analytics platform
- **[Vector](https://github.com/vectordotdev/vector)** - High-performance observability data pipeline for logs
- **[Supavisor](https://github.com/supabase/supavisor)** - Supabase's Postgres connection pooler
## Documentation
- **[Self-Hosting with Docker](https://supabase.com/docs/guides/self-hosting/docker)** - Setup and configuration guides
- **[CHANGELOG.md](./CHANGELOG.md)** - Track recent updates and changes to services
- **[versions.md](./versions.md)** - Complete history of Docker image versions for rollback reference
- **[Ask DeepWiki / Supabase](https://deepwiki.com/supabase/supabase/3-self-hosted-deployment)** - DeepWiki-generated description of self-hosted configuration
- **[CONFIG.md](./CONFIG.md)** - Configuration reference for all environment variables
- **[Update your deployment](https://supabase.com/docs/guides/self-hosting/updating)** - Update an existing deployment with `update.sh`
## Updates
Back up your database, then:
```sh
sh update.sh --dry-run # optional preview
sh update.sh
sh run.sh pull && sh run.sh recreate
```
See the **[update guide](https://supabase.com/docs/guides/self-hosting/updating)** for conflicts,
breaking changes, pinning a release, and older installs without `.supabase-version`.
## Community & Support
For troubleshooting common issues, see:
- [GitHub Discussions](https://github.com/orgs/supabase/discussions?discussions_q=is%3Aopen+label%3Aself-hosted) - Questions, feature requests, and workarounds
- [GitHub Issues](https://github.com/supabase/supabase/issues?q=is%3Aissue%20state%3Aopen%20label%3Aself-hosted) - Known issues
- [Documentation](https://supabase.com/docs/guides/self-hosting) - Setup and configuration guides
Self-hosted Supabase is community-supported. Get help and connect with other users:
- [Discord](https://discord.supabase.com) - Real-time chat and community support
- [Reddit](https://www.reddit.com/r/Supabase/) - Official Supabase subreddit
Share your self-hosting experience:
- [GitHub Discussions](https://github.com/orgs/supabase/discussions/39820) - "Self-hosting: What's working (and what's not)?"
## Important Notes
### Security
⚠️ **The default configuration is not secure for production use.**
Before deploying to production, you must:
- [Update](https://supabase.com/docs/guides/self-hosting/docker#configuring-and-securing-supabase) all default passwords and secrets in the `.env` file
- Review and update CORS settings
- Consider setting up a secure proxy in front of self-hosted Supabase
- Review and adjust network security configuration (ACLs, etc.)
- Set up proper backup procedures
See the [main installation guide](https://supabase.com/docs/guides/self-hosting/docker) and the how-tos in the documentation.
## License
This repository is licensed under the Apache 2.0 License. See the main [Supabase repository](https://github.com/supabase/supabase) for details.
+48
View File
@@ -0,0 +1,48 @@
create table profiles (
id uuid references auth.users not null,
updated_at timestamp with time zone,
username text unique,
avatar_url text,
website text,
primary key (id),
unique(username),
constraint username_length check (char_length(username) >= 3)
);
alter table profiles enable row level security;
create policy "Public profiles are viewable by the owner."
on profiles for select
using ( auth.uid() = id );
create policy "Users can insert their own profile."
on profiles for insert
with check ( auth.uid() = id );
create policy "Users can update own profile."
on profiles for update
using ( auth.uid() = id );
-- Set up Realtime
begin;
drop publication if exists supabase_realtime;
create publication supabase_realtime;
commit;
alter publication supabase_realtime add table profiles;
-- Set up Storage
insert into storage.buckets (id, name)
values ('avatars', 'avatars');
create policy "Avatar images are publicly accessible."
on storage.objects for select
using ( bucket_id = 'avatars' );
create policy "Anyone can upload an avatar."
on storage.objects for insert
with check ( bucket_id = 'avatars' );
create policy "Anyone can update an avatar."
on storage.objects for update
with check ( bucket_id = 'avatars' );
@@ -0,0 +1,44 @@
version: "3.8"
services:
studio:
build:
context: ..
dockerfile: apps/studio/Dockerfile
target: dev
ports:
- 8082:8082
develop:
watch:
- action: sync
path: ../apps/studio
target: /app/apps/studio
ignore:
- node_modules/
- action: rebuild
path: package.json
mail:
container_name: supabase-mail
image: inbucket/inbucket:3.0.3
ports:
- '2500:2500' # SMTP
- '9000:9000' # web interface
- '1100:1100' # POP3
auth:
environment:
- GOTRUE_SMTP_USER=
- GOTRUE_SMTP_PASS=
meta:
ports:
- 5555:8080
db:
restart: 'no'
volumes:
# Always use a fresh database when developing
- /var/lib/postgresql/data
# Seed data should be inserted last (alphabetical order)
- ./dev/data.sql:/docker-entrypoint-initdb.d/seed.sql
storage:
volumes:
- /var/lib/storage
@@ -0,0 +1,42 @@
services:
# Caddy terminates TLS and forwards to the API gateway (api-gw) on port 8000,
# so the gateway's own host port binding is removed here. This works for
# either gateway, since the service is named api-gw in both cases.
api-gw:
ports: !reset []
# When using the Kong override uncomment the following:
#environment:
# KONG_PORT_MAPS: "443:8000,443:8443"
caddy:
container_name: supabase-caddy
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
depends_on:
api-gw:
condition: service_healthy
studio:
condition: service_healthy
environment:
PROXY_DOMAIN: ${PROXY_DOMAIN}
PROXY_AUTH_USERNAME: ${DASHBOARD_USERNAME}
PROXY_AUTH_PASSWORD: ${DASHBOARD_PASSWORD}
command:
- /bin/sh
- -c
- |
PROXY_AUTH_PASSWORD=$$(caddy hash-password --plaintext "$$PROXY_AUTH_PASSWORD") && \
caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
volumes:
- ./volumes/proxy/caddy:/etc/caddy
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
@@ -0,0 +1,15 @@
# DEPRECATED: Envoy is now the default API gateway defined directly in
# docker-compose.yml, so this override is no longer needed and does nothing.
#
# This no-op shim is kept for one release cycle so existing COMPOSE_FILE
# entries referencing it do not break. Remove it from your configuration:
#
# sh run.sh config remove envoy
#
# To run Kong instead of Envoy, use the Kong override:
#
# sh run.sh config add kong
#
# This file will be removed in a future release.
services: {}
@@ -0,0 +1,49 @@
# Kong API gateway override.
#
# Replaces the default Envoy gateway with Kong. Enable with:
# sh run.sh config add kong
# sh run.sh start
#
# This overrides the `api-gw` service in place, so its dependents (functions),
# the reverse-proxy overrides (caddy/nginx), and the `kong`/`envoy` network
# aliases keep working unchanged. Kong re-adds an HTTPS listener on 8443.
services:
api-gw:
container_name: supabase-kong
image: kong/kong:3.9.3
healthcheck:
test: !override ["CMD", "kong", "health"]
interval: 5s
timeout: 5s
retries: 5
ports: !override
- ${API_GW_HTTP_PORT:-${KONG_HTTP_PORT:-8000}}:8000/tcp
- ${KONG_HTTPS_PORT:-8443}:8443/tcp
volumes: !override
- ./volumes/api/kong.yml:/home/kong/temp.yml:ro,z
- ./volumes/api/kong-entrypoint.sh:/home/kong/kong-entrypoint.sh:ro,z
#- ./volumes/api/server.crt:/home/kong/server.crt:ro
#- ./volumes/api/server.key:/home/kong/server.key:ro
environment: !override
KONG_DATABASE: "off"
KONG_DECLARATIVE_CONFIG: /usr/local/kong/kong.yml
KONG_ROUTER_FLAVOR: expressions
KONG_DNS_ORDER: LAST,A,CNAME
KONG_DNS_NOT_FOUND_TTL: 1
KONG_DNS_VALID_TTL: 5
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction,post-function
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
KONG_PROXY_ACCESS_LOG: /dev/stdout combined
#KONG_SSL_CERT: /home/kong/server.crt
#KONG_SSL_CERT_KEY: /home/kong/server.key
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY:-}
SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY:-}
ANON_KEY_ASYMMETRIC: ${ANON_KEY_ASYMMETRIC:-}
SERVICE_ROLE_KEY_ASYMMETRIC: ${SERVICE_ROLE_KEY_ASYMMETRIC:-}
DASHBOARD_USERNAME: ${DASHBOARD_USERNAME}
DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD}
entrypoint: !override ["/bin/sh", "/home/kong/kong-entrypoint.sh"]
@@ -0,0 +1,99 @@
# This override adds the following to the self-hosted Supabase configuration:
# - Logflare: Log management and event analytics platform
# - Vector: High-performance observability data pipeline for logs
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.logs.yml up -d
services:
studio:
depends_on:
analytics:
condition: service_healthy
environment:
LOGFLARE_PRIVATE_ACCESS_TOKEN: ${LOGFLARE_PRIVATE_ACCESS_TOKEN}
LOGFLARE_URL: http://analytics:4000
ENABLED_FEATURES_LOGS_ALL: "true"
analytics:
container_name: supabase-analytics
image: supabase/logflare:1.43.1
restart: unless-stopped
#ports:
# - 4000:4000
healthcheck:
test:
[
"CMD-SHELL",
"curl -sSfL -o /dev/null http://localhost:4000/health"
]
timeout: 5s
interval: 5s
retries: 10
start_period: 30s
depends_on:
db:
# Disable this if you are using an external Postgres database
condition: service_healthy
environment:
LOGFLARE_NODE_HOST: 127.0.0.1
DB_USERNAME: supabase_admin
DB_DATABASE: _supabase
DB_HOSTNAME: ${POSTGRES_HOST}
DB_PORT: ${POSTGRES_PORT}
DB_PASSWORD: ${POSTGRES_PASSWORD}
DB_SCHEMA: _analytics
# Enable single-tenant mode for Logflare
LOGFLARE_SINGLE_TENANT: "true"
# Seed Supabase-related metadata
LOGFLARE_SUPABASE_MODE: "true"
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
LOGFLARE_PRIVATE_ACCESS_TOKEN: ${LOGFLARE_PRIVATE_ACCESS_TOKEN}
LOGFLARE_FEATURE_FLAG_OVERRIDE: multibackend=true
# Comment out the following two variables when switching to
# the BigQuery backend for logs
POSTGRES_BACKEND_URL: postgresql://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase
POSTGRES_BACKEND_SCHEMA: _analytics
# Uncomment to use the BigQuery backend for logs
#GOOGLE_PROJECT_ID: ${GOOGLE_PROJECT_ID}
#GOOGLE_PROJECT_NUMBER: ${GOOGLE_PROJECT_NUMBER}
# Uncomment to use the BigQuery backend for logs (requires gcloud.json
# service account key from Google Cloud Console)
#volumes:
# - ./gcloud.json:/opt/app/rel/logflare/bin/gcloud.json:ro,z
vector:
container_name: supabase-vector
image: timberio/vector:0.53.0-alpine
restart: unless-stopped
volumes:
- ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro,z
- ${DOCKER_SOCKET_LOCATION}:/var/run/docker.sock:ro,z
healthcheck:
test:
[
"CMD-SHELL",
"wget --no-verbose --tries=1 --spider http://vector:9001/health"
]
timeout: 5s
interval: 5s
retries: 3
depends_on:
analytics:
condition: service_healthy
environment:
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
command:
[
"--config",
"/etc/vector/vector.yml"
]
security_opt:
- "label=disable"
@@ -0,0 +1,41 @@
services:
# nginx terminates TLS and forwards to the API gateway (api-gw) on port 8000,
# so the gateway's own host port binding is removed here. This works for
# either gateway, since the service is named api-gw in both cases.
api-gw:
ports: !reset []
# When using the Kong override, uncomment the following:
#environment:
# KONG_PORT_MAPS: "443:8000,443:8443"
nginx:
container_name: supabase-nginx
image: jonasal/nginx-certbot:6.2.0-nginx1.31.3
restart: unless-stopped
ports:
- "80:80"
- "443:443"
depends_on:
api-gw:
condition: service_healthy
studio:
condition: service_healthy
environment:
PROXY_DOMAIN: ${PROXY_DOMAIN}
CERTBOT_EMAIL: ${CERTBOT_EMAIL}
PROXY_AUTH_USERNAME: ${DASHBOARD_USERNAME}
PROXY_AUTH_PASSWORD: ${DASHBOARD_PASSWORD}
command:
- /bin/bash
- -c
- |
printf '%s:%s\n' "$${PROXY_AUTH_USERNAME}" "$$(openssl passwd -apr1 "$${PROXY_AUTH_PASSWORD}")" > /etc/nginx/user_conf.d/dashboard-passwd && \
envsubst '$${PROXY_DOMAIN}' < /etc/nginx/supabase-nginx.conf.tpl > /etc/nginx/user_conf.d/nginx.conf && \
/scripts/start_nginx_certbot.sh
volumes:
- ./volumes/proxy/nginx/supabase-nginx.conf.tpl:/etc/nginx/supabase-nginx.conf.tpl:ro
- nginx_letsencrypt:/etc/letsencrypt
volumes:
nginx_letsencrypt:
@@ -0,0 +1,18 @@
# Postgres 15 override for self-hosted Supabase.
#
# Postgres 17 is now the default in docker-compose.yml. Use this override to pin
# Postgres 15, for two cases:
# 1. An existing Postgres 15 deployment that has not been upgraded yet.
# 2. As the rollback target for utils/upgrade-pg17.sh (PG 15 and PG 17 use
# different postgres UIDs, so rollback must start with the PG 15 image).
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.pg15.yml up -d
#
# To upgrade an existing Postgres 15 database to Postgres 17 in place, see:
# - https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17
# - utils/upgrade-pg17.sh
services:
db:
image: supabase/postgres:15.8.1.085
@@ -0,0 +1,19 @@
# Postgres 17 override for self-hosted Supabase.
#
# NOTE: Postgres 17 is now the default in docker-compose.yml, so this override
# is redundant for new installs (kept for explicitness / backwards compatibility).
# Keep the image tag here in sync with the db image in docker-compose.yml.
#
# Usage (new install or after upgrade):
# docker compose -f docker-compose.yml -f docker-compose.pg17.yml up -d
#
# For upgrading an existing Postgres 15 database, run utils/upgrade-pg17.sh first.
#
# Note: PG 15 and PG 17 use different postgres UIDs. If starting fresh with a
# leftover db-config volume from PG 15, you may see
# "FATAL: invalid secret key". Either remove the old volume or fix ownership.
# See: docs/guides/self-hosting/postgres-upgrade-17
services:
db:
image: supabase/postgres:17.6.1.136
@@ -0,0 +1,56 @@
# PgBouncer override for self-hosted Supabase
#
# Replaces the default Supavisor pooler with PgBouncer in transaction mode.
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.pgbouncer.yml up -d
#
# Or use run.sh to add PgBouncer to the stack:
# sh run.sh config add pgbouncer
# sh run.sh start
#
services:
supavisor: !reset null
# To expose Postgres directly on POSTGRES_PORT (5432), uncomment the "db"
# section below.
# WARNING: this opens Postgres to the external traffic. Restrict access at the
# network level if necessary.
#db:
# ports:
# - ${POSTGRES_PORT}:${POSTGRES_PORT}
pgbouncer:
container_name: supabase-pgbouncer
image: edoburu/pgbouncer:v1.25.2-p0
restart: unless-stopped
ports:
- ${POOLER_PROXY_PORT_TRANSACTION}:6432
healthcheck:
test: ['CMD', 'pg_isready', '-h', 'localhost', '-p', '6432']
interval: 10s
timeout: 5s
retries: 3
start_period: 5s
depends_on:
db:
condition: service_healthy
environment:
# PgBouncer connects to Postgres as the dedicated `pgbouncer` role and
# looks up every other role's password on demand via the auth_query below.
DB_USER: pgbouncer
DB_PASSWORD: ${POSTGRES_PASSWORD}
DB_HOST: ${POSTGRES_HOST}
DB_PORT: ${POSTGRES_PORT}
DB_NAME: ${POSTGRES_DB}
LISTEN_PORT: 6432
AUTH_TYPE: scram-sha-256
AUTH_QUERY: SELECT * FROM pgbouncer.get_auth($$1)
# POOL_MODE can be: session, transaction, or statement
POOL_MODE: transaction
DEFAULT_POOL_SIZE: ${POOLER_DEFAULT_POOL_SIZE}
MAX_CLIENT_CONN: ${POOLER_MAX_CLIENT_CONN}
# Only the `pgbouncer` role may run get_auth / the admin console.
ADMIN_USERS: pgbouncer
STATS_USERS: pgbouncer
@@ -0,0 +1,34 @@
# Override di produzione, pensato per girare anche su hardware limitato (es. Raspberry Pi 4).
#
# 1. Nessuna porta esposta pubblicamente: l'API gateway (Kong/envoy, che serve anche Studio)
# resta raggiungibile solo su 127.0.0.1 — mettici davanti un reverse proxy TLS (vedi
# Caddyfile.example). Postgres di suo non espone porte nel compose base (supavisor sì,
# ma non ci serve: l'app si connette al DB sulla rete interna Docker, non da host esterno).
# 2. Servizi mai usati da CrAPP disattivati (nessuna route usa Storage/imgproxy/Realtime/Edge
# Functions/pooler — verificato: zero `.storage.`, `.functions.invoke`, `.channel()` nel
# codice, e gli avatar sono salvati come dataURL nel DB, non su Storage).
# 3. `studio` + `meta` restano attivi: servono per guardare/gestire il database via interfaccia
# grafica (raggiungibile tramite lo stesso api-gw su 127.0.0.1:8000, con le credenziali
# DASHBOARD_USERNAME/DASHBOARD_PASSWORD del tuo .env).
#
# Uso: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
services:
api-gw:
# Compose unisce le liste `ports` tra file invece di sostituirle: serve !override
# per rimpiazzare davvero il binding pubblico del file base con uno solo-locale.
ports: !override
- "127.0.0.1:${API_GW_HTTP_PORT:-${KONG_HTTP_PORT:-8000}}:8000/tcp"
# Mai usati da CrAPP: profilo "unused" mai attivato, quindi `docker compose up -d`
# di default non li avvia nemmeno.
realtime:
profiles: ["unused"]
storage:
profiles: ["unused"]
imgproxy:
profiles: ["unused"]
functions:
profiles: ["unused"]
supavisor:
profiles: ["unused"]
@@ -0,0 +1,47 @@
services:
rustfs:
image: rustfs/rustfs:1.0.0-beta.11
environment:
RUSTFS_ACCESS_KEY: ${MINIO_ROOT_USER}
RUSTFS_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
RUSTFS_CONSOLE_ADDRESS: 0.0.0.0:9001
RUSTFS_CORS_ALLOWED_ORIGINS: "*"
RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS: "*"
security_opt:
- "no-new-privileges:true"
healthcheck:
test: [ "CMD", "curl", "-f", "http://rustfs:9000/health" ]
interval: 2s
timeout: 10s
retries: 5
volumes:
- rustfs-data:/data
rustfs-createbucket:
image: rustfs/rc
depends_on:
rustfs:
condition: service_healthy
entrypoint: >
/bin/sh -ec "
rc alias set supa-rustfs http://rustfs:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD} &&
rc mb --ignore-existing supa-rustfs/${GLOBAL_S3_BUCKET}
"
storage:
depends_on:
rustfs-createbucket:
condition: service_completed_successfully
rustfs:
condition: service_healthy
environment:
STORAGE_BACKEND: s3
GLOBAL_S3_ENDPOINT: http://rustfs:9000
GLOBAL_S3_PROTOCOL: http
GLOBAL_S3_FORCE_PATH_STYLE: true
AWS_ACCESS_KEY_ID: ${MINIO_ROOT_USER}
AWS_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD}
volumes:
rustfs-data:
@@ -0,0 +1,46 @@
services:
minio:
image: cgr.dev/chainguard/minio
#ports:
# - '9000:9000'
# - '9001:9001'
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
command: server --console-address ":9001" /data
healthcheck:
test: [ "CMD", "mc", "ready", "local" ]
interval: 2s
timeout: 10s
retries: 5
volumes:
- minio-data:/data
minio-createbucket:
image: cgr.dev/chainguard/minio-client:latest-dev
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -ec "
mc alias set supa-minio http://minio:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD} &&
mc mb --ignore-existing supa-minio/${GLOBAL_S3_BUCKET}
"
storage:
depends_on:
minio-createbucket:
condition: service_completed_successfully
minio:
condition: service_healthy
environment:
STORAGE_BACKEND: s3
GLOBAL_S3_ENDPOINT: http://minio:9000
GLOBAL_S3_PROTOCOL: http
GLOBAL_S3_FORCE_PATH_STYLE: true
AWS_ACCESS_KEY_ID: ${MINIO_ROOT_USER}
AWS_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD}
volumes:
minio-data:
+587
View File
@@ -0,0 +1,587 @@
# Usage
# Start: docker compose up -d
# Stop: docker compose down
# Dev mode: docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml up -d
# Reset everything: sh reset.sh
#
# Notes:
# - Nested variable interpolation (${A:-${B}}) requires podman-compose >= 1.6.0
#
name: supabase
services:
studio:
container_name: supabase-studio
image: supabase/studio:2026.08.03-sha-022b374
restart: unless-stopped
healthcheck:
test:
[
"CMD-SHELL",
"node -e \"fetch('http://localhost:3000/api/platform/profile').then((r) => {if (r.status !== 200) throw new Error(r.status)})\""
]
timeout: 10s
interval: 5s
retries: 3
start_period: 20s
environment:
# Listen on all IPv4 interfaces
HOSTNAME: "0.0.0.0"
STUDIO_PG_META_URL: http://meta:8080
POSTGRES_PORT: ${POSTGRES_PORT}
POSTGRES_HOST: ${POSTGRES_HOST}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
# See: https://supabase.com/docs/guides/self-hosting/remove-superuser-access
POSTGRES_USER_READ_WRITE: postgres
PG_META_CRYPTO_KEY: ${PG_META_CRYPTO_KEY}
PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS}
PGRST_DB_MAX_ROWS: ${PGRST_DB_MAX_ROWS:-1000}
PGRST_DB_EXTRA_SEARCH_PATH: ${PGRST_DB_EXTRA_SEARCH_PATH:-public}
DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION}
DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT}
OPENAI_API_KEY: ${OPENAI_API_KEY}
SUPABASE_URL: http://api-gw:8000
SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
AUTH_JWT_SECRET: ${JWT_SECRET}
SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY}
SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY}
# See: docker-compose.logs.yml
ENABLED_FEATURES_LOGS_ALL: "false"
SNIPPETS_MANAGEMENT_FOLDER: /app/snippets
EDGE_FUNCTIONS_MANAGEMENT_FOLDER: /app/edge-functions
volumes:
- ./volumes/snippets:/app/snippets:z
- ./volumes/functions:/app/edge-functions:ro,z
# Envoy is the default API gateway
# See: https://github.com/orgs/supabase/discussions/48048
api-gw:
container_name: supabase-envoy
image: envoyproxy/envoy:v1.39.0
restart: unless-stopped
networks:
default:
# Expose `envoy` and `kong` as network aliases, so internal configs
# that reference either hostname resolve to whichever gateway is active.
aliases:
- envoy
- kong
healthcheck:
# Using a TCP port check because this image does not include curl or wget.
test: ["CMD-SHELL", "timeout 1 bash -c '</dev/tcp/127.0.0.1/8000'"]
interval: 10s
timeout: 5s
retries: 3
depends_on:
studio:
condition: service_healthy
ports:
- ${API_GW_HTTP_PORT:-${KONG_HTTP_PORT:-8000}}:8000/tcp
volumes:
- ./volumes/api/envoy/envoy.yaml:/etc/envoy/envoy.yaml:ro
- ./volumes/api/envoy/cds.yaml:/etc/envoy/cds.yaml:ro
- ./volumes/api/envoy/lds.template.yaml:/etc/envoy/lds.template.yaml:ro
- ./volumes/api/envoy/docker-entrypoint.sh:/docker-entrypoint.sh:ro
environment:
ANON_KEY: ${ANON_KEY}
SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY:-}
SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY:-}
ANON_KEY_ASYMMETRIC: ${ANON_KEY_ASYMMETRIC:-}
SERVICE_ROLE_KEY_ASYMMETRIC: ${SERVICE_ROLE_KEY_ASYMMETRIC:-}
SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
DASHBOARD_USERNAME: ${DASHBOARD_USERNAME}
DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD}
entrypoint: ["/bin/sh", "/docker-entrypoint.sh"]
auth:
container_name: supabase-auth
image: supabase/gotrue:v2.189.0
restart: unless-stopped
healthcheck:
test:
[
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://localhost:9999/health"
]
timeout: 5s
interval: 5s
retries: 3
depends_on:
db:
# Disable this if you are using an external Postgres database
condition: service_healthy
environment:
GOTRUE_API_HOST: 0.0.0.0
GOTRUE_API_PORT: 9999
API_EXTERNAL_URL: ${API_EXTERNAL_URL}
GOTRUE_DB_DRIVER: postgres
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
GOTRUE_SITE_URL: ${SITE_URL}
GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS}
GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP}
GOTRUE_JWT_ADMIN_ROLES: service_role
GOTRUE_JWT_AUD: authenticated
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
GOTRUE_JWT_EXP: ${JWT_EXPIRY}
# Legacy symmetric HS256 key
GOTRUE_JWT_SECRET: ${JWT_SECRET}
# JSON array of signing JWKs (EC private + legacy symmetric)
# For Podman, use: GOTRUE_JWT_KEYS: ${JWT_KEYS}
#GOTRUE_JWT_KEYS: ${JWT_KEYS:-[]}
GOTRUE_JWT_ISSUER: ${API_EXTERNAL_URL}
GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP}
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS}
GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM}
# Uncomment to bypass nonce check in ID Token flow. Commonly set to true when using Google Sign In on mobile.
# GOTRUE_EXTERNAL_SKIP_NONCE_CHECK: "true"
# GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED: "true"
# GOTRUE_SMTP_MAX_FREQUENCY: 1s
GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL}
GOTRUE_SMTP_HOST: ${SMTP_HOST}
GOTRUE_SMTP_PORT: ${SMTP_PORT}
GOTRUE_SMTP_USER: ${SMTP_USER}
GOTRUE_SMTP_PASS: ${SMTP_PASS}
GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME}
GOTRUE_MAILER_URLPATHS_INVITE: ${MAILER_URLPATHS_INVITE}
GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${MAILER_URLPATHS_CONFIRMATION}
GOTRUE_MAILER_URLPATHS_RECOVERY: ${MAILER_URLPATHS_RECOVERY}
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${MAILER_URLPATHS_EMAIL_CHANGE}
GOTRUE_EXTERNAL_PHONE_ENABLED: ${ENABLE_PHONE_SIGNUP}
GOTRUE_SMS_AUTOCONFIRM: ${ENABLE_PHONE_AUTOCONFIRM}
# Uncomment to enable OAuth / social login providers.
# GOTRUE_EXTERNAL_GOOGLE_ENABLED: ${GOOGLE_ENABLED}
# GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
# GOTRUE_EXTERNAL_GOOGLE_SECRET: ${GOOGLE_SECRET}
# GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI: ${API_EXTERNAL_URL}/callback
# GOTRUE_EXTERNAL_GITHUB_ENABLED: ${GITHUB_ENABLED}
# GOTRUE_EXTERNAL_GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID}
# GOTRUE_EXTERNAL_GITHUB_SECRET: ${GITHUB_SECRET}
# GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI: ${API_EXTERNAL_URL}/callback
# GOTRUE_EXTERNAL_AZURE_ENABLED: ${AZURE_ENABLED}
# GOTRUE_EXTERNAL_AZURE_CLIENT_ID: ${AZURE_CLIENT_ID}
# GOTRUE_EXTERNAL_AZURE_SECRET: ${AZURE_SECRET}
# GOTRUE_EXTERNAL_AZURE_REDIRECT_URI: ${API_EXTERNAL_URL}/callback
# Uncomment to configure SMS delivery (phone auth and phone MFA).
# GOTRUE_SMS_PROVIDER: ${SMS_PROVIDER}
# GOTRUE_SMS_OTP_EXP: ${SMS_OTP_EXP}
# GOTRUE_SMS_OTP_LENGTH: ${SMS_OTP_LENGTH}
# GOTRUE_SMS_MAX_FREQUENCY: ${SMS_MAX_FREQUENCY}
# GOTRUE_SMS_TEMPLATE: ${SMS_TEMPLATE}
# Twilio credentials (when SMS_PROVIDER=twilio)
# GOTRUE_SMS_TWILIO_ACCOUNT_SID: ${SMS_TWILIO_ACCOUNT_SID}
# GOTRUE_SMS_TWILIO_AUTH_TOKEN: ${SMS_TWILIO_AUTH_TOKEN}
# GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID: ${SMS_TWILIO_MESSAGE_SERVICE_SID}
# Test OTP mappings for development
# GOTRUE_SMS_TEST_OTP: ${SMS_TEST_OTP}
# Uncomment to configure multi-factor authentication (MFA).
# GOTRUE_MFA_TOTP_ENROLL_ENABLED: ${MFA_TOTP_ENROLL_ENABLED}
# GOTRUE_MFA_TOTP_VERIFY_ENABLED: ${MFA_TOTP_VERIFY_ENABLED}
# GOTRUE_MFA_PHONE_ENROLL_ENABLED: ${MFA_PHONE_ENROLL_ENABLED}
# GOTRUE_MFA_PHONE_VERIFY_ENABLED: ${MFA_PHONE_VERIFY_ENABLED}
# GOTRUE_MFA_MAX_ENROLLED_FACTORS: ${MFA_MAX_ENROLLED_FACTORS}
# SAML SSO
# See: https://supabase.com/docs/guides/self-hosting/self-hosted-saml-sso
# GOTRUE_SAML_ENABLED: ${SAML_ENABLED}
# GOTRUE_SAML_PRIVATE_KEY: ${SAML_PRIVATE_KEY}
# GOTRUE_SAML_ALLOW_ENCRYPTED_ASSERTIONS: ${SAML_ALLOW_ENCRYPTED_ASSERTIONS}
# GOTRUE_SAML_RELAY_STATE_VALIDITY_PERIOD: ${SAML_RELAY_STATE_VALIDITY_PERIOD}
# GOTRUE_SAML_RATE_LIMIT_ASSERTION: ${SAML_RATE_LIMIT_ASSERTION}
# Optional, defaults to API_EXTERNAL_URL if not set
# GOTRUE_SAML_EXTERNAL_URL: ${SAML_EXTERNAL_URL}
# Uncomment to enable custom access token hook.
# See: https://supabase.com/docs/guides/auth/auth-hooks for
# full list of hooks and additional details about custom_access_token_hook
# GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED: "true"
# GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI: "pg-functions://postgres/public/custom_access_token_hook"
# GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS: "<standard-base64-secret>"
# GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_ENABLED: "true"
# GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/mfa_verification_attempt"
# GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_ENABLED: "true"
# GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/password_verification_attempt"
# GOTRUE_HOOK_SEND_SMS_ENABLED: "false"
# GOTRUE_HOOK_SEND_SMS_URI: "pg-functions://postgres/public/custom_access_token_hook"
# GOTRUE_HOOK_SEND_SMS_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n"
# GOTRUE_HOOK_SEND_EMAIL_ENABLED: "false"
# GOTRUE_HOOK_SEND_EMAIL_URI: "http://host.docker.internal:54321/functions/v1/email_sender"
# GOTRUE_HOOK_SEND_EMAIL_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n"
rest:
container_name: supabase-rest
image: postgrest/postgrest:v14.12
restart: unless-stopped
depends_on:
db:
# Disable this if you are using an external Postgres database
condition: service_healthy
healthcheck:
test: [ "CMD", "postgrest", "--ready" ]
interval: 5s
timeout: 5s
retries: 3
environment:
PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS}
PGRST_DB_MAX_ROWS: ${PGRST_DB_MAX_ROWS:-1000}
PGRST_DB_EXTRA_SEARCH_PATH: ${PGRST_DB_EXTRA_SEARCH_PATH:-public}
PGRST_DB_ANON_ROLE: anon
PGRST_ADMIN_SERVER_PORT: 3001
PGRST_ADMIN_SERVER_HOST: localhost
# PostgREST accepts a plain-text symmetric secret, a single JWK, or a JWKS.
# For Podman, use either PGRST_JWT_SECRET: ${JWT_SECRET} or
# PGRST_JWT_SECRET: ${JWT_JWKS}
PGRST_JWT_SECRET: ${JWT_JWKS:-${JWT_SECRET}}
PGRST_DB_USE_LEGACY_GUCS: "false"
PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY}
command:
[
"postgrest"
]
realtime:
# This container name looks inconsistent but is correct because realtime constructs tenant id by parsing the subdomain
container_name: realtime-dev.supabase-realtime
image: supabase/realtime:v2.102.3
restart: unless-stopped
depends_on:
db:
# Disable this if you are using an external Postgres database
condition: service_healthy
healthcheck:
test:
[
"CMD-SHELL",
"curl -sSfL --head -o /dev/null -H \"Authorization: Bearer ${ANON_KEY}\" http://localhost:4000/api/tenants/realtime-dev/health"
]
timeout: 5s
interval: 30s
retries: 3
start_period: 10s
environment:
PORT: 4000
DB_HOST: ${POSTGRES_HOST}
DB_PORT: ${POSTGRES_PORT}
DB_USER: supabase_admin
DB_PASSWORD: ${POSTGRES_PASSWORD}
DB_NAME: ${POSTGRES_DB}
DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
DB_ENC_KEY: ${REALTIME_DB_ENC_KEY:-supabaserealtime}
# Legacy symmetric HS256 key
API_JWT_SECRET: ${JWT_SECRET}
# JWKS for token verification (EC public + legacy symmetric).
# For Podman, use: API_JWT_JWKS: ${JWT_JWKS}
#API_JWT_JWKS: ${JWT_JWKS:-{"keys":[]}}
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
METRICS_JWT_SECRET: ${JWT_SECRET}
ERL_AFLAGS: -proto_dist inet_tcp
DNS_NODES: "''"
RLIMIT_NOFILE: "10000"
APP_NAME: realtime
SEED_SELF_HOST: "true"
RUN_JANITOR: "true"
DISABLE_HEALTHCHECK_LOGGING: "true"
# To use S3 backed storage: docker compose -f docker-compose.yml -f docker-compose.s3.yml up
storage:
container_name: supabase-storage
image: supabase/storage-api:v1.60.4
restart: unless-stopped
depends_on:
db:
# Disable this if you are using an external Postgres database
condition: service_healthy
rest:
condition: service_started
imgproxy:
condition: service_started
healthcheck:
test:
[
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://storage:5000/status"
]
timeout: 5s
interval: 5s
retries: 3
start_period: 10s
environment:
ANON_KEY: ${ANON_KEY}
SERVICE_KEY: ${SERVICE_ROLE_KEY}
POSTGREST_URL: http://rest:3000
# Legacy symmetric HS256 key
AUTH_JWT_SECRET: ${JWT_SECRET}
# JWKS for token verification (EC public + legacy symmetric).
# For Podman, use: JWT_JWKS: ${JWT_JWKS}
#JWT_JWKS: ${JWT_JWKS:-{"keys":[]}}
DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
STORAGE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
REQUEST_ALLOW_X_FORWARDED_PATH: "true"
FILE_SIZE_LIMIT: 52428800
STORAGE_BACKEND: file
# S3 bucket when using S3 backend, directory name when using 'file'
GLOBAL_S3_BUCKET: ${GLOBAL_S3_BUCKET}
# S3 Backend configuration
#GLOBAL_S3_ENDPOINT: https://your-s3-endpoint
#GLOBAL_S3_PROTOCOL: https
#GLOBAL_S3_FORCE_PATH_STYLE: "true"
#AWS_ACCESS_KEY_ID: your-access-key-id
#AWS_SECRET_ACCESS_KEY: your-secret-access-key
FILE_STORAGE_BACKEND_PATH: /var/lib/storage
TENANT_ID: ${STORAGE_TENANT_ID}
# TODO: https://github.com/supabase/storage-api/issues/55
REGION: ${REGION}
ENABLE_IMAGE_TRANSFORMATION: "true"
IMGPROXY_URL: http://imgproxy:5001
# S3 protocol endpoint configuration
S3_PROTOCOL_ACCESS_KEY_ID: ${S3_PROTOCOL_ACCESS_KEY_ID}
S3_PROTOCOL_ACCESS_KEY_SECRET: ${S3_PROTOCOL_ACCESS_KEY_SECRET}
volumes:
- ./volumes/storage:/var/lib/storage:z
imgproxy:
container_name: supabase-imgproxy
image: darthsim/imgproxy:v3.30.1
restart: unless-stopped
volumes:
- ./volumes/storage:/var/lib/storage:z
healthcheck:
test:
[
"CMD",
"imgproxy",
"health"
]
timeout: 5s
interval: 5s
retries: 3
environment:
IMGPROXY_BIND: ":5001"
IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
IMGPROXY_USE_ETAG: "true"
IMGPROXY_AUTO_WEBP: ${IMGPROXY_AUTO_WEBP}
IMGPROXY_MAX_SRC_RESOLUTION: 16.8
meta:
container_name: supabase-meta
image: supabase/postgres-meta:v0.96.6
restart: unless-stopped
depends_on:
db:
# Disable this if you are using an external Postgres database
condition: service_healthy
environment:
PG_META_PORT: 8080
PG_META_DB_HOST: ${POSTGRES_HOST}
PG_META_DB_PORT: ${POSTGRES_PORT}
PG_META_DB_NAME: ${POSTGRES_DB}
PG_META_DB_USER: postgres
PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
CRYPTO_KEY: ${PG_META_CRYPTO_KEY}
functions:
container_name: supabase-edge-functions
image: supabase/edge-runtime:v1.74.0
restart: unless-stopped
volumes:
- ./volumes/functions:/home/deno/functions:z
- deno-cache:/root/.cache/deno
depends_on:
api-gw:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "timeout 1 bash -c '</dev/tcp/127.0.0.1/9000'"]
interval: 5s
timeout: 5s
retries: 3
environment:
# Legacy symmetric HS256 key
JWT_SECRET: ${JWT_SECRET}
# JWKS for token verification (EC public + legacy symmetric).
# For Podman, use: SUPABASE_JWKS: ${JWT_JWKS}
#SUPABASE_JWKS: ${JWT_JWKS:-{"keys":[]}}
SUPABASE_URL: http://api-gw:8000
SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
# Legacy API keys (HS256-signed JWTs)
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
# New opaque API keys
SUPABASE_PUBLISHABLE_KEYS: "{\"default\":\"${SUPABASE_PUBLISHABLE_KEY:-}\"}"
SUPABASE_SECRET_KEYS: "{\"default\":\"${SUPABASE_SECRET_KEY:-}\"}"
SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
# TODO: Allow configuring VERIFY_JWT per function.
VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}"
command:
[
"start",
"--main-service",
"/home/deno/functions/main"
]
# Comment out everything below this point if you are using an external Postgres database
db:
container_name: supabase-db
# To upgrade an existing Postgres 15 database in place, see utils/upgrade-pg17.sh.
image: supabase/postgres:17.6.1.136
restart: unless-stopped
volumes:
- ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z
# Must be superuser to create event trigger
- ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:Z
# Must be superuser to alter reserved role
- ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:Z
# Initialize the database settings with JWT_SECRET and JWT_EXP
- ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:Z
# PGDATA directory is persisted between restarts
- ./volumes/db/data:/var/lib/postgresql/data:Z
# Changes required for internal supabase data such as _analytics
- ./volumes/db/_supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:Z
# Changes required for Analytics support
- ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:Z
# Changes required for Pooler support
- ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:Z
# Use named volume to persist pgsodium decryption key between restarts
- db-config:/etc/postgresql-custom
healthcheck:
test:
[
"CMD",
"pg_isready",
"-U",
"postgres",
"-h",
"localhost"
]
interval: 5s
timeout: 5s
retries: 10
environment:
POSTGRES_HOST: /var/run/postgresql
PGPORT: ${POSTGRES_PORT}
POSTGRES_PORT: ${POSTGRES_PORT}
PGPASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
PGDATABASE: ${POSTGRES_DB}
POSTGRES_DB: ${POSTGRES_DB}
JWT_SECRET: ${JWT_SECRET}
JWT_EXP: ${JWT_EXPIRY}
command:
[
"postgres",
"-c",
"config_file=/etc/postgresql/postgresql.conf",
"-c",
"log_min_messages=fatal" # prevents Realtime polling queries from appearing in logs
]
# Update the DATABASE_URL if you are using an external Postgres database
supavisor:
container_name: supabase-pooler
image: supabase/supavisor:2.9.5
restart: unless-stopped
ports:
- ${POSTGRES_PORT}:5432
- ${POOLER_PROXY_PORT_TRANSACTION}:6543
volumes:
- ./volumes/pooler/pooler.exs:/etc/pooler/pooler.exs:ro,z
healthcheck:
test:
[
"CMD",
"curl",
"-sSfL",
"--head",
"-o",
"/dev/null",
"http://127.0.0.1:4000/api/health"
]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
depends_on:
db:
condition: service_healthy
environment:
PORT: 4000
POSTGRES_PORT: ${POSTGRES_PORT}
POSTGRES_HOST: ${POSTGRES_HOST}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
DATABASE_URL: ecto://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase
CLUSTER_POSTGRES: "true"
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
VAULT_ENC_KEY: ${VAULT_ENC_KEY}
API_JWT_SECRET: ${JWT_SECRET}
METRICS_JWT_SECRET: ${JWT_SECRET}
REGION: local
ERL_AFLAGS: -proto_dist inet_tcp
POOLER_TENANT_ID: ${POOLER_TENANT_ID}
POOLER_DEFAULT_POOL_SIZE: ${POOLER_DEFAULT_POOL_SIZE}
POOLER_MAX_CLIENT_CONN: ${POOLER_MAX_CLIENT_CONN}
POOLER_POOL_MODE: transaction
DB_POOL_SIZE: ${POOLER_DB_POOL_SIZE}
command:
[
"/bin/sh",
"-c",
"/app/bin/migrate && /app/bin/supavisor eval \"$$(cat /etc/pooler/pooler.exs)\" && /app/bin/server"
]
volumes:
db-config:
deno-cache:
+76
View File
@@ -0,0 +1,76 @@
#!/bin/sh
set -e
auto_confirm=0
confirm () {
if [ "$auto_confirm" = "1" ]; then
return 0
fi
printf "Are you sure you want to proceed? (y/N) "
read -r REPLY
case "$REPLY" in
[Yy])
;;
*)
echo "Script canceled."
exit 1
;;
esac
}
if [ "$1" = "-y" ]; then
auto_confirm=1
fi
echo ""
echo "*** WARNING: This will remove all containers and container data, and optionally reset .env ***"
echo ""
confirm
echo "===> Stopping and removing all containers..."
if [ -f ".env" ]; then
docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml down -v --remove-orphans
elif [ -f ".env.example" ]; then
echo "No .env found, using .env.example for docker compose down..."
docker compose --env-file .env.example -f docker-compose.yml -f ./dev/docker-compose.dev.yml down -v --remove-orphans
else
echo "Skipping 'docker compose down' because there's no env-file."
fi
echo "===> Cleaning up bind-mounted directories..."
BIND_MOUNTS="./volumes/db/data ./volumes/storage"
for dir in $BIND_MOUNTS; do
if [ -d "$dir" ]; then
echo "Removing $dir..."
confirm
rm -rf "$dir"
else
echo "$dir not found."
fi
done
echo "===> Resetting .env file (will save backup to .env.old)..."
confirm
if [ -f ".env" ] || [ -L ".env" ]; then
echo "Renaming existing .env file to .env.old"
mv .env .env.old
else
echo "No .env file found."
fi
if [ -f ".env.example" ]; then
echo "===> Copying .env.example to .env"
cp .env.example .env
else
echo "No .env.example found, can't restore .env to default values."
fi
echo "Cleanup complete!"
echo "Re-run 'docker compose pull' to update images."
echo ""
+297
View File
@@ -0,0 +1,297 @@
#!/bin/sh
#
# Manage the self-hosted Supabase docker compose stack.
#
# Override files are layered via docker compose's native COMPOSE_FILE env
# var in .env. Format: colon-separated list with docker-compose.yml first.
#
# Examples in .env:
# COMPOSE_FILE=docker-compose.yml
# COMPOSE_FILE=docker-compose.yml:docker-compose.pg17.yml
#
# Manage with: sh run.sh config add <name> | config remove <name>
# (accepts either a short name like 'pg17' or 'docker-compose.pg17.yml')
#
# Usage:
# sh run.sh start # docker compose up -d --wait
# sh run.sh stop # docker compose down
# sh run.sh restart [service] # restart the stack (or named services)
# sh run.sh restart --except <svc>... # restart all services except the named ones
# sh run.sh recreate [service] # stop then start (or force-recreate one service)
# sh run.sh recreate --except <svc>... # force-recreate all services except the named ones
# sh run.sh status # docker compose ps
# sh run.sh logs [service] # follow logs (all or one service)
# sh run.sh inspect <service> # docker inspect on a service's container
# sh run.sh printenv <service> # print a service's environment variables
# sh run.sh pull # pull images
# sh run.sh config # show the active COMPOSE_FILE list
# sh run.sh config add <name> # add an override to COMPOSE_FILE in .env
# sh run.sh config remove <name> # remove an override from COMPOSE_FILE in .env
# sh run.sh compose-config # dump fully-resolved docker compose config
# sh run.sh secrets # print key passwords and API keys from .env
#
set -e
cd "$(dirname "$0")"
if [ ! -f docker-compose.yml ]; then
echo "ERROR: docker-compose.yml not found in $(pwd)" >&2
exit 1
fi
# Normalize an override argument:
# pg17 -> docker-compose.pg17.yml
# docker-compose.pg17.yml -> docker-compose.pg17.yml
# ./docker-compose.pg17.yml -> docker-compose.pg17.yml
# docker-compose.yml -> error (base file, always implicit)
normalize_override() {
arg="${1#./}"
case "$arg" in
docker-compose.yml)
echo "ERROR: docker-compose.yml is the base file, always included" >&2
return 1
;;
docker-compose.*.yml)
echo "$arg"
;;
*)
echo "docker-compose.${arg}.yml"
;;
esac
}
# Read COMPOSE_FILE from .env (stripping quotes and CR).
read_compose_file() {
[ -f .env ] || return 0
grep '^COMPOSE_FILE=' .env | head -n1 | cut -d= -f2- | tr -d "\r\"'"
}
# Pretty-print the effective compose file list.
print_config() {
val="$1"
[ -z "$val" ] && val="docker-compose.yml"
echo "COMPOSE_FILE=$val"
echo "compose files:"
OLD_IFS=$IFS
IFS=:
for f in $val; do
echo " $f"
done
IFS=$OLD_IFS
echo ""
}
# Update or append COMPOSE_FILE in .env.
write_compose_file() {
new_value="$1"
if [ ! -f .env ]; then
echo "ERROR: .env not found in $(pwd)" >&2
exit 1
fi
new_line="COMPOSE_FILE=$new_value"
if grep -q '^COMPOSE_FILE=' .env; then
sed -i.old -e "s|^COMPOSE_FILE=.*$|$new_line|" .env
rm -f .env.old
else
cat >> .env <<EOF
############
# Docker compose override files to layer on top of docker-compose.yml.
# Colon-separated list. Manage with: sh run.sh config add|remove <name>.
#
# Examples:
# COMPOSE_FILE=docker-compose.yml
# COMPOSE_FILE=docker-compose.yml:docker-compose.pg17.yml
############
$new_line
EOF
fi
}
# Echoes the list of services (one per line) minus those passed as args.
# Warns on unknown names; returns 1 if no services remain.
services_except() {
all_services=$(docker compose config --services)
filtered="$all_services"
for ex in "$@"; do
echo "$all_services" | grep -qFx "$ex" \
|| echo "Warning: '$ex' is not a service in this project" >&2
filtered=$(echo "$filtered" | grep -vFx "$ex" || true)
done
if [ -z "$filtered" ]; then
echo "No services left after applying --except" >&2
return 1
fi
printf '%s\n' "$filtered"
}
CMD="${1:-help}"
[ "$#" -gt 0 ] && shift
case "$CMD" in
start|up)
exec docker compose up -d --wait "$@"
;;
stop|down)
exec docker compose down "$@"
;;
restart)
if [ "${1:-}" = "--except" ]; then
shift
[ $# -eq 0 ] && { echo "Usage: $(basename "$0") restart --except <svc>..." >&2; exit 1; }
services=$(services_except "$@") || exit 1
# shellcheck disable=SC2086
exec docker compose restart $services
fi
exec docker compose restart "$@"
;;
recreate)
if [ "${1:-}" = "--except" ]; then
shift
[ $# -eq 0 ] && { echo "Usage: $(basename "$0") recreate --except <svc>..." >&2; exit 1; }
services=$(services_except "$@") || exit 1
# shellcheck disable=SC2086
exec docker compose up -d --wait --force-recreate --no-deps $services
fi
if [ $# -eq 0 ]; then
docker compose down
exec docker compose up -d --wait
fi
# Single-service recreate: force-recreate the named services only,
# leave their dependencies running.
exec docker compose up -d --wait --force-recreate --no-deps "$@"
;;
status|ps)
exec docker compose ps "$@"
;;
logs)
exec docker compose logs -f "$@"
;;
inspect)
[ $# -eq 0 ] && { echo "Usage: $(basename "$0") inspect <service> [docker-inspect-args]" >&2; exit 1; }
svc="$1"; shift
cid=$(docker compose ps -q "$svc")
[ -z "$cid" ] && { echo "Service '$svc' is not running" >&2; exit 1; }
exec docker inspect "$cid" "$@"
;;
printenv)
[ $# -eq 0 ] && { echo "Usage: $(basename "$0") printenv <service>" >&2; exit 1; }
svc="$1"
cid=$(docker compose ps -q "$svc")
[ -z "$cid" ] && { echo "Service '$svc' is not running" >&2; exit 1; }
exec docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' "$cid"
;;
pull)
exec docker compose pull "$@"
;;
compose-config)
exec docker compose config "$@"
;;
config)
sub="${1:-show}"
[ "$#" -gt 0 ] && shift
current=$(read_compose_file)
case "$sub" in
show)
print_config "$current"
;;
add)
[ $# -eq 0 ] && { echo "Usage: $(basename "$0") config add <name>..." >&2; exit 1; }
new_value="${current:-docker-compose.yml}"
changed=false
for arg in "$@"; do
file=$(normalize_override "$arg") || exit 1
if [ ! -f "$file" ]; then
echo "ERROR: $file not found" >&2
exit 1
fi
case ":$new_value:" in
*":$file:"*) echo "Already present: $file" ;;
*) new_value="$new_value:$file"; changed=true ;;
esac
done
[ "$changed" = true ] && write_compose_file "$new_value"
print_config "$new_value"
;;
remove|rm)
[ $# -eq 0 ] && { echo "Usage: $(basename "$0") config remove <name>..." >&2; exit 1; }
new_value="${current:-docker-compose.yml}"
changed=false
for arg in "$@"; do
file=$(normalize_override "$arg") || exit 1
case ":$new_value:" in
*":$file:"*)
# Drop $file by rebuilding the colon list
tmp=""
OLD_IFS=$IFS
IFS=:
for tok in $new_value; do
[ "$tok" = "$file" ] || tmp="${tmp:+$tmp:}$tok"
done
IFS=$OLD_IFS
new_value="$tmp"
changed=true
;;
*) echo "Not present: $file" ;;
esac
done
[ "$changed" = true ] && write_compose_file "$new_value"
print_config "$new_value"
;;
*)
echo "Unknown config subcommand: $sub" >&2
echo "Use: config | config add <name>... | config remove <name>..." >&2
exit 1
;;
esac
;;
secrets)
if [ ! -f .env ]; then
echo "ERROR: .env not found in $(pwd)" >&2
exit 1
fi
for var in POSTGRES_PASSWORD DASHBOARD_PASSWORD \
SUPABASE_PUBLISHABLE_KEY SUPABASE_SECRET_KEY \
S3_PROTOCOL_ACCESS_KEY_ID S3_PROTOCOL_ACCESS_KEY_SECRET; do
line=$(grep "^${var}=" .env | head -n1)
if [ -n "$line" ]; then
echo "$line"
else
echo "${var}="
fi
done
echo ""
;;
help|-h|--help)
cat <<EOF
Usage: $(basename "$0") <command>
Commands:
start Start the stack (docker compose up -d --wait)
stop Stop the stack (docker compose down)
restart [service] Restart the stack (or named services)
restart --except <svc>...
Restart all services except the named ones
recreate [service] Stop then start, or force-recreate one service (--no-deps)
recreate --except <svc>...
Force-recreate all services except the named ones (--no-deps)
status Show service status
logs [service] Follow logs (optionally for a single service)
inspect <service> Inspect a service's container (forwards extra args to docker inspect)
printenv <service> Print a service's environment variables (one per line)
pull Pull all images
config Show the active COMPOSE_FILE list
config add <name> Add an override to COMPOSE_FILE in .env (short name or full filename)
config remove <name> Remove an override from COMPOSE_FILE in .env
compose-config Dump the fully-resolved docker compose config
secrets Show key passwords and API keys from .env
EOF
;;
*)
echo "Unknown command: $CMD" >&2
echo "Run '$0 help' for usage." >&2
exit 1
;;
esac
+455
View File
@@ -0,0 +1,455 @@
#!/bin/sh
#
# Bootstrap a self-hosted Supabase project on Linux (Debian/Ubuntu or RHEL/CentOS/Fedora).
#
# What it does:
# 1. Installs prerequisites: git, openssl, jq, ca-certificates
# 2. Installs Docker Engine + Compose plugin (if missing)
# 3. Optionally installs the AWS CLI v2 (--with-aws)
# 4. Sparse-clones the repo to extract the contents of ./docker
# 5. Creates a project directory in CWD and copies docker/* into it
# 6. Records the base version the deployment was set up from (.supabase-version)
# 7. Prompts for the main URLs and writes them to .env
# 8. Generates secrets and asymmetric API keys via utils/*.sh
#
# Usage:
# sh setup.sh # interactive
# sh setup.sh -y # accept defaults, no prompts
# sh setup.sh --project-dir my-supabase # name the project directory
# sh setup.sh --skip-deps # skip system-package installation
# sh setup.sh --with-aws # also install the AWS CLI v2
# sh setup.sh --ref self-hosted/v0.7.0 # clone docker/ from a specific git ref
# sh setup.sh --head # clone docker/ from HEAD (skip tags)
#
# curl -fsSL <url-to-this-script> | sh # bootstrap from scratch in CWD
#
# By default the docker/ sources are cloned from the latest self-hosted release
# tag (self-hosted/v*), falling back to the default branch (HEAD) if none exist.
#
set -e
PROJECT_DIR="supabase-project"
SKIP_DEPS=0
WITH_AWS=0
ASSUME_YES=0
SOURCE_REF=""
FORCE_HEAD=0
print_help() {
cat <<EOF
Usage: setup.sh [options]
Options:
-p, --project-dir <name> Name of the project directory (default: supabase-project)
--skip-deps Skip installation of system packages
--with-aws Install the AWS CLI v2
--ref <tag|branch> Clone docker/ from this git ref instead of the
latest self-hosted tag (no HEAD fallback)
--head Clone docker/ from the default branch (HEAD),
skipping self-hosted tag detection
-y, --yes Non-interactive: accept defaults, no prompts
-h, --help Show this help and exit
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
-p|--project-dir) PROJECT_DIR="$2"; shift 2 ;;
--skip-deps) SKIP_DEPS=1; shift ;;
--with-aws) WITH_AWS=1; shift ;;
--ref) SOURCE_REF="$2"; shift 2 ;;
--head) FORCE_HEAD=1; shift ;;
-y|--yes) ASSUME_YES=1; shift ;;
-h|--help) print_help; exit 0 ;;
*) echo "Unknown option: $1" >&2; print_help; exit 1 ;;
esac
done
# Interactive vs not: -y forces non-interactive; otherwise we're non-interactive
# when there's no controlling terminal to prompt on (e.g. curl | sh in CI).
if [ "$ASSUME_YES" = "1" ] || ! ( : < /dev/tty ) 2>/dev/null; then
NON_INTERACTIVE=1
else
NON_INTERACTIVE=0
fi
if [ "$(id -u)" = "0" ]; then
SUDO=""
else
SUDO="sudo"
fi
log() { printf "===> %s\n" "$*"; }
warn() { printf "WARNING: %s\n" "$*" >&2; }
die() { printf "ERROR: %s\n" "$*" >&2; exit 1; }
# Prompt with a default; echoes the chosen value on stdout.
# Reads from /dev/tty so prompts work even when stdin is a pipe (curl | sh).
# Falls back to the default with -y or when no controlling terminal exists.
ask() {
# ask <prompt> <default> -> echoes chosen value
if [ "$NON_INTERACTIVE" = "1" ]; then
printf '%s' "$2"
return
fi
printf "%s [%s]: " "$1" "$2" > /dev/tty
read -r reply < /dev/tty
[ -z "$reply" ] && reply="$2"
printf '%s' "$reply"
}
# True if the value looks like an http(s) URL (scheme check only, not validation).
valid_url() {
case "$1" in
http://?*|https://?*) return 0 ;;
*) return 1 ;;
esac
}
# Like ask, but requires an http(s):// value. Re-prompts interactively; with no
# usable terminal (-y or curl | sh) a bad value is fatal rather than silently kept.
ask_url() {
# ask_url <prompt> <default> -> echoes a validated URL
while :; do
_url=$(ask "$1" "$2")
valid_url "$_url" && { printf '%s' "$_url"; return 0; }
if [ "$NON_INTERACTIVE" = "1" ]; then
die "$1 must start with http:// or https:// (got: '$_url')"
fi
printf " '%s' is not a URL - it must start with http:// or https://.\n" "$_url" > /dev/tty
done
}
OS_FAMILY=""
OS_ID=""
OS_CODENAME=""
detect_os() {
[ -f /etc/os-release ] || die "Cannot detect OS: /etc/os-release missing. Linux only."
# shellcheck disable=SC1091
. /etc/os-release
OS_ID="$ID"
OS_CODENAME="${VERSION_CODENAME:-}"
case "$ID" in
ubuntu|debian) OS_FAMILY="debian" ;;
centos|rhel|fedora|rocky|almalinux|ol|amzn) OS_FAMILY="rhel" ;;
*)
case "${ID_LIKE:-}" in
*debian*|*ubuntu*) OS_FAMILY="debian" ;;
*rhel*|*fedora*|*centos*) OS_FAMILY="rhel" ;;
*) die "Unsupported distribution: $ID" ;;
esac
;;
esac
log "Detected OS: $ID ($OS_FAMILY)"
}
pkg_update() {
if [ "$OS_FAMILY" = "debian" ]; then
$SUDO apt-get update -qq -y
else
$SUDO dnf makecache -q -y || true
fi
}
pkg_install() {
if [ "$OS_FAMILY" = "debian" ]; then
export DEBIAN_FRONTEND=noninteractive
$SUDO apt-get install -qq -y "$@"
else
$SUDO dnf install -q -y "$@"
fi
}
install_base_packages() {
log "Installing base packages: git, openssl, jq, ca-certificates"
pkg_update
if [ "$OS_FAMILY" = "debian" ]; then
pkg_install git openssl jq ca-certificates \
apt-transport-https gnupg lsb-release
else
pkg_install git openssl jq ca-certificates dnf-plugins-core
fi
}
docker_present() {
command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1
}
install_docker() {
if docker_present; then
log "Docker already installed: $(docker --version)"
return 0
fi
log "Installing Docker Engine and Compose plugin"
if [ "$OS_FAMILY" = "debian" ]; then
$SUDO install -m 0755 -d /etc/apt/keyrings
curl -fsSL "https://download.docker.com/linux/${OS_ID}/gpg" \
| $SUDO gpg --dearmor --yes -o /etc/apt/keyrings/docker.gpg
$SUDO chmod a+r /etc/apt/keyrings/docker.gpg
codename="${OS_CODENAME:-$(lsb_release -cs 2>/dev/null || echo stable)}"
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/${OS_ID} ${codename} stable" \
| $SUDO tee /etc/apt/sources.list.d/docker.list >/dev/null
$SUDO apt-get update -y
pkg_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
else
# Amazon Linux
if [ "$OS_ID" = "amzn" ]; then
# Install Docker from the repo
pkg_install docker
# Install Docker Compose
$SUDO mkdir -p /usr/local/lib/docker/cli-plugins && \
$SUDO curl -fsSL "https://github.com/docker/compose/releases/latest/download/docker-compose-linux-$(uname -m)" \
-o /usr/local/lib/docker/cli-plugins/docker-compose && \
$SUDO chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
else
repo_distro="centos"
case "$OS_ID" in
fedora) repo_distro="fedora" ;;
rhel) repo_distro="rhel" ;;
esac
$SUDO dnf config-manager --add-repo "https://download.docker.com/linux/${repo_distro}/docker-ce.repo" 2>/dev/null \
|| $SUDO dnf-3 config-manager --add-repo "https://download.docker.com/linux/${repo_distro}/docker-ce.repo"
pkg_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
fi
fi
log "Enabling and starting docker service"
$SUDO systemctl enable --now docker || warn "Could not enable docker via systemctl; start it manually."
docker_present || die "Docker installation finished but 'docker compose' is still unavailable."
}
install_aws() {
if command -v aws >/dev/null 2>&1; then
log "AWS CLI already installed: $(aws --version 2>&1)"
return 0
fi
arch=$(uname -m)
case "$arch" in
x86_64|amd64) aws_arch="x86_64" ;;
aarch64|arm64) aws_arch="aarch64" ;;
*) die "Unsupported architecture for AWS CLI: $arch" ;;
esac
log "Installing AWS CLI v2 (${aws_arch})"
command -v unzip >/dev/null 2>&1 || pkg_install unzip
tmp=$(mktemp -d)
(
cd "$tmp"
curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-${aws_arch}.zip" -o awscliv2.zip
unzip -q -o awscliv2.zip
$SUDO ./aws/install --bin-dir /usr/local/bin --install-dir /usr/local/aws-cli --update
)
rm -rf "$tmp"
}
SRC_DIR=""
SRC_TMP=""
RESOLVED_REF=""
REPO_URL="${SUPABASE_REPO_URL:-https://github.com/supabase/supabase}"
STAMP_FILE=".supabase-version"
# Highest self-hosted/v* tag on the remote, or empty when the remote has none.
# Returns non-zero (printing nothing) when the remote can't be reached.
latest_release_tag() {
_refs=$(git ls-remote --tags --refs "$REPO_URL" 2>/dev/null) || return 1
printf '%s\n' "$_refs" \
| sed 's#^.*refs/tags/##' \
| grep -E '^self-hosted/v[0-9]' \
| sort -V | tail -n1
}
# Clone only docker/ from <ref> (empty = default branch) into <dest>.
sparse_clone() {
# sparse_clone <dest> [ref]
_dest="$1"
_ref="$2"
if [ -n "$_ref" ]; then
git clone --filter=blob:none --no-checkout --depth=1 --quiet \
--branch "$_ref" "$REPO_URL" "$_dest" 2>/dev/null || return 1
else
git clone --filter=blob:none --no-checkout --depth=1 --quiet \
"$REPO_URL" "$_dest" 2>/dev/null || return 1
fi
( cd "$_dest" &&
git sparse-checkout init --cone &&
git sparse-checkout set docker &&
git checkout --quiet ) 2>/dev/null || return 1
}
# Echo the commit the clone resolved to (for stamping HEAD-based checkouts).
resolved_sha() {
git -C "$1" rev-parse HEAD 2>/dev/null || true
}
prepare_source() {
SRC_TMP=$(mktemp -d) || die "Could not create a temporary directory"
dest="$SRC_TMP/supabase"
# Pick the ref to clone; empty means the default branch (HEAD), used only
# when no release tag exists yet (or --head). A resolved tag that fails to
# clone is an error, not a reason to silently install HEAD instead.
if [ -n "$SOURCE_REF" ]; then
ref="$SOURCE_REF"
elif [ "$FORCE_HEAD" = "1" ]; then
ref=""
else
if ref=$(latest_release_tag); then
[ -n "$ref" ] || log "No self-hosted release tag found; using the default branch (HEAD)"
else
die "Could not reach $REPO_URL to look up release tags. Check your network and retry, or pass --head (default branch) or --ref <tag>."
fi
fi
log "Sparse-cloning supabase repo at ${ref:-HEAD}"
sparse_clone "$dest" "$ref" || die "Could not clone '${ref:-HEAD}' from $REPO_URL"
# Stamp the tag name for a release tag, else the exact commit SHA.
case "$ref" in
self-hosted/v*) RESOLVED_REF="$ref" ;;
*) RESOLVED_REF=$(resolved_sha "$dest") ;;
esac
SRC_DIR="$dest/docker"
}
cleanup_src_tmp() {
if [ -n "$SRC_TMP" ] && [ -d "$SRC_TMP" ]; then
rm -rf "$SRC_TMP"
fi
}
trap cleanup_src_tmp EXIT
read_env() {
grep "^$1=" .env 2>/dev/null | head -n1 | cut -d= -f2-
}
# Record the base version this deployment was set up from, so update.sh can
# 3-way merge future upgrades against it: it fetches the snapshot at this ref
# as the merge base, and derives the base version from the ref itself. Just the
# ref - per-deployment state, not vendor content: gitignored.
write_version_stamp() {
[ -n "$1" ] || { warn "Could not resolve a base ref; skipping $STAMP_FILE"; return 0; }
{
echo "# Supabase self-hosted version stamp. Managed by setup.sh / update.sh."
echo "# Do not commit or edit by hand. Records the ref this deployment was based on."
echo "ref=$1"
} > "$STAMP_FILE"
log "Recorded base version in $STAMP_FILE (ref=$1)"
}
# --- Main ---
log "Setup starting in $(pwd)"
log "This may take several minutes..."
if [ "$SKIP_DEPS" = "1" ]; then
log "Skipping system-package installation (--skip-deps)"
else
detect_os
install_base_packages
install_docker
fi
if [ "$WITH_AWS" = "1" ]; then
install_aws
fi
# Idempotent re-run: if CWD is already a set-up project, skip bootstrap.
# A clone has docker-compose.yml + utils/ but only .env.example;
# a set-up project also has a real .env.
if [ -f .env ] && [ -f docker-compose.yml ] && [ -d utils ]; then
log "Already in a Supabase project directory; skipping bootstrap."
exit 0
fi
prepare_source
target="$(pwd)/$PROJECT_DIR"
if [ -e "$target" ]; then
die "Target $target already exists. Pick a different name with --project-dir"
fi
log "Creating project at $target"
mkdir -p "$target"
cp -rf "$SRC_DIR/." "$target/"
if [ -f "$target/.env.example" ] && [ ! -f "$target/.env" ]; then
cp "$target/.env.example" "$target/.env"
fi
cd "$target"
current_public_url=$(read_env SUPABASE_PUBLIC_URL)
current_site_url=$(read_env SITE_URL)
[ -z "$current_public_url" ] && current_public_url="http://localhost:8000"
[ -z "$current_site_url" ] && current_site_url="http://localhost:3000"
if [ "$NON_INTERACTIVE" = "1" ]; then
log "Non-interactive: using default URLs (edit .env to change)"
else
echo ""
echo "Configure the main URLs (press Enter to accept the default)."
echo ""
fi
public_url=$(ask_url "SUPABASE_PUBLIC_URL (Studio + APIs)" "$current_public_url")
api_url=$(ask_url "API_EXTERNAL_URL (Auth callbacks)" "$public_url/auth/v1")
site_url=$(ask_url "SITE_URL (default Auth redirect)" "$current_site_url")
# Suggest PROXY_DOMAIN from the public_url host (unless it's localhost-ish)
public_host=$(printf '%s' "$public_url" | sed -e 's|^https*://||' -e 's|/.*$||' -e 's|:.*$||')
case "$public_host" in
localhost|127.*|"") current_proxy_domain=$(read_env PROXY_DOMAIN) ;;
*) current_proxy_domain="$public_host" ;;
esac
[ -z "$current_proxy_domain" ] && current_proxy_domain="your-domain.example.com"
proxy_domain=$(ask "PROXY_DOMAIN (for nginx/caddy HTTPS proxy)" "$current_proxy_domain")
# Derive CERTBOT_EMAIL = admin@<last-two-labels-of-proxy-domain>.
# Naive: doesn't handle ccTLDs like .co.uk; user can edit .env after.
domain_root=$(printf '%s' "$proxy_domain" | awk -F. 'NF>=2 { print $(NF-1)"."$NF; next } { print }')
certbot_email="admin@${domain_root}"
log "Setting CERTBOT_EMAIL=${certbot_email}"
sed -i.old \
-e "s|^SUPABASE_PUBLIC_URL=.*$|SUPABASE_PUBLIC_URL=${public_url}|" \
-e "s|^API_EXTERNAL_URL=.*$|API_EXTERNAL_URL=${api_url}|" \
-e "s|^SITE_URL=.*$|SITE_URL=${site_url}|" \
-e "s|^PROXY_DOMAIN=.*$|PROXY_DOMAIN=${proxy_domain}|" \
-e "s|^CERTBOT_EMAIL=.*$|CERTBOT_EMAIL=${certbot_email}|" \
.env
rm -f .env.old
log "Generating secrets and legacy API keys"
sh utils/generate-keys.sh --update-env
log "Generating asymmetric key pair and opaque API keys"
sh utils/add-new-auth-keys.sh --update-env
write_version_stamp "$RESOLVED_REF"
log "Pulling Docker images"
if [ "$NON_INTERACTIVE" = "1" ]; then
docker compose --progress quiet pull || warn "docker compose pull failed; you can retry later."
else
docker compose pull || warn "docker compose pull failed; you can retry later."
fi
echo ""
echo "Setup complete. Project ready at: $(pwd)"
echo ""
echo "Next steps:"
echo " cd $(pwd)"
echo " sh run.sh config"
echo " sh run.sh secrets"
echo " sh run.sh start"
echo ""
echo "To enable docker-compose overrides (caddy, nginx, logs, rustfs, s3, kong):"
echo " sh run.sh config add caddy"
echo ""
@@ -0,0 +1,11 @@
# Test override: exposes the S3 backend port for direct testing.
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.rustfs.yml \
# -f ./tests/docker-compose.rustfs.test.yml up -d
#
services:
rustfs:
ports:
- "${S3_BACKEND_TEST_PORT:-9100}:9000"
@@ -0,0 +1,13 @@
# Test override: exposes the S3 backend port for direct testing.
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.s3.yml \
# -f ./tests/docker-compose.s3.test.yml up -d
#
# When swapping to a different S3 backend (e.g. RustFS), update the
# service name and internal port to match the new backend.
services:
minio:
ports:
- "${S3_BACKEND_TEST_PORT:-9100}:9000"
@@ -0,0 +1,398 @@
#!/bin/sh
#
# Test API key types and asymmetric auth against a running self-hosted instance.
#
# Usage:
# sh test-auth-keys.sh # Uses http://localhost:8000
# sh test-auth-keys.sh <base_url> # Custom URL
#
# Prerequisites:
# - Running self-hosted Supabase instance
# - .env file with all keys configured
# - jq (for JSON parsing)
# - node >= 16 (for HS256 token minting test only)
#
set -e
BASE_URL="${1:-http://localhost:8000}"
if [ ! -f .env ]; then
echo "Error: .env file not found. Run from the project directory."
exit 1
fi
for cmd in jq node; do
if ! command -v $cmd >/dev/null 2>&1; then
echo "Error: $cmd not found."
exit 1
fi
done
# Read keys from .env
JWT_SECRET=$(grep '^JWT_SECRET=' .env | cut -d= -f2-)
ANON_KEY=$(grep '^ANON_KEY=' .env | cut -d= -f2-)
SERVICE_ROLE_KEY=$(grep '^SERVICE_ROLE_KEY=' .env | cut -d= -f2-)
SUPABASE_PUBLISHABLE_KEY=$(grep '^SUPABASE_PUBLISHABLE_KEY=' .env | cut -d= -f2-)
SUPABASE_SECRET_KEY=$(grep '^SUPABASE_SECRET_KEY=' .env | cut -d= -f2-)
pass=0
fail=0
check() {
test_name="$1"
expected="$2"
actual="$3"
if [ "$actual" = "$expected" ]; then
echo " PASS: $test_name (HTTP $actual)"
pass=$((pass + 1))
else
echo " FAIL: $test_name (expected $expected, got $actual)"
fail=$((fail + 1))
fi
}
http_status() {
url="$1"
shift
curl -s -o /dev/null -w "%{http_code}" "$@" "$url"
}
echo ""
echo "=== Testing against $BASE_URL ==="
echo ""
# ---------------------------------------------
# 1. Route tests with API key types
# ---------------------------------------------
echo "--- REST API (/rest/v1/) ---"
check "Legacy ANON_KEY -> 403" "403" \
"$(http_status "$BASE_URL/rest/v1/" -H "apikey: $ANON_KEY")"
check "Legacy SERVICE_ROLE_KEY" "200" \
"$(http_status "$BASE_URL/rest/v1/" -H "apikey: $SERVICE_ROLE_KEY")"
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
check "New PUBLISHABLE_KEY -> 403" "403" \
"$(http_status "$BASE_URL/rest/v1/" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")"
check "New SECRET_KEY" "200" \
"$(http_status "$BASE_URL/rest/v1/" -H "apikey: $SUPABASE_SECRET_KEY")"
else
echo " SKIP: Opaque keys not configured"
fi
check "No key -> 401" "401" \
"$(http_status "$BASE_URL/rest/v1/")"
check "Invalid key -> 401" "401" \
"$(http_status "$BASE_URL/rest/v1/" -H "apikey: invalid-key")"
echo ""
echo "--- Auth (/auth/v1/settings) ---"
check "Legacy ANON_KEY" "200" \
"$(http_status "$BASE_URL/auth/v1/settings" -H "apikey: $ANON_KEY")"
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
check "New PUBLISHABLE_KEY" "200" \
"$(http_status "$BASE_URL/auth/v1/settings" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")"
fi
check "No key -> 401" "401" \
"$(http_status "$BASE_URL/auth/v1/settings")"
echo ""
echo "--- Storage (/storage/v1/bucket) ---"
# Storage has no key-auth - passes through, Storage returns its own errors
check "No key -> not 401 (Storage handles auth)" "true" \
"$([ "$(http_status "$BASE_URL/storage/v1/bucket")" != "401" ] && echo true || echo false)"
check "Legacy ANON_KEY" "200" \
"$(http_status "$BASE_URL/storage/v1/bucket" -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY")"
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
# With opaque key, the API gateway translates to asymmetric JWT in Authorization
check "New PUBLISHABLE_KEY" "200" \
"$(http_status "$BASE_URL/storage/v1/bucket" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")"
fi
echo ""
echo "--- Storage S3 (/storage/v1/s3/) ---"
# S3 uses AWS SigV4 auth (not apikey) - the request-transformer Lua expression
# passes the Authorization header through unchanged for non-sb_ values
check "S3 route accessible" "true" \
"$([ "$(http_status "$BASE_URL/storage/v1/s3/")" != "502" ] && echo true || echo false)"
echo ""
echo "--- GraphQL (/graphql/v1) ---"
check "Legacy ANON_KEY" "200" \
"$(http_status "$BASE_URL/graphql/v1" \
-H "apikey: $ANON_KEY" \
-H "Content-Type: application/json" \
-d '{"query":"{ __typename }"}')"
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
check "New PUBLISHABLE_KEY" "200" \
"$(http_status "$BASE_URL/graphql/v1" \
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
-H "Content-Type: application/json" \
-d '{"query":"{ __typename }"}')"
fi
check "No key -> 401" "401" \
"$(http_status "$BASE_URL/graphql/v1" \
-H "Content-Type: application/json" \
-d '{"query":"{ __typename }"}')"
echo ""
echo "--- Realtime REST (/realtime/v1/api/) ---"
# Realtime REST API - use /api/ping to verify key auth (expect 200 with a valid key)
check "Legacy ANON_KEY -> 200" "200" \
"$(http_status "$BASE_URL/realtime/v1/api/ping" -H "apikey: $ANON_KEY")"
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
check "New PUBLISHABLE_KEY -> 200" "200" \
"$(http_status "$BASE_URL/realtime/v1/api/ping" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")"
fi
check "No key -> 401" "401" \
"$(http_status "$BASE_URL/realtime/v1/api/ping")"
# Management endpoints must be blocked at the gateway (even with a valid key)
check "/api/tenants blocked -> 403" "403" \
"$(http_status "$BASE_URL/realtime/v1/api/tenants" -H "apikey: $ANON_KEY")"
check "/api/openapi blocked -> 403" "403" \
"$(http_status "$BASE_URL/realtime/v1/api/openapi" -H "apikey: $ANON_KEY")"
echo ""
echo "--- supabase-js style requests (apikey + Authorization) ---"
# supabase-js sends both apikey header AND Authorization: Bearer <apikey>
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
check "apikey + Authorization: Bearer sb_ (replace path)" "403" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
-H "Authorization: Bearer $SUPABASE_PUBLISHABLE_KEY")"
check "secret apikey + Authorization: Bearer sb_secret" "200" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $SUPABASE_SECRET_KEY" \
-H "Authorization: Bearer $SUPABASE_SECRET_KEY")"
fi
check "Legacy apikey + Authorization: Bearer <legacy jwt>" "403" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $ANON_KEY")"
check "Service role apikey + Authorization: Bearer <legacy jwt>" "200" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY")"
echo ""
echo "--- Edge cases ---"
# Opaque key in Authorization only (no apikey header) - should be rejected by key-auth
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
check "sb_ in Authorization only (no apikey) -> 401" "401" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "Authorization: Bearer $SUPABASE_PUBLISHABLE_KEY")"
fi
echo ""
echo "--- JWKS endpoint ---"
check "JWKS public endpoint (no auth)" "200" \
"$(http_status "$BASE_URL/auth/v1/.well-known/jwks.json")"
# Verify JWKS content: should have EC key, should NOT have symmetric key
jwks_content=$(curl -s "$BASE_URL/auth/v1/.well-known/jwks.json")
jwks_has_ec=$(echo "$jwks_content" | jq -r '[.keys[] | .kty] | if any(. == "EC") then "true" else "false" end' 2>/dev/null)
jwks_has_oct=$(echo "$jwks_content" | jq -r '[.keys[] | .kty] | if any(. == "oct") then "true" else "false" end' 2>/dev/null)
check "JWKS contains EC public key" "true" "$jwks_has_ec"
check "JWKS does NOT contain symmetric key" "false" "$jwks_has_oct"
#echo ""
#echo "--- OAuth metadata endpoint ---"
#check "well-known oauth (no auth)" "200" \
# "$(http_status "$BASE_URL/.well-known/oauth-authorization-server")"
echo ""
echo "--- Realtime WebSocket upgrade ---"
# Test that WebSocket upgrade request gets through (expect 101 or non-401)
# curl --max-time to prevent hanging on successful upgrade (101 keeps connection open)
ws_status=$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 \
"$BASE_URL/realtime/v1/websocket?apikey=$ANON_KEY&vsn=1.0.0" \
-H "Upgrade: websocket" \
-H "Connection: Upgrade" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Version: 13" 2>/dev/null || echo "000")
# 101 = upgrade success, 000 = timeout (connection stayed open = success)
check "WebSocket upgrade with legacy key -> not 401" "true" \
"$([ "$ws_status" != "401" ] && echo true || echo false)"
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
ws_status_new=$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 \
"$BASE_URL/realtime/v1/websocket?apikey=$SUPABASE_PUBLISHABLE_KEY&vsn=1.0.0" \
-H "Upgrade: websocket" \
-H "Connection: Upgrade" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Version: 13" 2>/dev/null || echo "000")
check "WebSocket upgrade with opaque key -> not 401" "true" \
"$([ "$ws_status_new" != "401" ] && echo true || echo false)"
fi
# ---------------------------------------------
# 2. User session JWT tests
# ---------------------------------------------
echo ""
echo "--- User session JWT ---"
# Create user via admin API (works regardless of email autoconfirm setting)
test_email="test-keys-$$@example.com"
test_password="test-password-123456"
create_resp=$(curl -s "$BASE_URL/auth/v1/admin/users" \
-X POST \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$test_email\",\"password\":\"$test_password\",\"email_confirm\":true}")
test_user_id=$(echo "$create_resp" | jq -r '.id // empty' 2>/dev/null)
# Sign in to get session JWT
auth_response=$(curl -s "$BASE_URL/auth/v1/token?grant_type=password" \
-H "apikey: $ANON_KEY" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$test_email\",\"password\":\"$test_password\"}")
access_token=$(echo "$auth_response" | jq -r '.access_token // empty' 2>/dev/null)
if [ -n "$access_token" ]; then
# Check the algorithm in the JWT header
jwt_alg=$(echo "$access_token" | cut -d. -f1 | \
jq -Rr '@base64d | fromjson | .alg // empty' 2>/dev/null)
if [ -n "$jwt_alg" ]; then
echo " INFO: User session JWT signed with: $jwt_alg"
if [ "$jwt_alg" = "ES256" ]; then
check "JWT uses ES256 (asymmetric)" "ES256" "$jwt_alg"
else
check "JWT uses HS256 (legacy)" "HS256" "$jwt_alg"
fi
fi
# Use the session JWT with PostgREST
check "Session JWT with PostgREST" "403" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $access_token")"
check "Session JWT with PostgREST + service role key" "200" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $access_token")"
# Use the session JWT with Storage
check "Session JWT with Storage" "200" \
"$(http_status "$BASE_URL/storage/v1/bucket" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $access_token")"
# CRITICAL: Authenticated user + opaque key (most common supabase-js flow)
# supabase-js sends apikey: sb_publishable_xxx AND Authorization: Bearer <user_session_jwt>
# The expression MUST keep the user JWT and NOT replace it with the anon asymmetric JWT
if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
echo ""
echo "--- Authenticated user + opaque key (critical path) ---"
check "Opaque apikey + user JWT -> PostgREST uses user JWT" "403" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
-H "Authorization: Bearer $access_token")"
check "Secret apikey + user JWT -> PostgREST allowed" "200" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $SUPABASE_SECRET_KEY" \
-H "Authorization: Bearer $access_token")"
check "Opaque apikey + user JWT -> Storage uses user JWT" "200" \
"$(http_status "$BASE_URL/storage/v1/bucket" \
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
-H "Authorization: Bearer $access_token")"
check "Opaque apikey + user JWT -> Auth uses user JWT" "200" \
"$(http_status "$BASE_URL/auth/v1/user" \
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
-H "Authorization: Bearer $access_token")"
fi
else
check "Sign in test user" "true" "false"
fi
# Clean up test user
if [ -n "$test_user_id" ]; then
curl -s -o /dev/null "$BASE_URL/auth/v1/admin/users/$test_user_id" \
-X DELETE \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY"
fi
# ---------------------------------------------
# 3. HS256 backward compatibility
# ---------------------------------------------
echo ""
echo "--- HS256 backward compatibility ---"
# Mint a legacy HS256 JWT with role=anon (simulating a pre-migration token)
hs256_token=$(JWT_SECRET="$JWT_SECRET" node -e "
const crypto = require('crypto');
const header = Buffer.from(JSON.stringify({alg:'HS256',typ:'JWT'})).toString('base64url');
const payload = Buffer.from(JSON.stringify({
role:'anon',iss:'supabase',
iat:Math.floor(Date.now()/1000),
exp:Math.floor(Date.now()/1000)+3600
})).toString('base64url');
const sig = crypto.createHmac('sha256',process.env.JWT_SECRET)
.update(header+'.'+payload).digest('base64url');
console.log(header+'.'+payload+'.'+sig);
" 2>/dev/null)
if [ -n "$hs256_token" ]; then
check "HS256 token with PostgREST (backward compat)" "403" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $hs256_token")"
check "HS256 token with PostgREST + service role key" "200" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $hs256_token")"
else
echo " SKIP: Could not mint HS256 token (node required)"
fi
# ---------------------------------------------
# 4. JWT_KEYS format validation
# ---------------------------------------------
echo ""
echo "--- JWT_KEYS format ---"
JWT_KEYS_VAL=$(grep '^JWT_KEYS=' .env | cut -d= -f2-)
if [ -n "$JWT_KEYS_VAL" ]; then
# Auth expects a JSON array, not a JWKS object
jwt_keys_is_array=$(echo "$JWT_KEYS_VAL" | jq -r 'if type == "array" then "true" else "false" end' 2>/dev/null)
check "JWT_KEYS is JSON array (not JWKS object)" "true" "$jwt_keys_is_array"
jwt_keys_has_sign=$(echo "$JWT_KEYS_VAL" | jq -r 'if any(.[]; .key_ops and (.key_ops | index("sign"))) then "true" else "false" end' 2>/dev/null)
check "JWT_KEYS has a signing key (key_ops: sign)" "true" "$jwt_keys_has_sign"
else
echo " SKIP: JWT_KEYS not configured"
fi
# ---------------------------------------------
# Summary
# ---------------------------------------------
echo ""
echo "=== Results: $pass passed, $fail failed ==="
echo ""
if [ "$fail" -gt 0 ]; then
exit 1
fi
@@ -0,0 +1,179 @@
#!/bin/sh
#
# Verify all self-hosted Supabase services started correctly by checking log output.
#
# Usage:
# sh test-container-logs.sh
#
# Prerequisites:
# - Running self-hosted Supabase instance (docker compose up)
#
set -e
pass=0
fail=0
project_name="${COMPOSE_PROJECT_NAME:-supabase}"
fail_msg() {
fail=$((fail + 1))
echo " FAIL: $1"
}
pass_msg() {
pass=$((pass + 1))
echo " PASS: $1"
}
# The `docker ps` fallback in the helpers below exists because the script
# doesn't know which compose `-f` flags the user ran `up` with. `docker compose
# ps` only sees services defined in the currently loaded compose files, but
# compose stamps `com.docker.compose.{project,service}` labels at `up` time -
# so a label-based lookup finds the container regardless of which override
# files are active in this shell.
is_service_running() {
service="$1"
if docker compose ps --services --status running 2>/dev/null | grep -q "^$service$"; then
return 0
fi
docker ps --filter "label=com.docker.compose.project=$project_name" \
--filter "label=com.docker.compose.service=$service" \
--filter "status=running" \
--quiet | grep -q '.'
}
get_container_id() {
service="$1"
container_id=$(docker compose ps -q "$service" 2>/dev/null || true)
if [ -n "$container_id" ]; then
printf '%s' "$container_id"
return
fi
container_id=$(docker ps -a \
--filter "label=com.docker.compose.project=$project_name" \
--filter "label=com.docker.compose.service=$service" \
--quiet)
set -- $container_id
printf '%s' "$1"
}
# Check that a service's logs contain all expected patterns.
# Logs are written to a temp file so that grep -q exits cleanly.
check_logs() {
service="$1"
shift
logfile=$(mktemp)
docker compose logs "$service" > "$logfile" 2>/dev/null || true
if [ ! -s "$logfile" ]; then
container_id=$(get_container_id "$service")
if [ -n "$container_id" ]; then
docker logs "$container_id" > "$logfile" 2>&1 || true
fi
fi
if [ ! -s "$logfile" ]; then
rm -f "$logfile"
fail_msg "$service (no logs found)"
return
fi
for pattern in "$@"; do
if ! grep -q -i -E "$pattern" "$logfile"; then
rm -f "$logfile"
fail_msg "$service (missing: $pattern)"
return
fi
done
rm -f "$logfile"
pass_msg "$service"
}
check_logs_if_running() {
service="$1"
shift
if is_service_running "$service"; then
check_logs "$service" "$@"
else
pass_msg "$service (skipped: service not running)"
fi
}
echo ""
echo "=== Checking service startup logs ==="
echo ""
check_logs db \
'PostgreSQL init process complete; ready for start up.|Skipping initialization'
check_logs auth \
'db worker started'
# API gateway: Envoy by default, or Kong when the kong override is enabled.
# The service is named api-gw in both cases, so accept either startup marker.
check_logs api-gw \
'init\.lua.*declarative config loaded|Envoy configuration generated successfully'
check_logs rest \
'Schema cache loaded in.*milliseconds'
check_logs realtime \
'Starting Realtime' \
'Connected to Postgres database' \
'Janitor started' \
'Starting MetricsCleaner'
check_logs storage \
'Started Successfully'
check_logs studio \
'ready in.*s$'
check_logs meta \
'Server listening at http'
check_logs functions \
'main function started'
check_logs_if_running analytics \
'Access LogflareWeb.Endpoint at http://localhost:4000' \
'Executing startup tasks' \
'Ensuring single tenant user is seeded'
# Database pooler: Supavisor by default, or PgBouncer when the pgbouncer
# override is enabled (which disables Supavisor). Check whichever is running.
if is_service_running supavisor; then
check_logs supavisor \
'Connected to Postgres database' \
'HEAD /api/health$'
elif is_service_running pgbouncer; then
check_logs pgbouncer \
'process up: PgBouncer' \
'listening on .*6432'
else
fail_msg "pooler (neither supavisor nor pgbouncer is running)"
fi
check_logs_if_running vector \
'Vector has started'
check_logs imgproxy \
'Starting server at :5001'
echo ""
echo "=== Results: $pass passed, $fail failed ==="
echo ""
if [ "$fail" -gt 0 ]; then
echo "Inspect logs: docker compose logs <service>"
echo ""
exit 1
fi
@@ -0,0 +1,355 @@
#!/bin/sh
#
# Test Postgres 15 -> 17 upgrade for self-hosted Supabase.
#
# Seeds test data on a running Postgres 15 stack, runs the upgrade script,
# and verifies data integrity + service connectivity using pgTAP.
#
# Usage:
# cd docker/
# sudo bash tests/test-pg17-upgrade.sh
#
# Prerequisites:
# - Running self-hosted Supabase with a clean, tests-only Postgres 15.
# Postgres 17 is now the default, so start PG 15 explicitly via the override:
# docker compose -f docker-compose.yml -f docker-compose.pg15.yml up -d
# - .env file with POSTGRES_PASSWORD, ANON_KEY
#
set -eu
DB_CONTAINER="supabase-db"
if [ ! -f .env ]; then
echo "Error: .env file not found. Run from the docker/ directory."
exit 1
fi
pg_password=$(grep '^POSTGRES_PASSWORD=' .env | cut -d '=' -f 2-)
anon_key=$(grep '^ANON_KEY=' .env | cut -d '=' -f 2- || true)
if [ -z "$pg_password" ]; then
echo "Error: POSTGRES_PASSWORD not set in .env"
exit 1
fi
run_sql() {
docker exec -i \
-e PGPASSWORD="$pg_password" \
"$DB_CONTAINER" \
psql -h localhost -U supabase_admin -d postgres -v ON_ERROR_STOP=1 "$@"
}
echo ""
echo "=== Postgres 15 -> 17 Upgrade Test ==="
echo ""
# --- Verify we're starting from Postgres 15 --------------------------------
current_version=$(run_sql -A -t -c "SHOW server_version;" | head -1)
case "$current_version" in
15.*) echo "Starting version: PostgreSQL $current_version" ;;
17.*) echo "Error: Already on Postgres 17. Start with a PG 15 stack."; exit 1 ;;
*) echo "Error: Unexpected version: $current_version"; exit 1 ;;
esac
# --- Seed test data --------------------------------------------------------
# Note: this script is designed to run against a fresh docker-compose stack,
# not an existing database with user data.
echo ""
echo "Seeding test data on Postgres 15..."
run_sql <<'EOSQL'
-- Test table with various column types
CREATE TABLE IF NOT EXISTS public._upgrade_test (
id serial PRIMARY KEY,
name text NOT NULL,
value numeric(10,2),
created_at timestamptz DEFAULT now(),
metadata jsonb
);
TRUNCATE public._upgrade_test;
INSERT INTO public._upgrade_test (name, value, metadata) VALUES
('alpha', 1.50, '{"tag": "a"}'),
('bravo', 2.75, '{"tag": "b"}'),
('charlie', 3.00, '{"tag": "c"}'),
('delta', 4.25, '{"tag": "d"}'),
('echo', 5.99, '{"tag": "e"}');
-- Index
CREATE INDEX IF NOT EXISTS _upgrade_test_name_idx ON public._upgrade_test (name);
-- Function
CREATE OR REPLACE FUNCTION public._upgrade_test_fn(n int)
RETURNS int LANGUAGE sql IMMUTABLE AS $$
SELECT n * 2;
$$;
-- Grant access so PostgREST can read it
GRANT SELECT ON public._upgrade_test TO anon, authenticated;
-- pg_cron: created here as supabase_admin (the common manually-enabled case),
-- so the extension is owned by supabase_admin, NOT postgres. complete.sh's
-- drop+recreate only fires for postgres-owned pg_cron, so this exercises the
-- version reconcile in the upgrade script: Supabase PG 15 registers pg_cron
-- as '1.6', while the target image packages it as '1.6.4' with no update
-- path between them.
CREATE EXTENSION IF NOT EXISTS pg_cron;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'upgrade_test_job') THEN
PERFORM cron.unschedule('upgrade_test_job');
END IF;
END $$;
SELECT cron.schedule('upgrade_test_job', '5 4 * * *', 'SELECT 1');
EOSQL
pre_count=$(run_sql -A -t -c "SELECT count(*) FROM public._upgrade_test;" | tr -d '[:space:]')
pre_checksum=$(run_sql -A -t -c "SELECT md5(string_agg(name || value::text, ',' ORDER BY id)) FROM public._upgrade_test;" | tr -d '[:space:]')
echo " Rows: $pre_count"
echo " Checksum: $pre_checksum"
# --- Seed a Vault secret ---------------------------------------------------
# Verifies the pgsodium root key survives the volume swap/chown AND that Vault
# secrets still decrypt on Postgres 17. For legacy (key_id-based) secrets this
# also exercises complete.sh's pgsodium->Vault re-encryption; on a stock
# self-hosted stack the secret is already pgsodium-less, so this confirms the
# round-trip and the post-upgrade invariant (key_id IS NULL).
VAULT_SECRET_NAME="upgrade_test_secret"
VAULT_SECRET_VALUE="upgrade-test-secret-value-42"
vault_available="f"
if [ "$(run_sql -A -t -c "SELECT EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'supabase_vault');" | tr -d '[:space:]')" = "t" ]; then
echo ""
echo "Seeding Vault secret on Postgres 15..."
run_sql <<EOSQL
CREATE EXTENSION IF NOT EXISTS supabase_vault;
-- Pre-clean so a re-run after a mid-run failure (where the trailing cleanup
-- never executed) does not abort on the unique (name) index.
DELETE FROM vault.secrets WHERE name = '${VAULT_SECRET_NAME}';
SELECT vault.create_secret('${VAULT_SECRET_VALUE}', '${VAULT_SECRET_NAME}', 'pg17 upgrade test');
EOSQL
pre_secret=$(run_sql -A -t -c "SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name = '${VAULT_SECRET_NAME}';" | tr -d '\n')
if [ "$pre_secret" = "$VAULT_SECRET_VALUE" ]; then
echo " Seeded '${VAULT_SECRET_NAME}' (decrypts correctly on PG 15)"
else
echo " Warning: seeded secret did not decrypt as expected on PG 15" >&2
fi
vault_available="t"
else
echo ""
echo "Skipping Vault seed: supabase_vault extension not available."
fi
# --- Run upgrade -----------------------------------------------------------
echo ""
echo "Running upgrade script..."
echo ""
bash utils/upgrade-pg17.sh --yes
echo ""
# --- Verify with pgTAP ----------------------------------------------------
echo "Running pgTAP verification..."
echo ""
# Optional Vault assertions, only when a secret was seeded above.
vault_plan=0
vault_tests=""
if [ "$vault_available" = "t" ]; then
vault_plan=3
vault_tests=$(cat <<EOSQL
-- Vault secret survived the upgrade and still decrypts (root key intact).
SELECT ok(
EXISTS (SELECT 1 FROM vault.secrets WHERE name = '${VAULT_SECRET_NAME}'),
'Vault secret survived upgrade'
);
SELECT is(
(SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name = '${VAULT_SECRET_NAME}'),
'${VAULT_SECRET_VALUE}',
'Vault secret still decrypts to original plaintext after upgrade'
);
SELECT ok(
(SELECT key_id IS NULL FROM vault.secrets WHERE name = '${VAULT_SECRET_NAME}'),
'Vault secret is in pgsodium-less format (key_id IS NULL) after upgrade'
);
EOSQL
)
fi
total_plan=$((16 + vault_plan))
# Use a non-quoted heredoc so $pre_count, $pre_checksum, $total_plan and
# $vault_tests are interpolated.
run_sql <<EOSQL
CREATE EXTENSION IF NOT EXISTS pgtap;
SELECT plan(${total_plan});
-- Version
SELECT ok(version() LIKE 'PostgreSQL 17%', 'Running Postgres 17');
-- Table
SELECT has_table('public', '_upgrade_test', 'Test table survived upgrade');
-- Row count
SELECT is(
(SELECT count(*)::int FROM public._upgrade_test),
${pre_count},
'Row count preserved'
);
-- Data checksum
SELECT is(
(SELECT md5(string_agg(name || value::text, ',' ORDER BY id)) FROM public._upgrade_test),
'${pre_checksum}',
'Data checksum matches'
);
-- Index
SELECT has_index('public', '_upgrade_test', '_upgrade_test_name_idx', 'Index survived upgrade');
-- Function
SELECT has_function('public', '_upgrade_test_fn', ARRAY['integer'], 'Function survived upgrade');
SELECT is(public._upgrade_test_fn(21), 42, 'Function returns correct result');
-- Core extensions
-- Note: pgsodium may not be created as an extension in the postgres database
-- on default self-hosted installs (it's loaded via shared_preload_libraries
-- but the CREATE EXTENSION is conditional in the init migration).
SELECT ok(
EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_net'),
'pg_net extension exists'
);
-- Roles
SELECT ok(
EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'supabase_etl_admin'),
'supabase_etl_admin role exists'
);
SELECT ok(
EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'supabase_read_only_user'),
'supabase_read_only_user role exists'
);
-- postgres is not superuser
SELECT ok(
NOT (SELECT rolsuper FROM pg_roles WHERE rolname = 'postgres'),
'postgres role is not superuser'
);
-- pg_cron: registered as 1.6 on PG 15; the upgrade must reconcile the version
-- label to the target's packaged 1.6.4 and preserve scheduled jobs.
SELECT ok(
EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_cron'),
'pg_cron extension exists'
);
SELECT is(
(SELECT extversion FROM pg_extension WHERE extname = 'pg_cron'),
'1.6.4',
'pg_cron version label reconciled to 1.6.4'
);
SELECT ok(
EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'upgrade_test_job'),
'pg_cron scheduled job survived the upgrade'
);
-- New predefined role (initdb-only migration; the target image's supautils.conf
-- references it, so it must exist on an upgraded instance).
SELECT ok(
EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'supabase_privileged_role'),
'supabase_privileged_role exists'
);
-- Extension version reconcile: complete.sh ran with .063 binaries, so the
-- catalog should be reconciled to the target image's default version.
SELECT is(
(SELECT extversion FROM pg_extension WHERE extname = 'pg_net'),
(SELECT default_version FROM pg_available_extensions WHERE name = 'pg_net'),
'pg_net version reconciled to image default'
);
${vault_tests}
SELECT * FROM finish(true);
EOSQL
# --- Check service connectivity --------------------------------------------
echo ""
echo "Checking service connectivity..."
pass=0
fail=0
check() {
test_name="$1"
expected="$2"
actual="$3"
if [ "$actual" = "$expected" ]; then
echo " PASS: $test_name"
pass=$((pass + 1))
else
echo " FAIL: $test_name (expected $expected, got $actual)"
fail=$((fail + 1))
fi
}
# PostgREST
if [ -n "$anon_key" ]; then
rest_status=$(curl -s -o /dev/null -w "%{http_code}" \
-H "apikey: $anon_key" \
-H "Authorization: Bearer $anon_key" \
"http://localhost:8000/rest/v1/_upgrade_test?select=count" 2>/dev/null) || rest_status="000"
check "PostgREST connectivity" "200" "$rest_status"
fi
# Auth health (needs apikey header through the API gateway)
if [ -n "$anon_key" ]; then
auth_status=$(curl -s -o /dev/null -w "%{http_code}" \
-H "apikey: $anon_key" \
"http://localhost:8000/auth/v1/health" 2>/dev/null) || auth_status="000"
check "Auth service health" "200" "$auth_status"
fi
echo ""
echo " Services: $pass passed, $fail failed"
# --- Clean up test artifacts ----------------------------------------------
echo ""
echo "Cleaning up test artifacts..."
run_sql <<'EOSQL' || true
DROP FUNCTION IF EXISTS public._upgrade_test_fn(int);
DROP TABLE IF EXISTS public._upgrade_test;
DROP EXTENSION IF EXISTS pgtap;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'upgrade_test_job') THEN
PERFORM cron.unschedule('upgrade_test_job');
END IF;
END $$;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'supabase_vault') THEN
DELETE FROM vault.secrets WHERE name = 'upgrade_test_secret';
END IF;
END $$;
EOSQL
# --- Summary --------------------------------------------------------------
echo ""
if [ "$fail" -gt 0 ]; then
echo "=== SOME TESTS FAILED ==="
exit 1
fi
echo "=== Upgrade test passed ==="
echo ""
echo "To reclaim disk space:"
echo " rm -rf ./volumes/db/data.bak.pg15 ./volumes/db/pg17_upgrade_bin_*.tar.gz"
echo ""
@@ -0,0 +1,371 @@
#!/bin/sh
#
# Test S3 backend directly, bypassing the Storage service.
#
# Validates that the S3-compatible backend (MinIO, RustFS, etc.) handles
# all S3 operations that Storage relies on. Uses the aws cli so the test
# is backend-agnostic - no vendor-specific tools required.
#
# Usage:
# sh test-s3-backend.sh # Uses localhost:9100
# sh test-s3-backend.sh <backend_url> # Custom URL
#
# Prerequisites:
# - Running self-hosted Supabase instance with S3 backend + test override:
# docker compose -f docker-compose.yml -f docker-compose.s3.yml \
# -f ./tests/docker-compose.s3.test.yml up -d
# - .env file with MINIO_ROOT_USER, MINIO_ROOT_PASSWORD, GLOBAL_S3_BUCKET
# - aws cli v2
# - jq (for JSON parsing)
#
set -e
cleanup_files=""
trap 'rm -f $cleanup_files' EXIT
BACKEND_URL="${1:-http://localhost:${S3_BACKEND_TEST_PORT:-9100}}"
if [ ! -f .env ]; then
echo "Error: .env file not found. Run from the project directory."
exit 1
fi
for cmd in aws jq; do
if ! command -v $cmd >/dev/null 2>&1; then
echo "Error: $cmd not found."
exit 1
fi
done
# Read backend credentials from .env
BACKEND_ACCESS_KEY=$(grep '^MINIO_ROOT_USER=' .env | cut -d= -f2-)
BACKEND_SECRET_KEY=$(grep '^MINIO_ROOT_PASSWORD=' .env | cut -d= -f2-)
GLOBAL_S3_BUCKET=$(grep '^GLOBAL_S3_BUCKET=' .env | cut -d= -f2-)
REGION=$(grep '^REGION=' .env | cut -d= -f2-)
REGION="${REGION:-us-east-1}"
if [ -z "$BACKEND_ACCESS_KEY" ] || [ -z "$BACKEND_SECRET_KEY" ]; then
echo "Error: MINIO_ROOT_USER or MINIO_ROOT_PASSWORD not set in .env"
exit 1
fi
pass=0
fail=0
check() {
test_name="$1"
expected="$2"
actual="$3"
if [ "$actual" = "$expected" ]; then
echo " PASS: $test_name"
pass=$((pass + 1))
else
echo " FAIL: $test_name (expected $expected, got $actual)"
fail=$((fail + 1))
fi
}
# Wrapper for aws commands against the backend directly.
#
# Always exits 0: under `set -e` a failing aws call would kill the suite on the
# spot, so the check never records a FAIL and no summary is printed. The aws
# error is echoed to stderr so a FAIL stays explainable even where the caller
# discards stdout.
s3() {
s3_out=$(AWS_ACCESS_KEY_ID="$BACKEND_ACCESS_KEY" \
AWS_SECRET_ACCESS_KEY="$BACKEND_SECRET_KEY" \
aws "$@" --endpoint-url "$BACKEND_URL" --region "$REGION" 2>&1) ||
echo " aws $1 $2 failed: $(printf '%s' "$s3_out" | tail -n 1)" >&2
printf '%s\n' "$s3_out"
}
# Wrapper for jq that yields empty output instead of aborting the suite when
# the payload is not JSON (e.g. an S3 error document) and jq exits non-zero.
jq_r() {
jq -r "$@" 2>/dev/null || true
}
bucket_name="backend-test-$$"
echo ""
echo "=== S3 backend test against $BACKEND_URL ==="
echo ""
# ---------------------------------------------
# 1. ListBuckets (backend reachable)
# ---------------------------------------------
echo "--- Connectivity ---"
list_output=$(s3 s3api list-buckets --output json)
list_ok=$(echo "$list_output" | jq_r 'if .Buckets then "true" else "false" end')
check "Backend reachable (ListBuckets)" "true" "$list_ok"
if [ "$list_ok" != "true" ]; then
echo " Cannot reach backend. Is the test override running?"
echo " Response: $list_output"
echo ""
echo "=== Results: $pass passed, $fail failed ==="
exit 1
fi
# ---------------------------------------------
# 2. Verify GLOBAL_S3_BUCKET exists
# ---------------------------------------------
echo ""
echo "--- Storage bucket ---"
if [ -n "$GLOBAL_S3_BUCKET" ]; then
storage_bucket_exists=$(s3 s3api list-buckets --output json | \
jq_r --arg name "$GLOBAL_S3_BUCKET" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end')
check "GLOBAL_S3_BUCKET ($GLOBAL_S3_BUCKET) exists" "true" "$storage_bucket_exists"
else
echo " SKIP: GLOBAL_S3_BUCKET not set"
fi
# ---------------------------------------------
# 3. CreateBucket
# ---------------------------------------------
echo ""
echo "--- CreateBucket ---"
s3 s3api create-bucket --bucket "$bucket_name" --output json >/dev/null
# Verify create succeeded
create_found=$(s3 s3api list-buckets --output json | \
jq_r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end')
check "CreateBucket" "true" "$create_found"
if [ "$create_found" != "true" ]; then
echo " Cannot continue without a bucket. Aborting."
echo ""
echo "=== Results: $pass passed, $fail failed ==="
exit 1
fi
# Verify in ListBuckets (separate call)
bucket_found=$(s3 s3api list-buckets --output json | \
jq_r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end')
check "Bucket visible in ListBuckets" "true" "$bucket_found"
# ---------------------------------------------
# 4. PutObject
# ---------------------------------------------
echo ""
echo "--- PutObject ---"
tmpfile=$(mktemp); cleanup_files="$cleanup_files $tmpfile"
echo "hello from backend test" > "$tmpfile"
put_output=$(s3 s3 cp "$tmpfile" "s3://$bucket_name/test-file.txt")
put_ok=$(echo "$put_output" | grep -q "upload:" && echo "true" || echo "false")
check "PutObject" "true" "$put_ok"
# ---------------------------------------------
# 5. ListObjectsV2
# ---------------------------------------------
echo ""
echo "--- ListObjectsV2 ---"
list_objects=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --output json)
object_found=$(echo "$list_objects" | \
jq_r '[.Contents[]? | .Key] | if any(. == "test-file.txt") then "true" else "false" end')
check "Object found in ListObjectsV2" "true" "$object_found"
# ---------------------------------------------
# 6. HeadObject
# ---------------------------------------------
echo ""
echo "--- HeadObject ---"
head_output=$(s3 s3api head-object --bucket "$bucket_name" --key "test-file.txt" --output json)
head_size=$(echo "$head_output" | jq_r '.ContentLength // 0')
original_size=$(wc -c < "$tmpfile" | tr -d ' ')
check "HeadObject returns correct size" "$original_size" "$head_size"
rm -f "$tmpfile"
# ---------------------------------------------
# 7. GetObject + content verify
# ---------------------------------------------
echo ""
echo "--- GetObject ---"
download_file=$(mktemp); cleanup_files="$cleanup_files $download_file"
s3 s3 cp "s3://$bucket_name/test-file.txt" "$download_file" >/dev/null
downloaded_content=$(cat "$download_file")
check "GetObject content matches" "hello from backend test" "$downloaded_content"
rm -f "$download_file"
# ---------------------------------------------
# 8. CopyObject
# ---------------------------------------------
echo ""
echo "--- CopyObject ---"
copy_output=$(s3 s3 cp "s3://$bucket_name/test-file.txt" "s3://$bucket_name/test-copy.txt")
copy_ok=$(echo "$copy_output" | grep -q "copy:" && echo "true" || echo "false")
check "CopyObject" "true" "$copy_ok"
copy_download=$(mktemp); cleanup_files="$cleanup_files $copy_download"
s3 s3 cp "s3://$bucket_name/test-copy.txt" "$copy_download" >/dev/null
check "Copied object content matches" "hello from backend test" "$(cat "$copy_download")"
rm -f "$copy_download"
# ---------------------------------------------
# 9. DeleteObject
# ---------------------------------------------
echo ""
echo "--- DeleteObject ---"
s3 s3 rm "s3://$bucket_name/test-copy.txt" >/dev/null
list_after_delete=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --output json)
copy_gone=$(echo "$list_after_delete" | \
jq_r '[.Contents[]? | .Key] | if any(. == "test-copy.txt") then "false" else "true" end')
check "Deleted object no longer listed" "true" "$copy_gone"
# ---------------------------------------------
# 10. Multipart upload (7MB)
# ---------------------------------------------
echo ""
echo "--- Multipart upload (7MB) ---"
large_file=$(mktemp); cleanup_files="$cleanup_files $large_file"
dd if=/dev/urandom of="$large_file" bs=1048576 count=7 2>/dev/null
large_size=$(wc -c < "$large_file" | tr -d ' ')
large_put=$(s3 s3 cp "$large_file" "s3://$bucket_name/large-file.bin")
large_ok=$(echo "$large_put" | grep -q "upload:" && echo "true" || echo "false")
check "Multipart upload (7MB)" "true" "$large_ok"
large_head=$(s3 s3api head-object --bucket "$bucket_name" --key "large-file.bin" --output json)
remote_size=$(echo "$large_head" | jq_r '.ContentLength // 0')
check "Multipart size matches ($large_size bytes)" "$large_size" "$remote_size"
large_download=$(mktemp); cleanup_files="$cleanup_files $large_download"
s3 s3 cp "s3://$bucket_name/large-file.bin" "$large_download" >/dev/null
download_size=$(wc -c < "$large_download" | tr -d ' ')
check "Multipart download size matches" "$large_size" "$download_size"
rm -f "$large_file" "$large_download"
# ---------------------------------------------
# 11. DeleteObjects (batch delete)
# ---------------------------------------------
echo ""
echo "--- DeleteObjects (batch) ---"
batch_file=$(mktemp); cleanup_files="$cleanup_files $batch_file"
echo "batch-a" > "$batch_file"
s3 s3 cp "$batch_file" "s3://$bucket_name/batch-a.txt" >/dev/null
echo "batch-b" > "$batch_file"
s3 s3 cp "$batch_file" "s3://$bucket_name/batch-b.txt" >/dev/null
echo "batch-c" > "$batch_file"
s3 s3 cp "$batch_file" "s3://$bucket_name/batch-c.txt" >/dev/null
rm -f "$batch_file"
delete_objects_output=$(s3 s3api delete-objects --bucket "$bucket_name" \
--delete '{"Objects":[{"Key":"batch-a.txt"},{"Key":"batch-b.txt"},{"Key":"batch-c.txt"}]}' \
--output json)
deleted_count=$(echo "$delete_objects_output" | jq_r '.Deleted | length')
check "DeleteObjects removed 3 objects" "3" "$deleted_count"
# Verify all gone
batch_list=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --prefix "batch-" --output json)
batch_remaining=$(echo "$batch_list" | jq_r '[.Contents[]?] | length')
check "Batch-deleted objects gone" "0" "$batch_remaining"
# ---------------------------------------------
# 12. Presigned URLs
# ---------------------------------------------
echo ""
echo "--- Presigned URLs ---"
presign_file=$(mktemp); cleanup_files="$cleanup_files $presign_file"
echo "presigned content test" > "$presign_file"
s3 s3 cp "$presign_file" "s3://$bucket_name/presign-test.txt" >/dev/null
rm -f "$presign_file"
presigned_url=$(s3 s3 presign "s3://$bucket_name/presign-test.txt")
presign_body=$(curl -s "$presigned_url" || true)
check "Presigned URL returns correct content" "presigned content test" "$presign_body"
# ---------------------------------------------
# 13. Conditional request (IfNoneMatch)
# ---------------------------------------------
echo ""
echo "--- Conditional request (IfNoneMatch) ---"
# --if-none-match requires aws cli v2.22+ ; skip if not supported
if aws s3api put-object help 2>&1 | grep -q 'if-none-match'; then
cond_file=$(mktemp); cleanup_files="$cleanup_files $cond_file"
echo "conditional test" > "$cond_file"
# First put should succeed (key doesn't exist)
first_put_err=$(s3 s3api put-object --bucket "$bucket_name" --key "cond-test.txt" \
--body "$cond_file" --if-none-match '*' --output json 2>&1 || true)
first_put_ok=$(echo "$first_put_err" | grep -qi "error\|denied\|PreconditionFailed" && echo "false" || echo "true")
check "IfNoneMatch put (new key) succeeds" "true" "$first_put_ok"
# Second put should fail with PreconditionFailed (key exists)
cond_err=$(s3 s3api put-object --bucket "$bucket_name" --key "cond-test.txt" \
--body "$cond_file" --if-none-match '*' --output json 2>&1 || true)
cond_rejected=$(echo "$cond_err" | grep -qi "PreconditionFailed" && echo "true" || echo "false")
check "IfNoneMatch put (existing key) rejected" "true" "$cond_rejected"
rm -f "$cond_file"
else
echo " SKIP: aws cli does not support --if-none-match (requires v2.22+)"
fi
# ---------------------------------------------
# 14. Range request (partial GetObject)
# ---------------------------------------------
echo ""
echo "--- Range request ---"
range_file=$(mktemp); cleanup_files="$cleanup_files $range_file"
echo "hello range test content" > "$range_file"
s3 s3 cp "$range_file" "s3://$bucket_name/range-test.txt" >/dev/null
rm -f "$range_file"
range_download=$(mktemp); cleanup_files="$cleanup_files $range_download"
s3 s3api get-object --bucket "$bucket_name" --key "range-test.txt" \
--range "bytes=0-4" "$range_download" --output json >/dev/null
range_content=$(cat "$range_download")
check "Range request returns partial content" "hello" "$range_content"
rm -f "$range_download"
# ---------------------------------------------
# 15. Authentication
# ---------------------------------------------
echo ""
echo "--- Authentication ---"
bad_output=$(AWS_ACCESS_KEY_ID="invalid-key" \
AWS_SECRET_ACCESS_KEY="invalid-secret" \
aws s3api list-buckets \
--endpoint-url "$BACKEND_URL" \
--region "$REGION" \
--output json 2>&1 || true)
bad_ok=$(echo "$bad_output" | grep -qi "denied\|invalid\|error\|403\|401" && echo "true" || echo "false")
check "Invalid credentials rejected" "true" "$bad_ok"
# ---------------------------------------------
# 16. Cleanup
# ---------------------------------------------
echo ""
echo "--- Cleanup ---"
s3 s3 rm "s3://$bucket_name/" --recursive >/dev/null
s3 s3api delete-bucket --bucket "$bucket_name" >/dev/null
bucket_gone=$(s3 s3api list-buckets --output json | \
jq_r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "false" else "true" end')
check "Test bucket deleted" "true" "$bucket_gone"
# ---------------------------------------------
# Summary
# ---------------------------------------------
echo ""
echo "=== Results: $pass passed, $fail failed ==="
echo ""
if [ "$fail" -gt 0 ]; then
exit 1
fi
+301
View File
@@ -0,0 +1,301 @@
#!/bin/sh
#
# Test S3 protocol endpoint for self-hosted Supabase Storage.
#
# Verifies that the S3-compatible endpoint at /storage/v1/s3 works with
# standard S3 clients - the same way end users interact with it via
# aws cli, rclone, or other S3-compatible tools.
#
# Usage:
# sh test-s3.sh # Uses http://localhost:8000
# sh test-s3.sh <base_url> # Custom URL
#
# Prerequisites:
# - Running self-hosted Supabase instance with S3 enabled:
# docker compose -f docker-compose.yml -f docker-compose.s3.yml up -d
# - .env file with S3_PROTOCOL_ACCESS_KEY_ID, S3_PROTOCOL_ACCESS_KEY_SECRET, REGION
# - aws cli v2 (for S3 operations)
# - jq (for JSON parsing)
#
set -e
cleanup_files=""
trap 'rm -f $cleanup_files' EXIT
BASE_URL="${1:-http://localhost:8000}"
S3_ENDPOINT="$BASE_URL/storage/v1/s3"
if [ ! -f .env ]; then
echo "Error: .env file not found. Run from the project directory."
exit 1
fi
for cmd in aws jq; do
if ! command -v $cmd >/dev/null 2>&1; then
echo "Error: $cmd not found."
exit 1
fi
done
# Read keys from .env
S3_ACCESS_KEY=$(grep '^S3_PROTOCOL_ACCESS_KEY_ID=' .env | cut -d= -f2-)
S3_SECRET_KEY=$(grep '^S3_PROTOCOL_ACCESS_KEY_SECRET=' .env | cut -d= -f2-)
REGION=$(grep '^REGION=' .env | cut -d= -f2-)
if [ -z "$S3_ACCESS_KEY" ] || [ -z "$S3_SECRET_KEY" ]; then
echo "Error: S3_PROTOCOL_ACCESS_KEY_ID or S3_PROTOCOL_ACCESS_KEY_SECRET not set in .env"
exit 1
fi
pass=0
fail=0
check() {
test_name="$1"
expected="$2"
actual="$3"
if [ "$actual" = "$expected" ]; then
echo " PASS: $test_name"
pass=$((pass + 1))
else
echo " FAIL: $test_name (expected $expected, got $actual)"
fail=$((fail + 1))
fi
}
# Wrapper for aws s3/s3api commands with correct endpoint and credentials.
#
# Always exits 0: under `set -e` a failing aws call would kill the suite on the
# spot, so the check never records a FAIL and no summary is printed. The aws
# error is echoed to stderr so a FAIL stays explainable even where the caller
# discards stdout.
s3() {
s3_out=$(AWS_ACCESS_KEY_ID="$S3_ACCESS_KEY" \
AWS_SECRET_ACCESS_KEY="$S3_SECRET_KEY" \
aws "$@" --endpoint-url "$S3_ENDPOINT" --region "$REGION" 2>&1) ||
echo " aws $1 $2 failed: $(printf '%s' "$s3_out" | tail -n 1)" >&2
printf '%s\n' "$s3_out"
}
# Wrapper for jq that yields empty output instead of aborting the suite when
# the payload is not JSON (e.g. an S3 error document) and jq exits non-zero.
jq_r() {
jq -r "$@" 2>/dev/null || true
}
bucket_name="s3-test-$$"
echo ""
echo "=== S3 protocol test against $BASE_URL ==="
echo ""
# ---------------------------------------------
# 1. S3 ListBuckets
# ---------------------------------------------
echo "--- S3 ListBuckets ---"
list_output=$(s3 s3api list-buckets --output json)
list_ok=$(echo "$list_output" | jq_r 'if .Buckets then "true" else "false" end')
check "ListBuckets returns valid response" "true" "$list_ok"
# ---------------------------------------------
# 2. S3 CreateBucket
# ---------------------------------------------
echo ""
echo "--- S3 CreateBucket ---"
s3 s3api create-bucket --bucket "$bucket_name" --output json >/dev/null
# Verify create succeeded
create_found=$(s3 s3api list-buckets --output json | \
jq_r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end')
check "CreateBucket" "true" "$create_found"
if [ "$create_found" != "true" ]; then
echo " Cannot continue without a bucket. Aborting."
echo ""
echo "=== Results: $pass passed, $fail failed ==="
exit 1
fi
# Verify bucket appears in ListBuckets (separate call)
s3_bucket_found=$(s3 s3api list-buckets --output json | \
jq_r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "true" else "false" end')
check "Bucket visible in ListBuckets" "true" "$s3_bucket_found"
# ---------------------------------------------
# 3. S3 PutObject
# ---------------------------------------------
echo ""
echo "--- S3 PutObject ---"
tmpfile=$(mktemp); cleanup_files="$cleanup_files $tmpfile"
echo "hello from s3 upload test" > "$tmpfile"
put_output=$(s3 s3 cp "$tmpfile" "s3://$bucket_name/s3-uploaded.txt")
put_ok=$(echo "$put_output" | grep -q "upload:" && echo "true" || echo "false")
check "PutObject upload" "true" "$put_ok"
# ---------------------------------------------
# 4. S3 ListObjectsV2
# ---------------------------------------------
echo ""
echo "--- S3 ListObjectsV2 ---"
list_objects=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --output json)
object_found=$(echo "$list_objects" | \
jq_r '[.Contents[]? | .Key] | if any(. == "s3-uploaded.txt") then "true" else "false" end')
check "Object found in ListObjectsV2" "true" "$object_found"
# ---------------------------------------------
# 5. S3 HeadObject
# ---------------------------------------------
echo ""
echo "--- S3 HeadObject ---"
head_output=$(s3 s3api head-object --bucket "$bucket_name" --key "s3-uploaded.txt" --output json)
head_size=$(echo "$head_output" | jq_r '.ContentLength // 0')
original_size=$(wc -c < "$tmpfile" | tr -d ' ')
check "HeadObject returns correct size" "$original_size" "$head_size"
rm -f "$tmpfile"
# ---------------------------------------------
# 6. S3 GetObject (download) + content verify
# ---------------------------------------------
echo ""
echo "--- S3 GetObject ---"
download_file=$(mktemp); cleanup_files="$cleanup_files $download_file"
s3 s3 cp "s3://$bucket_name/s3-uploaded.txt" "$download_file" >/dev/null
downloaded_content=$(cat "$download_file")
check "GetObject content matches" "hello from s3 upload test" "$downloaded_content"
rm -f "$download_file"
# ---------------------------------------------
# 7. S3 CopyObject (server-side copy)
# ---------------------------------------------
echo ""
echo "--- S3 CopyObject ---"
copy_output=$(s3 s3 cp "s3://$bucket_name/s3-uploaded.txt" "s3://$bucket_name/s3-copied.txt")
copy_ok=$(echo "$copy_output" | grep -q "copy:" && echo "true" || echo "false")
check "CopyObject" "true" "$copy_ok"
# Verify copied content
copy_download=$(mktemp); cleanup_files="$cleanup_files $copy_download"
s3 s3 cp "s3://$bucket_name/s3-copied.txt" "$copy_download" >/dev/null
check "Copied object content matches" "hello from s3 upload test" "$(cat "$copy_download")"
rm -f "$copy_download"
# ---------------------------------------------
# 8. S3 DeleteObject
# ---------------------------------------------
echo ""
echo "--- S3 DeleteObject ---"
s3 s3 rm "s3://$bucket_name/s3-copied.txt" >/dev/null
# Verify object is gone
list_after_delete=$(s3 s3api list-objects-v2 --bucket "$bucket_name" --output json)
copied_gone=$(echo "$list_after_delete" | \
jq_r '[.Contents[]? | .Key] | if any(. == "s3-copied.txt") then "false" else "true" end')
check "Deleted object no longer listed" "true" "$copied_gone"
# ---------------------------------------------
# 9. Multipart upload (>5MB triggers multipart)
# ---------------------------------------------
echo ""
echo "--- S3 multipart upload (7MB) ---"
large_file=$(mktemp); cleanup_files="$cleanup_files $large_file"
dd if=/dev/urandom of="$large_file" bs=1048576 count=7 2>/dev/null
large_size=$(wc -c < "$large_file" | tr -d ' ')
large_put=$(s3 s3 cp "$large_file" "s3://$bucket_name/large-file.bin")
large_ok=$(echo "$large_put" | grep -q "upload:" && echo "true" || echo "false")
check "Multipart upload (7MB)" "true" "$large_ok"
# Verify size via HeadObject
large_head=$(s3 s3api head-object --bucket "$bucket_name" --key "large-file.bin" --output json)
remote_size=$(echo "$large_head" | jq_r '.ContentLength // 0')
check "Multipart upload size matches ($large_size bytes)" "$large_size" "$remote_size"
# Download and verify size
large_download=$(mktemp); cleanup_files="$cleanup_files $large_download"
s3 s3 cp "s3://$bucket_name/large-file.bin" "$large_download" >/dev/null
download_size=$(wc -c < "$large_download" | tr -d ' ')
check "Multipart download size matches" "$large_size" "$download_size"
rm -f "$large_file" "$large_download"
# ---------------------------------------------
# 10. Range request (partial GetObject)
# ---------------------------------------------
echo ""
echo "--- Range request ---"
range_file=$(mktemp); cleanup_files="$cleanup_files $range_file"
echo "hello range test content" > "$range_file"
s3 s3 cp "$range_file" "s3://$bucket_name/range-test.txt" >/dev/null
rm -f "$range_file"
range_download=$(mktemp); cleanup_files="$cleanup_files $range_download"
s3 s3api get-object --bucket "$bucket_name" --key "range-test.txt" \
--range "bytes=0-4" "$range_download" --output json >/dev/null
range_content=$(cat "$range_download")
check "Range request returns partial content" "hello" "$range_content"
rm -f "$range_download"
# ---------------------------------------------
# 11. Presigned URLs
# ---------------------------------------------
# Storage supports S3 presigned URLs (query-parameter auth).
echo ""
echo "--- Presigned URLs ---"
presign_file=$(mktemp); cleanup_files="$cleanup_files $presign_file"
echo "presigned content test" > "$presign_file"
s3 s3 cp "$presign_file" "s3://$bucket_name/presign-test.txt" >/dev/null
rm -f "$presign_file"
presigned_url=$(s3 s3 presign "s3://$bucket_name/presign-test.txt")
presign_body=$(curl -s "$presigned_url" || true)
check "Presigned URL returns correct content" "presigned content test" "$presign_body"
# ---------------------------------------------
# 12. Authentication
# ---------------------------------------------
echo ""
echo "--- Authentication ---"
bad_output=$(AWS_ACCESS_KEY_ID="invalid-key" \
AWS_SECRET_ACCESS_KEY="invalid-secret" \
aws s3api list-buckets \
--endpoint-url "$S3_ENDPOINT" \
--region "$REGION" \
--output json 2>&1 || true)
bad_ok=$(echo "$bad_output" | grep -qi "denied\|invalid\|error\|403\|401" && echo "true" || echo "false")
check "Invalid credentials rejected" "true" "$bad_ok"
# ---------------------------------------------
# 13. Cleanup
# ---------------------------------------------
echo ""
echo "--- Cleanup ---"
s3 s3 rm "s3://$bucket_name/" --recursive >/dev/null
s3 s3api delete-bucket --bucket "$bucket_name" >/dev/null
# Verify bucket is gone
bucket_gone=$(s3 s3api list-buckets --output json | \
jq_r --arg name "$bucket_name" '[.Buckets[] | .Name] | if any(. == $name) then "false" else "true" end')
check "Bucket deleted via S3" "true" "$bucket_gone"
# ---------------------------------------------
# Summary
# ---------------------------------------------
echo ""
echo "=== Results: $pass passed, $fail failed ==="
echo ""
if [ "$fail" -gt 0 ]; then
exit 1
fi
@@ -0,0 +1,543 @@
#!/bin/sh
#
# Smoke test for self-hosted Supabase - verifies core functionality end-to-end.
#
# Usage:
# sh test-self-hosted.sh # Uses http://localhost:8000
# sh test-self-hosted.sh <base_url> # Custom URL
#
# Prerequisites:
# - Running self-hosted Supabase instance
# - .env file with keys configured
# - jq (for JSON parsing)
# - sha256sum or shasum (for file integrity checks)
#
set -e
cleanup_files=""
trap 'rm -f $cleanup_files' EXIT
BASE_URL="${1:-http://localhost:8000}"
if [ ! -f .env ]; then
echo "Error: .env file not found. Run from the project directory."
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "Error: jq not found. Install it: https://jqlang.github.io/jq/download/"
exit 1
fi
# Portable file hash: prefers sha256sum (Linux), falls back to shasum (macOS)
if command -v sha256sum >/dev/null 2>&1; then
file_hash() { sha256sum "$1" | awk '{print $1}'; }
elif command -v shasum >/dev/null 2>&1; then
file_hash() { shasum -a 256 "$1" | awk '{print $1}'; }
else
echo "Error: sha256sum or shasum not found."
exit 1
fi
# Read keys from .env
ANON_KEY=$(grep '^ANON_KEY=' .env | cut -d= -f2-)
SERVICE_ROLE_KEY=$(grep '^SERVICE_ROLE_KEY=' .env | cut -d= -f2-)
SUPABASE_PUBLISHABLE_KEY=$(grep '^SUPABASE_PUBLISHABLE_KEY=' .env | cut -d= -f2-)
SUPABASE_SECRET_KEY=$(grep '^SUPABASE_SECRET_KEY=' .env | cut -d= -f2-)
DASHBOARD_USERNAME=$(grep '^DASHBOARD_USERNAME=' .env | cut -d= -f2-)
DASHBOARD_PASSWORD=$(grep '^DASHBOARD_PASSWORD=' .env | cut -d= -f2-)
pass=0
fail=0
check() {
test_name="$1"
expected="$2"
actual="$3"
if [ "$actual" = "$expected" ]; then
echo " PASS: $test_name"
pass=$((pass + 1))
else
echo " FAIL: $test_name (expected $expected, got $actual)"
fail=$((fail + 1))
fi
}
http_status() {
url="$1"
shift
curl -s -o /dev/null -w "%{http_code}" "$@" "$url"
}
http_body() {
url="$1"
shift
curl -s "$@" "$url"
}
# Is a compose service running? Falls back to a label lookup so it works
# regardless of which override files are loaded in this shell.
service_running() {
svc="$1"
docker compose ps --services --status running 2>/dev/null | grep -qx "$svc" && return 0
docker ps --filter "label=com.docker.compose.project=${COMPOSE_PROJECT_NAME:-supabase}" \
--filter "label=com.docker.compose.service=$svc" \
--filter "status=running" --quiet | grep -q '.'
}
echo ""
echo "=== Self-hosted smoke test against $BASE_URL ==="
echo ""
# ---------------------------------------------
# 1. Container health (via docker compose)
# ---------------------------------------------
echo "--- Container health ---"
if command -v docker >/dev/null 2>&1; then
container_status=$(docker compose ps --format json 2>/dev/null | jq -rs '
[.[] | select(.State != "running" or (.Health != "" and .Health != "healthy"))]
| (length | tostring) + "|" + ([.[] | .Service + ": State=" + .State + " Health=" + (.Health // "none")] | join(", "))
' 2>/dev/null || echo "?|")
unhealthy="${container_status%%|*}"
container_issues="${container_status#*|}"
if [ "$unhealthy" = "0" ]; then
check "All containers healthy" "0" "$unhealthy"
elif [ "$unhealthy" = "?" ]; then
echo " SKIP: Could not check container health"
else
check "All containers healthy ($container_issues)" "0" "$unhealthy"
fi
else
echo " SKIP: docker not available"
fi
# ---------------------------------------------
# 2. Studio dashboard
# ---------------------------------------------
echo ""
echo "--- Studio dashboard ---"
# Studio may redirect (307/302) after auth - follow redirects
check "Studio accessible with basic auth" "200" \
"$(http_status "$BASE_URL/" -L -u "$DASHBOARD_USERNAME:$DASHBOARD_PASSWORD")"
check "Studio rejects without auth" "401" \
"$(http_status "$BASE_URL/")"
# ---------------------------------------------
# 3. Auth: create user, sign in, get user, public signup, delete
# ---------------------------------------------
echo ""
echo "--- Auth: user lifecycle ---"
test_email="smoke-test-$$@example.com"
test_password="smoke-test-password-123456"
# Create user via admin API (works regardless of email autoconfirm setting)
create_resp=$(http_body "$BASE_URL/auth/v1/admin/users" \
-X POST \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$test_email\",\"password\":\"$test_password\",\"email_confirm\":true}")
user_id=$(echo "$create_resp" | jq -r '.id // empty' 2>/dev/null)
if [ -n "$user_id" ]; then
check "Create user (admin)" "true" "true"
# Sign in via public endpoint
signin_resp=$(http_body "$BASE_URL/auth/v1/token?grant_type=password" \
-H "apikey: $ANON_KEY" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$test_email\",\"password\":\"$test_password\"}")
access_token=$(echo "$signin_resp" | jq -r '.access_token // empty' 2>/dev/null)
if [ -n "$access_token" ]; then
check "Sign in user" "true" "true"
# Get user profile with session JWT
check "Get user profile" "200" \
"$(http_status "$BASE_URL/auth/v1/user" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $access_token")"
else
check "Sign in user" "true" "false"
fi
# Delete user
delete_status=$(http_status "$BASE_URL/auth/v1/admin/users/$user_id" \
-X DELETE \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY")
check "Delete user (admin)" "200" "$delete_status"
else
check "Create user (admin)" "true" "false"
fi
# Public signup (optional - depends on email autoconfirm setting)
signup_email="smoke-signup-$$@example.com"
signup_resp=$(http_body "$BASE_URL/auth/v1/signup" \
-H "apikey: $ANON_KEY" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$signup_email\",\"password\":\"$test_password\"}")
signup_token=$(echo "$signup_resp" | jq -r '.access_token // empty' 2>/dev/null)
signup_user_id=$(echo "$signup_resp" | jq -r '.id // .user.id // empty' 2>/dev/null)
if [ -n "$signup_token" ]; then
check "Public signup (autoconfirm on)" "true" "true"
else
echo " SKIP: Public signup (autoconfirm is off)"
fi
# Clean up signup user if created
if [ -n "$signup_user_id" ]; then
http_status "$BASE_URL/auth/v1/admin/users/$signup_user_id" \
-X DELETE \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" >/dev/null 2>&1
fi
# ---------------------------------------------
# 4. PostgREST: query
# ---------------------------------------------
echo ""
echo "--- PostgREST ---"
check "REST API route with anon key" "403" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $ANON_KEY")"
echo ""
echo "--- PostgREST ---"
check "REST API route with service role key" "200" \
"$(http_status "$BASE_URL/rest/v1/" \
-H "apikey: $SERVICE_ROLE_KEY")"
# ---------------------------------------------
# 5. GraphQL
# ---------------------------------------------
echo ""
echo "--- GraphQL (optional; off by default) ---"
# pg_graphql is OFF by default since the PG17 image (the image drops the extension
# on init, matching platform behavior for new projects), but users may enable it
# (Studio extensions UI / CREATE EXTENSION pg_graphql). Both are valid states. A
# healthy endpoint returns HTTP 200 either way:
# enabled => {"data": ...}
# disabled => {"errors":[{"message":"pg_graphql extension is not enabled."}]}
# Assert the status AND the response shape, so a non-200, non-JSON, or empty body
# (a real gateway/runtime failure) is not silently classified as "disabled".
gql_status=$(http_status "$BASE_URL/graphql/v1" \
-H "apikey: $ANON_KEY" \
-H "Content-Type: application/json" \
-d '{"query":"{ __typename }"}')
gql_body=$(http_body "$BASE_URL/graphql/v1" \
-H "apikey: $ANON_KEY" \
-H "Content-Type: application/json" \
-d '{"query":"{ __typename }"}')
if [ "$gql_status" = "200" ] && echo "$gql_body" | jq -e '.data' >/dev/null 2>&1; then
gql_state="enabled"
elif [ "$gql_status" = "200" ] && echo "$gql_body" | jq -e '.errors' >/dev/null 2>&1; then
gql_state="disabled"
else
gql_state="unhealthy (HTTP $gql_status)"
fi
case "$gql_state" in
enabled | disabled) gql_health="healthy" ;;
*) gql_health="unhealthy" ;;
esac
check "GraphQL endpoint healthy" "healthy" "$gql_health"
echo " (GraphQL is $gql_state)"
# ---------------------------------------------
# 6. Storage: create bucket, upload >6MB file, download, cleanup
# ---------------------------------------------
echo ""
echo "--- Storage: bucket + file lifecycle ---"
bucket_name="smoke-test-$$"
# Create bucket
create_bucket_status=$(http_status "$BASE_URL/storage/v1/bucket" \
-X POST \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "Content-Type: application/json" \
-d "{\"id\":\"$bucket_name\",\"name\":\"$bucket_name\",\"public\":true}")
check "Create bucket" "200" "$create_bucket_status"
if [ "$create_bucket_status" = "200" ]; then
# Generate a ~7MB file
tmpfile=$(mktemp); cleanup_files="$cleanup_files $tmpfile"
dd if=/dev/urandom of="$tmpfile" bs=1048576 count=7 2>/dev/null
# Upload file
upload_status=$(http_status "$BASE_URL/storage/v1/object/$bucket_name/test-large-file.bin" \
-X POST \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$tmpfile")
check "Upload 7MB file" "200" "$upload_status"
# Download file and verify integrity
download_tmp=$(mktemp); cleanup_files="$cleanup_files $download_tmp"
curl -s "$BASE_URL/storage/v1/object/public/$bucket_name/test-large-file.bin" -o "$download_tmp"
original_size=$(wc -c < "$tmpfile" | tr -d ' ')
download_size=$(wc -c < "$download_tmp" | tr -d ' ')
check "Download file (size matches)" "$original_size" "$download_size"
original_hash=$(file_hash "$tmpfile")
download_hash=$(file_hash "$download_tmp")
check "Download file (hash matches)" "$original_hash" "$download_hash"
rm -f "$download_tmp"
rm -f "$tmpfile"
# Signed URL: upload a small file, create signed URL, fetch without auth
sign_upload_status=$(http_status "$BASE_URL/storage/v1/object/$bucket_name/sign-test.txt" \
-X POST \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "Content-Type: text/plain" \
--data-binary "signed url test content")
check "Upload file for signing" "200" "$sign_upload_status"
if [ "$sign_upload_status" = "200" ]; then
sign_resp=$(http_body "$BASE_URL/storage/v1/object/sign/$bucket_name/sign-test.txt" \
-X POST \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "Content-Type: application/json" \
-d '{"expiresIn": 600}')
signed_path=$(echo "$sign_resp" | jq -r '.signedURL // empty' 2>/dev/null)
if [ -n "$signed_path" ]; then
check "Create signed URL" "true" "true"
# Fetch signed URL without any auth headers (goes through the API gateway)
signed_content=$(curl -s "$BASE_URL/storage/v1$signed_path")
check "Fetch signed URL (no auth)" "signed url test content" "$signed_content"
else
check "Create signed URL" "true" "false"
fi
fi
# Delete file
delete_file_status=$(http_status "$BASE_URL/storage/v1/object/$bucket_name/test-large-file.bin" \
-X DELETE \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY")
check "Delete file" "200" "$delete_file_status"
# Delete signed test file
http_status "$BASE_URL/storage/v1/object/$bucket_name/sign-test.txt" \
-X DELETE \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" >/dev/null 2>&1
# Delete bucket
delete_bucket_status=$(http_status "$BASE_URL/storage/v1/bucket/$bucket_name" \
-X DELETE \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY")
check "Delete bucket" "200" "$delete_bucket_status"
fi
# ---------------------------------------------
# 6b. Storage: TUS resumable upload
# ---------------------------------------------
echo ""
echo "--- Storage: TUS resumable upload ---"
tus_bucket="smoke-tus-$$"
tus_bucket_status=$(http_status "$BASE_URL/storage/v1/bucket" \
-X POST \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "Content-Type: application/json" \
-d "{\"id\":\"$tus_bucket\",\"name\":\"$tus_bucket\",\"public\":true}")
check "TUS: create bucket" "200" "$tus_bucket_status"
if [ "$tus_bucket_status" = "200" ]; then
# Generate a ~7MB file (above Studio's 6MB TUS threshold)
tusfile=$(mktemp); cleanup_files="$cleanup_files $tusfile"
dd if=/dev/urandom of="$tusfile" bs=1048576 count=7 2>/dev/null
tus_file_size=$(wc -c < "$tusfile" | tr -d ' ')
tus_chunk_size=$((4 * 1048576)) # 4MB first chunk
# Encode TUS metadata values as base64
tus_bucket_b64=$(printf '%s' "$tus_bucket" | base64)
tus_object_b64=$(printf '%s' "tus-test-file.bin" | base64)
tus_mime_b64=$(printf '%s' "application/octet-stream" | base64)
# 1. Create resumable upload
tus_create_resp=$(curl -s -i -X POST \
"$BASE_URL/storage/v1/upload/resumable" \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "Tus-Resumable: 1.0.0" \
-H "Upload-Length: $tus_file_size" \
-H "Upload-Metadata: bucketName $tus_bucket_b64,objectName $tus_object_b64,contentType $tus_mime_b64" \
-H "x-upsert: true")
tus_create_status=$(echo "$tus_create_resp" | grep -m1 '^HTTP/' | grep -o '[0-9][0-9][0-9]')
# Supabase Storage always returns an absolute Location URL (see generateUrl in storage/src/http/routes/tus/lifecycle.ts)
tus_location=$(echo "$tus_create_resp" | grep -i '^location:' | tr -d '\r' | sed 's/^[Ll]ocation: *//')
check "TUS: create resumable upload" "201" "$tus_create_status"
if [ -n "$tus_location" ]; then
# 2. Upload first chunk (0 to 4MB)
tus_chunk1_status=$(dd if="$tusfile" bs=1048576 count=4 2>/dev/null | \
curl -s -o /dev/null -w "%{http_code}" -X PATCH \
"$tus_location" \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "Tus-Resumable: 1.0.0" \
-H "Upload-Offset: 0" \
-H "Content-Type: application/offset+octet-stream" \
--data-binary @-)
check "TUS: upload chunk 1 (4MB)" "204" "$tus_chunk1_status"
# 3. Upload second chunk (4MB to end)
tus_remaining=$((tus_file_size - tus_chunk_size))
tus_chunk2_status=$(dd if="$tusfile" bs=1048576 skip=4 count=3 2>/dev/null | \
curl -s -o /dev/null -w "%{http_code}" -X PATCH \
"$tus_location" \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H "Tus-Resumable: 1.0.0" \
-H "Upload-Offset: $tus_chunk_size" \
-H "Content-Type: application/offset+octet-stream" \
--data-binary @-)
check "TUS: upload chunk 2 (remaining)" "204" "$tus_chunk2_status"
# 4. Verify download matches original (hash check proves correct chunk reassembly)
tus_download_tmp=$(mktemp); cleanup_files="$cleanup_files $tus_download_tmp"
curl -s "$BASE_URL/storage/v1/object/public/$tus_bucket/tus-test-file.bin" -o "$tus_download_tmp"
tus_download_size=$(wc -c < "$tus_download_tmp" | tr -d ' ')
check "TUS: download size matches" "$tus_file_size" "$tus_download_size"
tus_original_hash=$(file_hash "$tusfile")
tus_download_hash=$(file_hash "$tus_download_tmp")
check "TUS: download hash matches" "$tus_original_hash" "$tus_download_hash"
rm -f "$tus_download_tmp"
fi
rm -f "$tusfile"
# Cleanup: delete file and bucket
http_status "$BASE_URL/storage/v1/object/$tus_bucket/tus-test-file.bin" \
-X DELETE \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY" >/dev/null 2>&1
tus_delete_bucket=$(http_status "$BASE_URL/storage/v1/bucket/$tus_bucket" \
-X DELETE \
-H "apikey: $SERVICE_ROLE_KEY" \
-H "Authorization: Bearer $SERVICE_ROLE_KEY")
check "TUS: delete bucket" "200" "$tus_delete_bucket"
fi
# ---------------------------------------------
# 7. Edge Functions
# ---------------------------------------------
echo ""
echo "--- Edge Functions ---"
fn_resp=$(http_body "$BASE_URL/functions/v1/hello" \
-X POST \
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
-H "Content-Type: application/json" \
-d '{}')
check "Call hello function" '{"message":"Hello from Edge Functions!"}' "$fn_resp"
# ---------------------------------------------
# 8. pg-meta (Studio backend)
# ---------------------------------------------
echo ""
echo "--- pg-meta ---"
check "pg-meta with service_role key" "200" \
"$(http_status "$BASE_URL/pg/schemas" \
-H "apikey: $SERVICE_ROLE_KEY")"
check "pg-meta rejects anon key" "403" \
"$(http_status "$BASE_URL/pg/schemas" \
-H "apikey: $ANON_KEY")"
check "pg-meta rejects no key" "401" \
"$(http_status "$BASE_URL/pg/schemas")"
echo ""
echo "--- MCP (blocked by default) ---"
check "/api/mcp blocked" "403" \
"$(http_status "$BASE_URL/api/mcp")"
check "/mcp blocked" "403" \
"$(http_status "$BASE_URL/mcp")"
# ---------------------------------------------
# 9. Realtime
# ---------------------------------------------
echo ""
echo "--- Realtime ---"
check "Realtime health (ping)" "200" \
"$(http_status "$BASE_URL/realtime/v1/api/ping" \
-H "apikey: $ANON_KEY")"
# Management endpoints must be blocked at the gateway (even with a valid key)
check "Realtime /api/tenants blocked" "403" \
"$(http_status "$BASE_URL/realtime/v1/api/tenants" \
-H "apikey: $ANON_KEY")"
check "Realtime /api/openapi blocked" "403" \
"$(http_status "$BASE_URL/realtime/v1/api/openapi" \
-H "apikey: $ANON_KEY")"
# ---------------------------------------------
# 10. Database pooler (transaction mode)
# ---------------------------------------------
echo ""
echo "--- Database pooler (transaction mode) ---"
if command -v docker >/dev/null 2>&1; then
pg_password=$(grep '^POSTGRES_PASSWORD=' .env | cut -d= -f2-)
pooler_tenant_id=$(grep '^POOLER_TENANT_ID=' .env | cut -d= -f2-)
# Connect as the postgres role through whichever transaction pooler is running:
# Supavisor (default) -> service 'supavisor', port 6543, user 'postgres.<tenant>'
# PgBouncer (override) -> service 'pgbouncer', port 6432 (published as 6543), user 'postgres'
# psql runs inside the db container (always has a client) and reaches the
# pooler over the compose network.
if service_running supavisor; then
pooler_user="postgres.$pooler_tenant_id"; pooler_host="supavisor"; pooler_port=6543
elif service_running pgbouncer; then
pooler_user="postgres"; pooler_host="pgbouncer"; pooler_port=6432
else
pooler_user=""
fi
if [ -n "$pooler_user" ]; then
pooler_result=$(docker exec -e PGPASSWORD="$pg_password" supabase-db \
psql "host=$pooler_host port=$pooler_port user=$pooler_user dbname=postgres sslmode=disable" \
-tAc "select 'pooler_ok';" 2>/dev/null | tr -d '[:space:]')
check "Pooler transaction-mode query (as postgres)" "pooler_ok" "$pooler_result"
else
check "Pooler running (supavisor or pgbouncer)" "true" "false"
fi
else
echo " SKIP: docker not available"
fi
# ---------------------------------------------
# Summary
# ---------------------------------------------
echo ""
echo "=== Results: $pass passed, $fail failed ==="
echo ""
if [ "$fail" -gt 0 ]; then
exit 1
fi
+380
View File
@@ -0,0 +1,380 @@
#!/bin/sh
#
# Hermetic test for update.sh (the self-hosted in-place update script).
#
# Builds a tiny synthetic "upstream" git repo with two tagged releases
# (self-hosted/v0.9.0 -> self-hosted/v1.1.0; 1.0.0 exists only as a manifest key),
# where the target ships a breaking-change manifest with entries at the base and
# inside the window. It then simulates a configured deployment based on v0.9.0
# and runs update.sh via a SUPABASE_REPO_URL override (no network), asserting:
# the 3-way merge preserves secrets/data/overrides, adds new .env keys (but not
# ones the user commented out), applies clean merges, reports real conflicts,
# honors .gitignore for user-owned paths, surfaces only the in-window gate
# entries (base-version entry excluded), and advances the stamp ONLY on a clean
# apply. Also covers the clean-apply path, default-target resolution, --from,
# --dry-run, the missing-stamp report-only path, and a malformed manifest being
# refused.
#
# Usage:
# sh tests/test-update.sh # run from the docker/ directory
#
set -eu
# Isolate from the developer's global/system git config (gpgsign, hooksPath,
# templateDir, core.excludesfile) so neither the synthetic commits nor update.sh's
# internal git calls (fetch, merge-file, check-ignore) are affected. Exported so
# the update.sh subprocess inherits them too.
export GIT_CONFIG_GLOBAL=/dev/null
export GIT_CONFIG_NOSYSTEM=1
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
DOCKER_DIR=$(dirname "$SCRIPT_DIR")
UPDATE_SH="$DOCKER_DIR/update.sh"
[ -f "$UPDATE_SH" ] || { echo "ERROR: $UPDATE_SH not found"; exit 1; }
WORK=$(mktemp -d)
cleanup() { rm -rf "$WORK"; }
trap cleanup EXIT INT TERM
PASS=0
FAIL=0
ok() { PASS=$((PASS+1)); printf " ok - %s\n" "$1"; }
bad() { FAIL=$((FAIL+1)); printf " FAIL - %s\n" "$1"; }
assert_file_contains() { # <file> <pattern> <desc>
if grep -qF "$2" "$1" 2>/dev/null; then ok "$3"; else bad "$3 (missing '$2' in $1)"; fi
}
assert_file_missing_pattern() { # <file> <pattern> <desc>
if grep -qF "$2" "$1" 2>/dev/null; then bad "$3 (unexpected '$2' in $1)"; else ok "$3"; fi
}
assert_line() { # <file> <ERE> <desc>
if grep -qE "$2" "$1" 2>/dev/null; then ok "$3"; else bad "$3 (no line matching /$2/ in $1)"; fi
}
assert_no_line() { # <file> <ERE> <desc>
if grep -qE "$2" "$1" 2>/dev/null; then bad "$3 (unexpected line matching /$2/ in $1)"; else ok "$3"; fi
}
assert_path_exists() { # <path> <desc>
if [ -e "$1" ]; then ok "$2"; else bad "$2 ($1 missing)"; fi
}
assert_path_absent() { # <path> <desc>
if [ -e "$1" ]; then bad "$2 ($1 should not exist)"; else ok "$2"; fi
}
# portable in-place sed (BSD + GNU): sedi <expr> <file>
sedi() { sed "$1" "$2" > "$2.tmp" && mv "$2.tmp" "$2"; }
# --- 1. Build the synthetic upstream repo -----------------------------------
SRC="$WORK/upstream"
mkdir -p "$SRC/docker/volumes/api"
cd "$SRC"
git init -q
git config user.email t@t.t
git config user.name t
cat > docker/docker-compose.yml <<'EOF'
services:
studio:
image: supabase/studio:OLD
db:
image: supabase/postgres:15
EOF
cat > docker/.env.example <<'EOF'
POSTGRES_PASSWORD=changeme
JWT_SECRET=changeme
KEEP_ME=base-default
EOF
printf 'base kong\n' > docker/volumes/api/kong.yml
printf 'remove me\n' > docker/old-only.txt
cp "$DOCKER_DIR/.gitignore" docker/.gitignore
mkdir -p docker/volumes/functions/main
printf 'base main\n' > docker/volumes/functions/main/index.ts
git add -A && git commit -qm base && git tag self-hosted/v0.9.0
# target commit
cat > docker/docker-compose.yml <<'EOF'
services:
studio:
image: supabase/studio:NEW
db:
image: supabase/postgres:17
EOF
cat > docker/.env.example <<'EOF'
POSTGRES_PASSWORD=changeme
JWT_SECRET=changeme
KEEP_ME=base-default
NEW_KEY=new-default
EOF
printf 'brand new\n' > docker/new-only.txt
printf 'target main\n' > docker/volumes/functions/main/index.ts
mkdir -p docker/volumes/functions/hello
printf 'target hello\n' > docker/volumes/functions/hello/index.ts
# A gitignored sample under volumes/snippets shipped by upstream: must NOT
# overwrite the user's file of the same name (exercises is_excluded directly).
mkdir -p docker/volumes/snippets
printf 'VENDOR SEED\n' > docker/volumes/snippets/seed.sql
# Manifest with entries at/below and inside the window:
# 0.9.0 == the base -> must be EXCLUDED (window is half-open: (base, target]).
# 1.0.0 inside -> surfaces (carries requires+gate).
# 1.1.0 == target -> surfaces; requires-less and sorts LAST (no _schema after),
# guarding the set -e regression where a requires-less final entry
# aborted the script. Do NOT add a version key after 1.1.0.
cat > docker/upgrades.json <<'EOF'
{
"0.9.0": {
"breaking": true
},
"1.0.0": {
"breaking": true,
"gate": "utils/demo-migrate.sh",
"migration_guide_url": "https://example.test/guide",
"requires": ["Run the demo migration first."]
},
"1.1.0": {
"breaking": true
}
}
EOF
git rm -q docker/old-only.txt
git add -A
git add -f docker/volumes/functions/hello/index.ts docker/volumes/snippets/seed.sql
# Release tag for the target / default-target (latest self-hosted/v*) path.
git commit -qm target && git tag self-hosted/v1.1.0
# A ref whose upgrades.json is valid JSON but NOT an object. update.sh must
# refuse (die) rather than silently skip the gate. Named so latest_release_tag
# ignores it (not self-hosted/v*), leaving the default-target path on v1.1.0.
printf '["valid JSON, but not an object"]\n' > docker/upgrades.json
git add -A && git commit -qm 'malformed manifest' && git tag malformed-manifest
# --- helper: lay down a deployment based on v0.9.0 --------------------------
# make_deploy <dir> [conflict] - pass "conflict" to pin the studio image so the
# merge produces a real conflict; omit for a clean apply.
make_deploy() { # <dir> [conflict]
d="$1"
_mode="${2:-clean}"
mkdir -p "$d"
git -C "$SRC" archive self-hosted/v0.9.0 docker | tar -x -C "$d" --strip-components=1
cp "$UPDATE_SH" "$d/update.sh"
# configured .env: real secret, an extra user key, and KEEP_ME commented out
# on purpose (must NOT be re-added by the .env key-union).
cp "$d/.env.example" "$d/.env"
sedi "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=test-secret-123/" "$d/.env"
sedi "s/^KEEP_ME=/#KEEP_ME=/" "$d/.env"
printf 'EXTRA_USER_KEY=mine\n' >> "$d/.env"
# user-owned override (must never be touched)
printf 'services: {}\n# my override\n' > "$d/docker-compose.override.yml"
# data dirs with sentinels (must never be touched)
mkdir -p "$d/volumes/db/data" "$d/volumes/storage"
printf 'DBDATA\n' > "$d/volumes/db/data/keep.txt"
printf 'OBJ\n' > "$d/volumes/storage/keep.txt"
# user adds a line to kong (upstream unchanged -> clean merge, must survive)
printf 'user added line\n' >> "$d/volumes/api/kong.yml"
# user-owned paths per .gitignore (must not be touched by the merge)
mkdir -p "$d/volumes/snippets" "$d/volumes/functions/my-fn"
printf 'USER_SNIPPET\n' > "$d/volumes/snippets/user.sql"
printf 'user fn\n' > "$d/volumes/functions/my-fn/index.ts"
# user file at the SAME path as the vendor snippet shipped at target:
# is_excluded must skip it so the user's content survives.
printf 'USER SEED\n' > "$d/volumes/snippets/seed.sql"
# legacy sample fn in snapshot at target but gitignored - must not overwrite
mkdir -p "$d/volumes/functions/hello"
printf 'user hello\n' > "$d/volumes/functions/hello/index.ts"
# version stamp pointing at the base (ref only; update.sh derives the rest)
printf 'ref=self-hosted/v0.9.0\n' > "$d/.supabase-version"
if [ "$_mode" = "conflict" ]; then
# user pins the studio image (same line upstream changes -> conflict)
sedi "s#supabase/studio:OLD#supabase/studio:USER-PINNED#" "$d/docker-compose.yml"
fi
}
echo ""
echo "=== update.sh: apply path (with a conflict) ==="
DEPLOY="$WORK/deploy"
make_deploy "$DEPLOY" conflict
cd "$DEPLOY"
rc=0
SUPABASE_REPO_URL="$SRC" sh ./update.sh --to self-hosted/v1.1.0 --yes > "$WORK/apply.log" 2>&1 || rc=$?
sed 's/^/ | /' "$WORK/apply.log"
if [ "$rc" = "2" ]; then ok "exit status 2 signals conflicts"; else bad "expected exit 2 (conflicts), got $rc"; fi
assert_file_contains ".env" "POSTGRES_PASSWORD=test-secret-123" "user secret preserved"
assert_file_contains ".env" "NEW_KEY=new-default" "new .env key appended"
assert_file_contains ".env" "EXTRA_USER_KEY=mine" "extra user key kept"
assert_line ".env" "^#KEEP_ME=base-default" "user's commented key left commented"
assert_no_line ".env" "^KEEP_ME=" "commented .env key not re-added uncommented"
assert_file_contains "docker-compose.override.yml" "my override" "override untouched"
assert_file_contains "volumes/db/data/keep.txt" "DBDATA" "db data untouched"
assert_file_contains "volumes/storage/keep.txt" "OBJ" "storage untouched"
assert_file_contains "docker-compose.yml" "<<<<<<<" "conflict open marker written"
assert_file_contains "docker-compose.yml" "=======" "conflict separator written"
assert_file_contains "docker-compose.yml" ">>>>>>>" "conflict close marker written"
assert_file_contains "docker-compose.yml" "USER-PINNED" "user value present in conflict"
assert_file_contains "docker-compose.yml" "supabase/studio:NEW" "upstream value present in conflict"
assert_file_contains "docker-compose.yml" "supabase/postgres:17" "non-conflicting line merged (pg17)"
assert_path_exists "new-only.txt" "new upstream file added"
assert_path_exists "old-only.txt" "removed-upstream file left in place"
assert_file_contains "volumes/api/kong.yml" "user added line" "clean merge preserved user line"
assert_file_contains ".supabase-version" "ref=self-hosted/v0.9.0" "stamp NOT advanced on conflict"
assert_file_contains "$WORK/apply.log" "Files with merge conflicts" "conflicts reported in summary"
assert_file_contains "$WORK/apply.log" "[1.0.0]" "manifest entry 1.0.0 surfaced"
assert_file_contains "$WORK/apply.log" "[1.1.0] BREAKING" "requires-less last entry surfaced (no set -e abort)"
assert_file_missing_pattern "$WORK/apply.log" "[0.9.0]" "base-version entry excluded (window lower bound is half-open)"
assert_file_contains "$WORK/apply.log" "Run the demo migration first." "manifest gate step surfaced"
assert_file_contains "$WORK/apply.log" "utils/demo-migrate.sh" "manifest gate script surfaced"
assert_file_contains "$WORK/apply.log" "example.test/guide" "manifest migration guide surfaced"
assert_file_contains "$WORK/apply.log" "gone from the new .env.example" ".env key-removal section shown"
assert_file_contains "$WORK/apply.log" "EXTRA_USER_KEY" "removed .env key listed in report"
if ls backups/*.tgz >/dev/null 2>&1; then
ok "backup archive created"
for _bk in backups/*.tgz; do break; done
tar tzf "$_bk" > "$WORK/bk.list" 2>/dev/null || true
assert_file_contains "$WORK/bk.list" ".env" "backup includes .env"
assert_file_missing_pattern "$WORK/bk.list" "volumes/db/data" "backup excludes db data dir"
assert_file_missing_pattern "$WORK/bk.list" "volumes/storage" "backup excludes storage dir"
assert_file_missing_pattern "$WORK/bk.list" "backups/" "backup excludes backups dir"
else
bad "no backup archive"
fi
assert_file_contains "volumes/snippets/user.sql" "USER_SNIPPET" "snippets left untouched"
# seed.sql and hello/index.ts are the only files that are BOTH shipped in the
# target snapshot AND gitignored, so they are the real is_excluded coverage.
# Assert the user's content survives AND no vendor content / conflict markers
# leaked in - i.e. the file was skipped, not merged/conflicted.
assert_file_contains "volumes/snippets/seed.sql" "USER SEED" "gitignored snippet: user content kept"
assert_file_missing_pattern "volumes/snippets/seed.sql" "VENDOR SEED" "gitignored snippet: no vendor content"
assert_file_missing_pattern "volumes/snippets/seed.sql" "<<<<<<<" "gitignored snippet: not conflicted (skipped)"
assert_file_contains "volumes/functions/my-fn/index.ts" "user fn" "custom edge fn left untouched"
assert_file_contains "volumes/functions/main/index.ts" "target main" "vendor main/index.ts updated"
assert_file_contains "volumes/functions/hello/index.ts" "user hello" "gitignored fn: user content kept"
assert_file_missing_pattern "volumes/functions/hello/index.ts" "target hello" "gitignored fn: no vendor content"
assert_file_missing_pattern "volumes/functions/hello/index.ts" "<<<<<<<" "gitignored fn: not conflicted (skipped)"
echo ""
echo "=== update.sh: clean apply (no conflict) advances the stamp and exits 0 ==="
CLEAN="$WORK/clean"
make_deploy "$CLEAN"
cd "$CLEAN"
rc=0
SUPABASE_REPO_URL="$SRC" sh ./update.sh --to self-hosted/v1.1.0 --yes > "$WORK/clean.log" 2>&1 || rc=$?
if [ "$rc" = "0" ]; then ok "clean apply exits 0"; else bad "clean apply expected exit 0, got $rc"; fi
assert_file_contains "$WORK/clean.log" "Update applied cleanly." "clean apply announced"
assert_file_missing_pattern "docker-compose.yml" "<<<<<<<" "clean apply wrote no conflict markers"
assert_file_contains ".supabase-version" "ref=self-hosted/v1.1.0" "stamp advanced on clean apply"
assert_file_contains ".env" "NEW_KEY=new-default" "new .env key appended (clean)"
assert_file_contains "docker-compose.yml" "supabase/studio:NEW" "vendor file updated (clean)"
assert_file_contains "volumes/api/kong.yml" "user added line" "clean merge preserved user line (clean)"
echo ""
echo "=== update.sh: default target resolves to latest self-hosted/v* tag ==="
TAGD="$WORK/tagdefault"
make_deploy "$TAGD"
cd "$TAGD"
SUPABASE_REPO_URL="$SRC" sh ./update.sh --yes > "$WORK/tag.log" 2>&1 || true
assert_file_contains "$WORK/tag.log" "Latest release tag: self-hosted/v1.1.0" "resolved latest release tag (no --to)"
assert_file_contains ".supabase-version" "ref=self-hosted/v1.1.0" "stamp advanced to the tag"
assert_file_contains ".env" "NEW_KEY=new-default" "update applied via default target"
echo ""
echo "=== update.sh: --from supplies the base when the stamp is missing ==="
FROMD="$WORK/fromd"
make_deploy "$FROMD"
cd "$FROMD"
rm -f .supabase-version
rc=0
SUPABASE_REPO_URL="$SRC" sh ./update.sh --from self-hosted/v0.9.0 --to self-hosted/v1.1.0 --yes > "$WORK/from.log" 2>&1 || rc=$?
assert_file_missing_pattern "$WORK/from.log" "REPORT-ONLY" "--from performs a real update (not report-only)"
assert_file_contains ".env" "NEW_KEY=new-default" "--from applied the update"
assert_file_contains ".supabase-version" "ref=self-hosted/v1.1.0" "--from advanced the stamp"
echo ""
echo "=== update.sh: --dry-run writes nothing ==="
DRYD="$WORK/dry"
make_deploy "$DRYD" conflict
cd "$DRYD"
SUPABASE_REPO_URL="$SRC" sh ./update.sh --to self-hosted/v1.1.0 --dry-run > "$WORK/dry.log" 2>&1 || true
assert_file_missing_pattern ".env" "NEW_KEY" "dry-run did not append env key"
assert_file_missing_pattern "docker-compose.yml" "<<<<<<<" "dry-run did not write conflict"
assert_file_contains ".supabase-version" "ref=self-hosted/v0.9.0" "dry-run left stamp unchanged"
assert_path_absent "backups" "dry-run took no backup"
assert_file_contains "$WORK/dry.log" "DRY RUN" "dry-run labeled output"
echo ""
echo "=== update.sh: missing stamp -> report-only (still surfaces the gate) ==="
MISS="$WORK/miss"
make_deploy "$MISS"
cd "$MISS"
rm -f .supabase-version
rc=0
SUPABASE_REPO_URL="$SRC" sh ./update.sh --to self-hosted/v1.1.0 > "$WORK/miss.log" 2>&1 || rc=$?
if [ "$rc" = "0" ]; then ok "report-only exits 0"; else bad "report-only expected exit 0, got $rc"; fi
assert_file_contains "$WORK/miss.log" "REPORT-ONLY" "report-only mode announced"
assert_file_missing_pattern ".env" "NEW_KEY" "report-only wrote nothing to .env"
assert_path_absent "backups" "report-only took no backup"
assert_file_contains "$WORK/miss.log" "Breaking changes / required manual steps" "report-only surfaces the gate"
assert_file_contains "$WORK/miss.log" "[1.0.0]" "report-only lists in-range breaking release"
assert_file_contains "$WORK/miss.log" "[0.9.0]" "report-only (open lower bound) includes the base-version entry"
echo ""
echo "=== update.sh: malformed manifest -> refuses (dies), writes nothing ==="
BADM="$WORK/badmanifest"
make_deploy "$BADM"
cd "$BADM"
rc=0
SUPABASE_REPO_URL="$SRC" sh ./update.sh --to malformed-manifest --yes > "$WORK/bad.log" 2>&1 || rc=$?
if [ "$rc" != "0" ] && [ "$rc" != "2" ]; then ok "malformed manifest aborts (die, not a normal exit)"; else bad "expected die (non-0, non-2), got $rc"; fi
assert_file_contains "$WORK/bad.log" "not a valid JSON object" "malformed-manifest error surfaced"
assert_file_missing_pattern ".env" "NEW_KEY" "malformed manifest: .env untouched"
assert_path_absent "backups" "malformed manifest: no backup taken"
assert_file_contains ".supabase-version" "ref=self-hosted/v0.9.0" "malformed manifest: stamp not advanced"
echo ""
echo "=== update.sh: never overwrites the running script; stages the target's copy as .dist ==="
# A dedicated upstream whose docker/ ships an update.sh that DIFFERS from the one
# we run, so the merge must divert it to update.sh.dist rather than rewrite the
# live script mid-run (which would corrupt this process).
SELFSRC="$WORK/selfsrc"
mkdir -p "$SELFSRC/docker"
cd "$SELFSRC"
git init -q
git config user.email t@t.t
git config user.name t
printf 'services:\n db:\n image: x\n' > docker/docker-compose.yml
printf 'KEEP=1\n' > docker/.env.example
cp "$DOCKER_DIR/.gitignore" docker/.gitignore
printf '#!/bin/sh\necho THIS-IS-THE-NEW-UPDATE-SH\n' > docker/update.sh
git add -A && git commit -qm self && git tag self-hosted/v2.0.0
SELFDEP="$WORK/selfdep"
mkdir -p "$SELFDEP"
printf 'services:\n db:\n image: x\n' > "$SELFDEP/docker-compose.yml"
printf 'KEEP=1\n' > "$SELFDEP/.env"
cp "$UPDATE_SH" "$SELFDEP/update.sh" # the running script = the real update.sh
printf 'ref=self-hosted/v2.0.0\n' > "$SELFDEP/.supabase-version"
cd "$SELFDEP"
rc=0
SUPABASE_REPO_URL="$SELFSRC" sh ./update.sh --to self-hosted/v2.0.0 --yes > "$WORK/self.log" 2>&1 || rc=$?
if [ "$rc" = "0" ]; then ok "self-update run exits 0"; else bad "expected exit 0, got $rc"; fi
if cmp -s ./update.sh "$UPDATE_SH"; then ok "running update.sh left byte-identical"; else bad "running update.sh was modified in place"; fi
assert_no_line "./update.sh" '^(<<<<<<<|=======|>>>>>>>)' "no conflict markers written into the running update.sh"
assert_path_exists "$SELFDEP/update.sh.dist" "target's update.sh staged as update.sh.dist"
assert_file_contains "$SELFDEP/update.sh.dist" "THIS-IS-THE-NEW-UPDATE-SH" "update.sh.dist holds the target's version"
assert_file_contains "$WORK/self.log" "update.sh.dist" "summary points the user at update.sh.dist"
# --- summary -----------------------------------------------------------------
echo ""
echo "=== Result: $PASS passed, $FAIL failed ==="
[ "$FAIL" = "0" ] || exit 1
@@ -0,0 +1,90 @@
#!/bin/sh
#
# Validate the upgrade manifest (upgrades.json), which update.sh reads with jq.
#
# - json: upgrades.json is valid JSON
# - keys: top-level keys are bare-semver versions (e.g. "0.7.0"), plus the
# optional "_schema" documentation block
# - schema: each version-keyed entry has only known fields, with valid types
# (an unknown/misspelled key like "breakng" would silently disarm
# its gate, so it is rejected here)
# - gate: any non-null "gate" points at a script that exists in the repo
#
# The manifest is the source of truth for gating; the CHANGELOG is display only,
# so this test deliberately does NOT cross-check the two. Requires jq (already a
# runtime dependency of update.sh); no yq, no generation step.
#
# Usage:
# sh tests/test-upgrades-manifest.sh # run from the docker/ directory
#
set -eu
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
DOCKER_DIR=$(dirname "$SCRIPT_DIR")
cd "$DOCKER_DIR"
JSON=upgrades.json
command -v jq >/dev/null 2>&1 || { echo "ERROR: jq is required"; exit 1; }
[ -f "$JSON" ] || { echo "ERROR: $JSON missing"; exit 1; }
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT INT TERM
PASS=0; FAIL=0
ok() { PASS=$((PASS+1)); printf " ok - %s\n" "$1"; }
bad() { FAIL=$((FAIL+1)); printf " FAIL - %s\n" "$1"; }
echo ""
echo "=== upgrades.json is valid JSON ==="
if jq -e . "$JSON" >/dev/null 2>"$TMP/err"; then
ok "upgrades.json parses"
else
bad "upgrades.json is not valid JSON: $(cat "$TMP/err" 2>/dev/null)"
echo "=== Result: $PASS passed, $FAIL failed ==="; exit 1
fi
echo ""
echo "=== top-level keys are versions (or _schema) ==="
for k in $(jq -r 'keys[]' "$JSON"); do
case "$k" in
_schema) ok "doc block '_schema' present" ;;
[0-9]*.[0-9]*) ok "version key $k" ;;
*) bad "unexpected top-level key '$k' (want bare semver like 0.7.0)" ;;
esac
done
echo ""
echo "=== version-keyed entries have only known fields, with valid types ==="
for k in $(jq -r 'keys[]' "$JSON"); do
case "$k" in [0-9]*.[0-9]*) ;; *) continue ;; esac
errs=$(jq -r --arg k "$k" '.[$k] as $e
| (($e | keys) - ["breaking", "gate", "migration_guide_url", "requires"]) as $unknown
| [ (if ($e.breaking != null) and (($e.breaking|type) != "boolean") then "breaking must be bool" else empty end),
(if ($e.gate != null) and (($e.gate|type) != "string") then "gate must be string|null" else empty end),
(if ($e.migration_guide_url != null) and (($e.migration_guide_url|type) != "string") then "migration_guide_url must be string|null" else empty end),
(if ($e.requires != null) and (($e.requires|type) != "array") then "requires must be array" else empty end),
(if ($unknown | length) > 0 then "unknown field(s) (typo?): " + ($unknown | join(", ")) else empty end)
] | join("; ")' "$JSON")
if [ -z "$errs" ]; then ok "entry $k valid"; else bad "entry $k: $errs"; fi
done
echo ""
echo "=== gate scripts referenced by entries exist ==="
checked=0
for k in $(jq -r 'keys[]' "$JSON"); do
case "$k" in [0-9]*.[0-9]*) ;; *) continue ;; esac
gate=$(jq -r --arg k "$k" '.[$k].gate // empty' "$JSON")
[ -n "$gate" ] || continue
checked=$((checked+1))
if [ -f "$gate" ]; then
ok "gate for $k exists: $gate"
else
bad "gate for $k missing: $gate"
fi
done
[ "$checked" = 0 ] && echo " (no entries reference a gate script)"
echo ""
echo "=== Result: $PASS passed, $FAIL failed ==="
[ "$FAIL" = 0 ] || exit 1
+692
View File
@@ -0,0 +1,692 @@
#!/bin/sh
#
# Update an existing self-hosted Supabase deployment in place.
#
# The deployment directory mixes vendor-owned files (docker-compose.yml, the
# override files, volumes/*, scripts, .env.example) with user-owned state
# (.env, docker-compose.override.yml, volumes/db/data, volumes/storage, etc.).
# This script pulls a newer version of the Supabase files on top of yours
# using a 3-way merge against the version you started from, so local edits
# survive and genuine conflicts are surfaced rather than silently overwritten.
#
# The version you started from is recorded in .supabase-version (written by
# setup.sh). If it is missing, pass --from <ref> or follow the printed guidance.
#
# What it never touches: .env values you set, docker-compose.override.yml, and
# the data directories (volumes/db/data, volumes/storage, etc.). New keys from
# .env.example are appended to your .env; existing values are kept as-is.
#
# By default it updates to the latest self-hosted/v* release tag (or 'master'
# until the first tag exists). Pass --to to pin a specific tag/branch.
#
# Usage:
# sh update.sh # update to the latest release tag
# sh update.sh --dry-run # show what would change, write nothing
# sh update.sh --to <tag> # update to a specific tag/branch
# sh update.sh --from <ref> # base to merge from (if no .supabase-version)
# sh update.sh --yes # don't prompt, even on breaking changes
#
# Env:
# SUPABASE_REPO_URL Override the upstream repo (default: github supabase/supabase)
#
# Documentation: https://supabase.com/docs/guides/self-hosting/updating
#
set -e
cd "$(dirname "$0")"
# Pipeline (see main at the bottom):
# resolve refs -> fetch base+target snapshots -> [report-only exit]
# -> build manifest gate -> confirm_gate (before any writes)
# -> backup → merge vendor files + .env keys -> summary → stamp
#
# Three trees for every vendor file path:
# - base - upstream at BASE_REF (.supabase-version ref=, or --from)
# - target - upstream at TARGET_REF (--to, or latest self-hosted/v* tag)
# - user's - the deployment directory (cwd); _not_ a git checkout
#
# Git is only used to fetch snapshots (fetch_snapshot) and to run git merge-file.
#
# Breaking-change gate (upgrades.json on the target snapshot):
# - Keyed by version (e.g. "0.7.0"); window = entries in (BASE_VER, TARGET_VER],
# where the bounds are the base/target refs reduced to bare semver
# (self-hosted/vX.Y.Z -> X.Y.Z) and compared with sort -V.
# - If a bound is not a release tag (a commit SHA, or "master"), that side of
# the window is left open and all applicable entries are shown with a warning.
# - Prompt when an entry has breaking:true or a gate script; runs before
# backup/merge so abort leaves the deployment untouched.
# - CHANGELOG.md is never parsed (update.sh only points users at it); routine
# "requires compose update" items are applied by the merge, not listed here.
#
# User-owned paths skipped during merge are defined in .gitignore (loaded from the
# target snapshot). git check-ignore --no-index applies negation rules (e.g.
# volumes/functions/** ignored except volumes/functions/main/index.ts).
# .git/ is excluded here only - never listed in .gitignore.
#
# .env is never 3-way merged: append missing keys from .env.example only.
# --- globals (set during main) -----------------------------------------------
REPO_URL="${SUPABASE_REPO_URL:-https://github.com/supabase/supabase}"
STAMP_FILE=".supabase-version"
SELF_NAME=$(basename "$0")
DRY_RUN=0
ASSUME_YES=0
TO_REF=""
FROM_REF=""
TARGET_REF=""
BASE_REF=""
BASE_VER=""
TARGET_VER=""
REPORT_ONLY=0
TMP_ROOT=""
TARGET_DIR=""
BASE_DIR=""
REPORT=""
ENV_ADDED=""
ENV_REMOVED=""
GATE_REPORT=""
GATE_REQUIRED=0
IGNORE_FILE=""
IGNORE_GIT_DIR=""
# --- logging -----------------------------------------------------------------
log() { printf "===> %s\n" "$*"; }
warn() { printf "WARNING: %s\n" "$*" >&2; }
die() { printf "ERROR: %s\n" "$*" >&2; exit 1; }
print_help() {
awk 'NR==1 {next} /^#/ {sub(/^# ?/,""); print; next} {exit}' "$0"
}
# --- small helpers -----------------------------------------------------------
# load_ignore_file - vendor/user split from the target snapshot's .gitignore.
# Uses a throwaway git dir so check-ignore works on deployment trees that are
# not git repos (and on Apple Git, which rejects --git-dir=/dev/null).
load_ignore_file() {
if [ -f "$TARGET_DIR/.gitignore" ]; then
IGNORE_FILE="$TARGET_DIR/.gitignore"
elif [ -f .gitignore ]; then
IGNORE_FILE="$(pwd)/.gitignore"
else
IGNORE_FILE=""
warn "No .gitignore found; only .git paths are excluded from the merge."
return 0
fi
case "$IGNORE_FILE" in
/*) ;;
*) IGNORE_FILE="$(cd "$(dirname "$IGNORE_FILE")" && pwd)/$(basename "$IGNORE_FILE")" ;;
esac
IGNORE_GIT_DIR="$TMP_ROOT/ignore-git"
git init -q "$IGNORE_GIT_DIR"
}
# is_excluded <relpath> - true when the path is user-owned and must not merge.
is_excluded() {
case "$1" in
.git|.git/*) return 0 ;;
esac
[ -n "$IGNORE_FILE" ] || return 1
git -C "$IGNORE_GIT_DIR" -c "core.excludesfile=$IGNORE_FILE" \
check-ignore -q --no-index "$1" 2>/dev/null
}
read_stamp_ref() {
[ -f "$STAMP_FILE" ] || return 1
val=$(grep -E '^ref=' "$STAMP_FILE" 2>/dev/null | head -n1 | cut -d= -f2- | tr -d "\r\"' ")
if [ -z "$val" ]; then
val=$(grep -vE '^[[:space:]]*#' "$STAMP_FILE" 2>/dev/null \
| grep -vE '^[[:space:]]*$' | head -n1 | tr -d "\r\"' ")
fi
[ -n "$val" ] && printf '%s' "$val"
}
# env_has_key <key> <file> - true if the key appears as KEY=, even commented
# (e.g. "#GOOGLE_ENABLED="), so we never re-add a key the user disabled on purpose.
env_has_key() {
grep -qE "^[[:space:]]*#?[[:space:]]*$1=" "$2" 2>/dev/null
}
record() { printf '%s:%s\n' "$1" "$2" >> "$REPORT"; }
count_status() { grep -cE "^$1:" "$REPORT" 2>/dev/null || true; }
list_status() {
grep -E "^$1:" "$REPORT" 2>/dev/null | cut -d: -f2- | sed 's/^/ /'
}
# --- upstream snapshots ------------------------------------------------------
_sparse_init() {
git -C "$1" init -q
git -C "$1" remote add origin "$REPO_URL"
git -C "$1" config core.sparseCheckout true
git -C "$1" sparse-checkout init --cone >/dev/null 2>&1
git -C "$1" sparse-checkout set docker >/dev/null 2>&1
}
# fetch_snapshot <ref> <dest_dir>
# Materializes ./docker at <ref> into <dest_dir> via a shallow fetch. Also the
# seam an artifact source (tarball + sha256) would slot into later.
fetch_snapshot() {
_ref="$1"
_dest="$2"
_work=$(mktemp -d "$TMP_ROOT/fetch.XXXXXX")
if _sparse_init "$_work" \
&& git -C "$_work" fetch --depth=1 --filter=blob:none -q origin "$_ref" 2>/dev/null \
&& git -C "$_work" checkout -q FETCH_HEAD 2>/dev/null \
&& [ -d "$_work/docker" ]; then
mkdir -p "$_dest"
cp -rf "$_work/docker/." "$_dest/"
rm -rf "$_work"
else
rm -rf "$_work"
return 1
fi
}
latest_release_tag() {
git ls-remote --tags --refs "$REPO_URL" 2>/dev/null \
| sed 's#^.*refs/tags/##' \
| grep -E '^self-hosted/v[0-9]' \
| sort -V | tail -n1
}
list_files() {
( cd "$1" && find . -type f | sed 's|^\./||' | grep -vE '^\.git(/|$)' | sort )
}
# normalize_version <ref> - reduce a ref to bare semver (0.7.0) for comparison,
# or empty when it is not a self-hosted release tag (commit SHA, "master", …).
normalize_version() {
_v="${1#refs/tags/}"
_v="${_v#self-hosted/}"
_v="${_v#v}"
case "$_v" in
[0-9]*.[0-9]*) printf '%s' "$_v" ;;
*) ;;
esac
}
# ver_gt A B - true when version A is strictly greater than B (sort -V order).
ver_gt() {
if [ "$1" = "$2" ]; then return 1; fi
[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" ]
}
# ver_in_window VER - true when BASE_VER < VER <= TARGET_VER. An empty bound
# leaves that side open (over-reports a gate rather than hiding one).
ver_in_window() {
if [ -n "$TARGET_VER" ] && ver_gt "$1" "$TARGET_VER"; then return 1; fi
if [ -n "$BASE_VER" ] && ! ver_gt "$1" "$BASE_VER"; then return 1; fi
return 0
}
# --- ref resolution ----------------------------------------------------------
resolve_target_ref() {
if [ -n "$TO_REF" ]; then
TARGET_REF="$TO_REF"
return
fi
TARGET_REF=$(latest_release_tag)
if [ -n "$TARGET_REF" ]; then
log "Latest release tag: $TARGET_REF"
else
TARGET_REF="master"
warn "No self-hosted/v* release tags found; targeting 'master'. Pin a version with --to."
fi
}
resolve_base_ref() {
REPORT_ONLY=0
if [ -n "$FROM_REF" ]; then
BASE_REF="$FROM_REF"
elif BASE_REF=$(read_stamp_ref) && [ -n "$BASE_REF" ]; then
:
else
REPORT_ONLY=1
BASE_REF=""
fi
}
print_report_only_guidance() {
warn "No $STAMP_FILE found and no --from <ref> given; cannot determine the version you started from."
cat >&2 <<EOF
Without a base version a safe 3-way merge is not possible. To fix this, find the
version your deployment was based on and record it, then re-run:
1. Identify your base - the self-hosted/vX.Y.Z release (or commit) your files
came from. Cross-reference the image tags in docker-compose.yml / .env
against versions.md, or the newest CHANGELOG.md section you remember pulling.
2. Write it to $STAMP_FILE, e.g.:
printf 'ref=self-hosted/v0.7.0\n' > $STAMP_FILE
3. Re-run: sh update.sh
Or supply it inline for this run: sh update.sh --from <commit-sha-or-tag>
Continuing in REPORT-ONLY mode. NOTE: this is NOT the full set of changes -
without a base version it can only list files and .env keys that are entirely
NEW to you; it CANNOT show which existing files would change or conflict. Record
a base and re-run for the real preview. Nothing will be written.
EOF
}
fetch_snapshots() {
TARGET_DIR="$TMP_ROOT/target"
log "Fetching target snapshot ($TARGET_REF)"
fetch_snapshot "$TARGET_REF" "$TARGET_DIR" \
|| die "Could not fetch target snapshot '$TARGET_REF' from $REPO_URL"
if [ "$REPORT_ONLY" = "1" ]; then
return
fi
BASE_DIR="$TMP_ROOT/base"
log "Fetching base snapshot ($BASE_REF)"
fetch_snapshot "$BASE_REF" "$BASE_DIR" \
|| die "Could not fetch base snapshot '$BASE_REF' from $REPO_URL"
}
run_report_only() {
log "Files in '$TARGET_REF' you do NOT have yet (brand-new only; existing files that changed are NOT shown here):"
while IFS= read -r f; do
is_excluded "$f" && continue
[ -f "$f" ] || echo " + $f"
done <<EOF
$(list_files "$TARGET_DIR")
EOF
echo ""
log ".env keys in the new .env.example that your .env is missing (add these):"
if [ -f "$TARGET_DIR/.env.example" ]; then
while IFS= read -r k; do
env_has_key "$k" .env || echo " + $k"
done <<EOF
$(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$TARGET_DIR/.env.example" | cut -d= -f1)
EOF
fi
echo ""
warn "This is NOT the full update. To see what would actually change, record a base version."
warn "See the guidance above and re-run the script. Nothing was written."
}
# --- breaking-change gate (manifest-driven, before any writes) ---------------
# Reads docker/upgrades.json from the target snapshot; gating keys off manifest
# entries in (BASE_VER, TARGET_VER].
build_gate_report() {
_manifest="$TARGET_DIR/upgrades.json"
GATE_REQUIRED=0
: > "$GATE_REPORT"
if [ ! -f "$_manifest" ]; then
return 0
fi
if ! jq -e 'type == "object"' "$_manifest" >/dev/null 2>&1; then
die "$_manifest is present but is not a valid JSON object; refusing to update without a working breaking-change gate. Please report this to the maintainers."
fi
if [ -z "$BASE_VER" ] || [ -z "$TARGET_VER" ]; then
warn "Base or target is not a self-hosted/vX.Y.Z tag; cannot compute an exact update window."
warn "Showing all applicable manual-action releases."
warn "Review which ones apply to your deployment."
fi
for k in $(jq -r 'keys[]' "$_manifest" 2>/dev/null); do
case "$k" in [0-9]*.[0-9]*) ;; *) continue ;; esac
ver_in_window "$k" || continue
breaking=$(jq -r --arg k "$k" '.[$k].breaking // false' "$_manifest")
gate=$(jq -r --arg k "$k" '.[$k].gate // empty' "$_manifest")
url=$(jq -r --arg k "$k" '.[$k].migration_guide_url // empty' "$_manifest")
reqs=$(jq -r --arg k "$k" '.[$k].requires[]? // empty' "$_manifest")
if [ "$breaking" = "true" ] || [ -n "$gate" ]; then
GATE_REQUIRED=1
fi
{
[ "$breaking" = "true" ] && echo "[$k] BREAKING" || echo "[$k]"
[ -n "$gate" ] && echo " gate: '$gate' must be run first (see the steps below for the exact command)"
[ -n "$url" ] && echo " guide: $url"
[ -n "$reqs" ] && printf '%s\n' "$reqs" | sed 's/^/ - /'
} >> "$GATE_REPORT"
done
# Explicit success: the loop's last iteration can end on a false test (e.g.
# an entry with no 'requires'), which would otherwise make this function
# return non-zero and abort the whole script under 'set -e'.
return 0
}
confirm_gate() {
[ "$GATE_REQUIRED" = "1" ] || return 0
[ "$DRY_RUN" != "1" ] || return 0
[ "$ASSUME_YES" != "1" ] || return 0
echo "" >&2
warn "This update requires manual action - review before continuing:"
sed 's/^/ /' "$GATE_REPORT" >&2
echo "" >&2
if { : > /dev/tty; } 2>/dev/null; then
printf "Have you completed the required steps and want to continue? [y/N]: " > /dev/tty
read -r reply < /dev/tty
case "$reply" in
y|Y|yes|YES) return 0 ;;
*) die "Aborted by user. Nothing was modified." ;;
esac
fi
die "Breaking/gated changes present and no controlling terminal to confirm. Re-run with --yes to proceed."
}
# --- backup ------------------------------------------------------------------
take_backup() {
if [ "$DRY_RUN" = "1" ]; then
log "Dry run: no backup taken, nothing will be written."
return 0
fi
mkdir -p backups
_backup="backups/pre-update-$(date +%Y%m%d-%H%M%S).tgz"
log "Backing up current configuration to $_backup (excluding data directories)"
tar czf "$_backup" \
--exclude='./backups' \
--exclude='./volumes/db/data' \
--exclude='./volumes/storage' \
. 2>/dev/null || warn "Backup archive reported errors; review $_backup before relying on it."
warn "This does NOT back up your database. Back it up separately before updating."
}
# --- vendor file merge -------------------------------------------------------
apply_file() {
[ "$DRY_RUN" = "1" ] && return 0
_dir=$(dirname "$1")
[ "$_dir" = "." ] || mkdir -p "$_dir"
cp -f "$2" "$1"
}
# Per-file 3-way merge (u=yours, b=base snapshot, t=target snapshot):
# no t → keep u; report removed-upstream
# no u → copy t; report new
# u == t → report unchanged
# u == b → copy t; report updated (user never edited)
# else → git merge-file u b t; report merged-clean or CONFLICT
# If b had no file, an empty file stands in for b.
merge_one_file() {
f="$1"
empty="$2"
merged="$TMP_ROOT/merged.out"
b="$BASE_DIR/$f"
t="$TARGET_DIR/$f"
u="$f"
if [ ! -f "$t" ]; then
[ -f "$u" ] && record "removed-upstream" "$f"
return 0
fi
if [ ! -f "$u" ]; then
apply_file "$f" "$t"
record "new" "$f"
return 0
fi
if cmp -s "$u" "$t"; then
record "unchanged" "$f"
return 0
fi
base_for_merge="$b"
[ -f "$base_for_merge" ] || base_for_merge="$empty"
if cmp -s "$u" "$base_for_merge"; then
apply_file "$f" "$t"
record "updated" "$f"
return 0
fi
if git merge-file -p -q \
-L "yours ($f)" -L "base" -L "new ($TARGET_REF)" \
"$u" "$base_for_merge" "$t" > "$merged" 2>/dev/null; then
[ "$DRY_RUN" != "1" ] && cp -f "$merged" "$u"
record "merged-clean" "$f"
elif [ -s "$merged" ]; then
# Non-zero exit with output = a normal conflict (markers written).
[ "$DRY_RUN" != "1" ] && cp -f "$merged" "$u"
record "CONFLICT" "$f"
else
# git merge-file errored and produced no output; keep the user's file
# intact rather than truncating it to empty.
record "merge-failed" "$f"
fi
}
# The running script is a vendor file too, but overwriting it in place would
# corrupt this process (the shell reads $0 as it runs). Never write it directly:
# if the target ships a different version, stage it as <name>.dist to review.
stage_self_update() {
_t="$TARGET_DIR/$SELF_NAME"
if [ ! -f "$_t" ] || cmp -s "$SELF_NAME" "$_t"; then
record "unchanged" "$SELF_NAME"
return 0
fi
[ "$DRY_RUN" = "1" ] || cp -f "$_t" "$SELF_NAME.dist"
record "self-staged" "$SELF_NAME"
}
merge_vendor_files() {
_empty="$TMP_ROOT/empty"
: > "$_empty"
: > "$REPORT"
while IFS= read -r f; do
[ -n "$f" ] || continue
is_excluded "$f" && continue
if [ "$f" = "$SELF_NAME" ]; then
stage_self_update
continue
fi
merge_one_file "$f" "$_empty"
done <<EOF
$( { list_files "$BASE_DIR"; list_files "$TARGET_DIR"; } | sort -u )
EOF
}
# --- .env key-union merge ----------------------------------------------------
merge_env_file() {
_example="$TARGET_DIR/.env.example"
: > "$ENV_ADDED"
: > "$ENV_REMOVED"
[ -f "$_example" ] || return 0
while IFS= read -r k; do
env_has_key "$k" .env || echo "$k" >> "$ENV_ADDED"
done <<EOF
$(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$_example" | cut -d= -f1)
EOF
while IFS= read -r k; do
env_has_key "$k" "$_example" || echo "$k" >> "$ENV_REMOVED"
done <<EOF
$(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' .env | cut -d= -f1)
EOF
if [ ! -s "$ENV_ADDED" ] || [ "$DRY_RUN" = "1" ]; then
return 0
fi
{
echo ""
echo "############"
echo "# Added by update.sh on $(date +%Y-%m-%d) from .env.example ($TARGET_REF)."
echo "# Review and set values as needed."
echo "############"
while IFS= read -r k; do
grep -E "^${k}=" "$_example" | head -n1
done < "$ENV_ADDED"
} >> .env
}
# --- summary and finish ------------------------------------------------------
print_summary() {
echo ""
if [ "$DRY_RUN" = "1" ]; then
log "DRY RUN - the following WOULD change (nothing was written):"
else
log "Update applied. Summary:"
fi
printf " updated: %s\n" "$(count_status updated)"
printf " new: %s\n" "$(count_status new)"
printf " merged (clean): %s\n" "$(count_status merged-clean)"
printf " CONFLICTS: %s\n" "$(count_status CONFLICT)"
printf " merge failures: %s\n" "$(count_status merge-failed)"
printf " removed upstream: %s\n" "$(count_status removed-upstream)"
printf " env keys added: %s\n" "$( [ -s "$ENV_ADDED" ] && wc -l < "$ENV_ADDED" | tr -d ' ' || echo 0 )"
if [ "$(count_status CONFLICT)" != "0" ]; then
echo ""
warn "Files with merge conflicts (edit these and remove the <<<<<<< ======= >>>>>>> markers):"
list_status CONFLICT
fi
if [ "$(count_status merge-failed)" != "0" ]; then
echo ""
warn "Files git could not merge (left unchanged - update these manually):"
list_status merge-failed
fi
if [ "$(count_status merged-clean)" != "0" ]; then
echo ""
log "Files merged cleanly (review recommended):"
list_status merged-clean
fi
if [ "$(count_status removed-upstream)" != "0" ]; then
echo ""
log "Removed upstream but kept in place (you may no longer need these):"
list_status removed-upstream
fi
if [ "$(count_status self-staged)" != "0" ]; then
echo ""
if [ "$DRY_RUN" = "1" ]; then
log "$SELF_NAME differs from the version in '$TARGET_REF'; a real run would stage that version as $SELF_NAME.dist (the running script is never overwritten in place)."
else
log "$SELF_NAME differs from the version in '$TARGET_REF', staged as $SELF_NAME.dist (the running script was not modified)."
log "Review it, then swap it in if needed: mv $SELF_NAME.dist $SELF_NAME"
fi
fi
if [ -s "$ENV_ADDED" ]; then
echo ""
log ".env keys added (review values):"
sed 's/^/ + /' "$ENV_ADDED"
fi
if [ -s "$ENV_REMOVED" ]; then
echo ""
log ".env keys you have that are gone from the new .env.example (review/remove manually):"
sed 's/^/ - /' "$ENV_REMOVED"
fi
if [ -s "$GATE_REPORT" ]; then
echo ""
log "Required manual steps for this update (from upgrades.json):"
sed 's/^/ /' "$GATE_REPORT"
fi
echo ""
log "For what changed in this update, see CHANGELOG.md (from $BASE_REF to $TARGET_REF)."
}
write_stamp() {
{
echo "# Supabase self-hosted version stamp. Managed by setup.sh / update.sh."
echo "# Do not commit or edit by hand. Records the ref this deployment was based on."
echo "ref=$TARGET_REF"
} > "$STAMP_FILE"
}
print_next_steps() {
echo ""
log "Next steps:"
echo " 1. Review the changes above (compare against the latest backup in backups/ if needed)."
echo " 2. sh run.sh pull"
echo " 3. sh run.sh recreate"
}
# --- main --------------------------------------------------------------------
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--yes|-y) ASSUME_YES=1; shift ;;
--to) [ $# -ge 2 ] || die "--to requires a <tag> argument."; TO_REF="$2"; shift 2 ;;
--from) [ $# -ge 2 ] || die "--from requires a <ref> argument."; FROM_REF="$2"; shift 2 ;;
-h|--help) print_help; exit 0 ;;
*) echo "Unknown option: $1" >&2; print_help; exit 1 ;;
esac
done
[ -f docker-compose.yml ] || die "docker-compose.yml not found in $(pwd). Run this from your deployment directory."
[ -f .env ] || die ".env not found in $(pwd). This does not look like a configured deployment."
command -v git >/dev/null 2>&1 || die "git is required but was not found on PATH."
command -v jq >/dev/null 2>&1 || die "jq is required but was not found on PATH."
resolve_target_ref
resolve_base_ref
TARGET_VER=$(normalize_version "$TARGET_REF")
BASE_VER=$(normalize_version "$BASE_REF")
[ "$REPORT_ONLY" = "1" ] && print_report_only_guidance
TMP_ROOT=$(mktemp -d)
REPORT="$TMP_ROOT/report"
ENV_ADDED="$TMP_ROOT/env_added"
ENV_REMOVED="$TMP_ROOT/env_removed"
GATE_REPORT="$TMP_ROOT/gate_report"
trap 'rm -rf "$TMP_ROOT"' EXIT INT TERM
fetch_snapshots
load_ignore_file
if [ "$REPORT_ONLY" = "1" ]; then
run_report_only
build_gate_report
if [ -s "$GATE_REPORT" ]; then
echo ""
log "Breaking changes / required manual steps in this range (from upgrades.json):"
sed 's/^/ /' "$GATE_REPORT"
warn "Record a base version (see above) and re-run to apply with the gate enforced."
fi
exit 0
fi
build_gate_report
confirm_gate
take_backup
merge_vendor_files
merge_env_file
print_summary
if [ "$DRY_RUN" = "1" ]; then
echo ""
log "Dry run complete. Re-run without --dry-run to apply."
exit 0
fi
if [ "$(count_status CONFLICT)" != "0" ] || [ "$(count_status merge-failed)" != "0" ]; then
echo ""
warn "Update applied WITH CONFLICTS. Resolve the files listed above (remove the"
warn "<<<<<<< ======= >>>>>>> markers, or fix the files git could not merge)"
warn "before starting the stack."
warn "The version stamp was NOT advanced; it will update on your next clean run."
warn "Exiting with status 2."
exit 2
fi
write_stamp
print_next_steps
log "Update applied cleanly."
+39
View File
@@ -0,0 +1,39 @@
{
"_schema": {
"description": "Manual-action manifest for self-hosted upgrades, read by update.sh with jq. Hand-edit this file directly; it is the source of truth (no generation step). Keys are self-hosted release versions as bare semver (e.g. \"0.7.0\"), matching the self-hosted/vX.Y.Z tag and the \"## [0.7.0]\" CHANGELOG heading. Only add an entry for a release that needs an action a file diff cannot encode: a data migration, a run-this-first script, or a breaking default. Routine config changes are applied automatically by update.sh's 3-way merge and must NOT be listed here. update.sh gates on entries in (your version, target version], ordered with sort -V.",
"fields": {
"breaking": "bool - requires explicit user confirmation before applying",
"gate": "string|null - script to run before upgrading past this release (e.g. utils/upgrade-pg17.sh)",
"migration_guide_url": "string|null - link to the per-release migration guide",
"requires": "string[] - free-text manual steps shown to the user"
}
},
"0.6.0": {
"breaking": true,
"gate": "utils/upgrade-pg17.sh",
"migration_guide_url": "https://supabase.com/docs/guides/self-hosting/postgres-upgrade-17",
"requires": [
"Postgres 17 is now the default. Do NOT start Postgres 17 against an existing Postgres 15 data directory - back up your database first.",
"Run 'sudo bash utils/upgrade-pg17.sh' (needs bash + root) to migrate Postgres 15 -> 17, then recreate containers. To defer, pin Postgres 15 with the docker-compose.pg15.yml override.",
"Includes a security fix for the API gateway (Realtime /api/tenants and /api/openapi routes) - strongly recommended for any instance running Realtime."
]
},
"0.7.0": {
"breaking": true,
"gate": null,
"migration_guide_url": "https://github.com/orgs/supabase/discussions/47093",
"requires": [
"API_EXTERNAL_URL now includes the /auth/v1 path prefix, and SAML SSO endpoints moved to /auth/v1/sso/saml/*. Update custom OAuth provider callback URLs and any SAML configuration accordingly.",
"Anon (publishable) key access to the OpenAPI spec at /rest/v1/ has been removed. Use the service role or secret API key if you relied on it; normal data access via /rest/v1/<table> is unaffected."
]
},
"0.8.0": {
"breaking": true,
"gate": null,
"migration_guide_url": "https://github.com/orgs/supabase/discussions/48048",
"requires": [
"Envoy is now the default API gateway, replacing Kong. The gateway service is renamed from 'kong' to 'api-gw' (container 'supabase-envoy'); the 'kong' network alias still resolves, so internal service references keep working.",
"If you customized volumes/api/kong.yml or the gateway service, enable the Kong override with 'sh run.sh config add kong' to keep running Kong; otherwise the merge switches you to Envoy."
]
}
}
@@ -0,0 +1,226 @@
#!/bin/sh
#
# Add asymmetric key pair and opaque API keys to a self-hosted Supabase installation.
#
# Reads JWT_SECRET from .env and generates:
# - EC P-256 key pair (JWT_KEYS, JWT_JWKS)
# - Opaque API keys (SUPABASE_PUBLISHABLE_KEY, SUPABASE_SECRET_KEY)
# - Internal: ES256 JWT API keys (ANON_KEY_ASYMMETRIC, SERVICE_ROLE_KEY_ASYMMETRIC)
#
# Usage:
# sh add-new-auth-keys.sh # Interactive: prints keys, prompts to update .env
# sh add-new-auth-keys.sh --update-env # Prints keys and writes them to .env
# sh add-new-auth-keys.sh | tee keys # Non-interactive: prints keys only
#
# Prerequisites:
# - .env file with JWT_SECRET set (run generate-keys.sh first)
# - node (>= 16) or docker
#
set -e
node_ok() {
command -v node >/dev/null 2>&1 || return 1
major=$(node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1)
[ -n "$major" ] && [ "$major" -ge 16 ] 2>/dev/null
}
# Resolve how to run node: local install (>= 16) preferred, docker fallback.
if node_ok; then
node_runner="node"
else
if command -v node >/dev/null 2>&1; then
echo "Local node $(node -v) is too old (need >= 16), falling back to docker."
fi
if ! command -v docker >/dev/null 2>&1; then
echo "Error: requires either node (>= 16) or docker."
exit 1
fi
if ! docker info >/dev/null 2>&1; then
echo "Error: docker is installed but the daemon is not running."
exit 1
fi
if ! docker image inspect node:22-alpine >/dev/null 2>&1; then
echo "Pulling node:22-alpine (first-run only)..."
docker pull node:22-alpine
fi
node_runner="docker run --rm node:22-alpine node"
fi
# Read JWT_SECRET from .env
if [ ! -f .env ]; then
echo "Error: .env file not found. Run generate-keys.sh first."
exit 1
fi
jwt_secret=$(grep '^JWT_SECRET=' .env | cut -d= -f2- | tr -d '\r')
if [ -z "$jwt_secret" ]; then
echo "Error: JWT_SECRET not found in .env. Run generate-keys.sh first."
exit 1
fi
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
# Node.js does the crypto-heavy work:
# - EC P-256 keypair generation
# - JWKS construction (with symmetric key included)
# - ES256 JWT signing
# - Opaque API key generation with checksum
$node_runner -e '
const crypto = require("crypto");
const jwtSecret = process.argv[1];
// Generate EC P-256 keypair and export as JWK
const { privateKey } = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
const jwkPrivate = privateKey.export({ format: "jwk" });
const kid = crypto.randomUUID();
// Symmetric key as JWK (base64url-encoded)
const octKey = {
kty: "oct",
k: Buffer.from(jwtSecret).toString("base64url"),
alg: "HS256"
};
// JWKS with private key (for Auth to sign tokens)
const jwksKeypair = { keys: [
{ kty: "EC", kid, use: "sig", key_ops: ["sign", "verify"], alg: "ES256", ext: true,
crv: jwkPrivate.crv, x: jwkPrivate.x, y: jwkPrivate.y, d: jwkPrivate.d },
octKey
]};
// JWKS with public key only (for PostgREST, Realtime, Storage to verify)
const jwksPublic = { keys: [
{ kty: "EC", kid, use: "sig", key_ops: ["verify"], alg: "ES256", ext: true,
crv: jwkPrivate.crv, x: jwkPrivate.x, y: jwkPrivate.y },
octKey
]};
// Sign ES256 JWT
function signES256(payload) {
const header = { alg: "ES256", typ: "JWT", kid };
const b64Header = Buffer.from(JSON.stringify(header)).toString("base64url");
const b64Payload = Buffer.from(JSON.stringify(payload)).toString("base64url");
const data = b64Header + "." + b64Payload;
const sig = crypto.sign("SHA256", Buffer.from(data), {
key: privateKey,
dsaEncoding: "ieee-p1363"
}).toString("base64url");
return data + "." + sig;
}
const iat = Math.floor(Date.now() / 1000);
const exp = iat + 5 * 365 * 24 * 3600; // 5 years
const anonJwt = signES256({ role: "anon", iss: "supabase", iat, exp });
const serviceJwt = signES256({ role: "service_role", iss: "supabase", iat, exp });
// Generate opaque API keys with checksum
const PROJECT_REF = "supabase-self-hosted";
function generateOpaqueKey(prefix) {
const random = crypto.randomBytes(17).toString("base64url").slice(0, 22);
const intermediate = prefix + random;
const checksum = crypto.createHash("sha256")
.update(PROJECT_REF + "|" + intermediate)
.digest("base64url")
.slice(0, 8);
return intermediate + "_" + checksum;
}
const publishableKey = generateOpaqueKey("sb_publishable_");
const secretKey = generateOpaqueKey("sb_secret_");
// Output as KEY=value lines for shell to parse
console.log("SUPABASE_PUBLISHABLE_KEY=" + publishableKey);
console.log("SUPABASE_SECRET_KEY=" + secretKey);
console.log("ANON_KEY_ASYMMETRIC=" + anonJwt);
console.log("SERVICE_ROLE_KEY_ASYMMETRIC=" + serviceJwt);
console.log("JWT_KEYS=" + JSON.stringify(jwksKeypair.keys));
console.log("JWT_JWKS=" + JSON.stringify(jwksPublic));
' "$jwt_secret" > "$tmpdir/output"
# Read generated values
SUPABASE_PUBLISHABLE_KEY=$(grep '^SUPABASE_PUBLISHABLE_KEY=' "$tmpdir/output" | cut -d= -f2-)
SUPABASE_SECRET_KEY=$(grep '^SUPABASE_SECRET_KEY=' "$tmpdir/output" | cut -d= -f2-)
ANON_KEY_ASYMMETRIC=$(grep '^ANON_KEY_ASYMMETRIC=' "$tmpdir/output" | cut -d= -f2-)
SERVICE_ROLE_KEY_ASYMMETRIC=$(grep '^SERVICE_ROLE_KEY_ASYMMETRIC=' "$tmpdir/output" | cut -d= -f2-)
JWT_KEYS=$(grep '^JWT_KEYS=' "$tmpdir/output" | cut -d= -f2-)
JWT_JWKS=$(grep '^JWT_JWKS=' "$tmpdir/output" | cut -d= -f2-)
echo ""
echo "SUPABASE_PUBLISHABLE_KEY=${SUPABASE_PUBLISHABLE_KEY}"
echo "SUPABASE_SECRET_KEY=${SUPABASE_SECRET_KEY}"
echo ""
echo "JWT_KEYS=${JWT_KEYS}"
echo ""
echo "JWT_JWKS=${JWT_JWKS}"
echo ""
echo "Ensure the following configuration is uncommented in docker-compose.yml for the asymmetric key pair to work:"
echo ""
echo " Auth: GOTRUE_JWT_KEYS: \${JWT_KEYS:-[]}"
echo " Realtime: API_JWT_JWKS: \${JWT_JWKS:-{\"keys\":[]}}"
echo " Storage: JWT_JWKS: \${JWT_JWKS:-{\"keys\":[]}}"
echo " Functions: SUPABASE_JWKS: \${JWT_JWKS:-{\"keys\":[]}}"
echo ""
if [ "$1" = "--update-env" ]; then
update_env=true
elif test -t 0; then
printf "Update .env file? (y/N) "
read -r REPLY
case "$REPLY" in
[Yy]) update_env=true ;;
*) update_env=false ;;
esac
else
echo "Running non-interactively. Pass --update-env to write to .env."
update_env=false
fi
if [ "$update_env" != "true" ]; then
exit 0
fi
echo "Updating .env..."
# Append new variables if they don't exist, or update them if they do
for var in SUPABASE_PUBLISHABLE_KEY SUPABASE_SECRET_KEY ANON_KEY_ASYMMETRIC SERVICE_ROLE_KEY_ASYMMETRIC JWT_KEYS JWT_JWKS; do
eval "val=\$$var"
if grep -q "^${var}=" .env; then
sed -i.old -e "s|^${var}=.*$|${var}=${val}|" .env
else
echo "${var}=${val}" >> .env
fi
done
# Uncomment new auth configuration in docker-compose.yml
echo "Updating docker-compose.yml..."
if [ ! -f docker-compose.yml ]; then
echo "Error: docker-compose.yml not found in $(pwd)"
exit 1
fi
# Always fall through to the grep check
sed -i.old \
-e '/^[ ]*#GOTRUE_JWT_KEYS:/ s/#//' \
-e '/^[ ]*#API_JWT_JWKS:/ s/#//' \
-e '/^[ ]*#JWT_JWKS:/ s/#//' \
-e '/^[ ]*#SUPABASE_JWKS:/ s/#//' \
docker-compose.yml || true
if grep -q '^[ ]*GOTRUE_JWT_KEYS:' docker-compose.yml && \
grep -q '^[ ]*API_JWT_JWKS:' docker-compose.yml && \
grep -q '^[ ]*JWT_JWKS:' docker-compose.yml && \
grep -q '^[ ]*SUPABASE_JWKS:' docker-compose.yml; then
echo "Done."
else
echo "Warning: could not edit docker-compose.yml. Uncomment auth configuration manually."
fi
+157
View File
@@ -0,0 +1,157 @@
#!/bin/sh
#
# Portions of this code are derived from Inder Singh's update-db-pass.sh
# Copyright 2025 Inder Singh. Licensed under Apache License 2.0.
# Original source:
# https://github.com/singh-inder/supabase-automated-self-host/blob/main/docker/update-db-pass.sh
#
# GitHub discussion here:
# https://github.com/supabase/supabase/issues/22605#issuecomment-3323382144
#
# Changed:
# - POSIX shell compatibility
# - No hardcoded values for database service and admin user
# - Use .env for the admin user and database service port
# - Does _not_ set password for supabase_read_only_user (this role is not
# supposed to have a password)
# - Print all values and confirm before updating
# - Stop on any errors
#
# Heads up:
# - Updating _analytics.source_backends is not needed after PR logflare#2069
# - Newer Logflare versions use a different table and update connection string
#
set -e
if ! docker compose version > /dev/null 2>&1; then
echo "Docker Compose not found."
exit 1
fi
if [ ! -f .env ]; then
echo "Missing .env file. Exiting."
exit 1
fi
# Generate random hex-only password to avoid issues with SQL/shell
new_passwd="$(openssl rand -hex 16)"
# If replacing with a custom password, avoid using @/?#:&
# https://supabase.com/docs/guides/database/postgres/roles#passwords
# new_passwd="d0notUseSpecialSymbolsForPq123-"
# Check Postgres service
db_image_prefix="supabase.postgres:"
compose_output=$(docker compose ps \
--format '{{.Image}}\t{{.Service}}\t{{.Status}}' 2>/dev/null | \
grep -m1 "^$db_image_prefix" || true)
if [ -z "$compose_output" ]; then
echo "Postgres container not found. Exiting."
exit 1
fi
db_image=$(echo "$compose_output" | cut -f1)
db_srv_name=$(echo "$compose_output" | cut -f2)
db_srv_status=$(echo "$compose_output" | cut -f3)
case "$db_srv_status" in
Up*)
;;
*)
echo "Postgres container status: $db_srv_status"
echo "Exiting."
exit 1
;;
esac
db_srv_port=$(grep "^POSTGRES_PORT=" .env | cut -d '=' -f 2)
port_source=" (.env):"
if [ -z "$db_srv_port" ]; then
db_srv_port="5432"
port_source=" (default):"
fi
db_admin_user="supabase_admin"
echo ""
echo "*** Check configuration below before updating database passwords! ***"
echo ""
echo "Service name: $db_srv_name"
echo "Service status: $db_srv_status"
echo "Service port${port_source} $db_srv_port"
echo "Image: $db_image"
echo ""
echo "Admin user: $db_admin_user"
if ! test -t 0; then
echo ""
echo "Running non-interactively. Not updating passwords."
exit 0
fi
echo "New database password: $new_passwd"
echo ""
printf "Update database passwords? (y/N) "
read -r REPLY
case "$REPLY" in
[Yy])
;;
*)
echo "Canceled. Not updating passwords."
exit 0
;;
esac
echo "Updating passwords..."
echo "Connecting to the database service container..."
docker compose exec -T "$db_srv_name" psql -U "$db_admin_user" -d "_supabase" -v ON_ERROR_STOP=1 <<EOF
alter user anon with password '${new_passwd}';
alter user authenticated with password '${new_passwd}';
alter user authenticator with password '${new_passwd}';
alter user dashboard_user with password '${new_passwd}';
alter user pgbouncer with password '${new_passwd}';
alter user postgres with password '${new_passwd}';
alter user service_role with password '${new_passwd}';
alter user supabase_admin with password '${new_passwd}';
alter user supabase_auth_admin with password '${new_passwd}';
alter user supabase_functions_admin with password '${new_passwd}';
alter user supabase_replication_admin with password '${new_passwd}';
alter user supabase_storage_admin with password '${new_passwd}';
DROP SCHEMA _supavisor CASCADE;
create schema if not exists _supavisor;
alter schema _supavisor owner to supabase_admin;
DO \$\$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = '_analytics'
AND table_name = 'source_backends'
) THEN
UPDATE _analytics.source_backends
SET config = jsonb_set(
config,
'{url}',
'"postgresql://${db_admin_user}:${new_passwd}@${db_srv_name}:${db_srv_port}/postgres"',
false
)
WHERE type = 'postgres';
END IF;
END
\$\$;
EOF
echo "Updating POSTGRES_PASSWORD in .env..."
sed -i.old "s|^POSTGRES_PASSWORD=.*$|POSTGRES_PASSWORD=$new_passwd|" .env
echo ""
echo "Success. To update and restart containers use:"
echo ""
echo "docker compose up -d --force-recreate"
echo ""
@@ -0,0 +1,138 @@
#!/bin/sh
#
# Generate secrets and legacy symmetric JWT API keys for self-hosted Supabase.
#
# Generates: JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY, and other secrets
# needed for a fresh installation.
#
# Usage:
# sh generate-keys.sh # Interactive: prints keys, prompts to update .env
# sh generate-keys.sh --update-env # Prints keys and writes them to .env
# sh generate-keys.sh | tee keys # Non-interactive: prints keys only
#
# Portions of this code are derived from Inder Singh's setup.sh shell script.
# Copyright 2025 Inder Singh. Licensed under Apache License 2.0.
# Original source: https://github.com/singh-inder/supabase-automated-self-host/blob/main/setup.sh
#
set -e
gen_hex() {
openssl rand -hex "$1"
}
gen_base64() {
openssl rand -base64 "$1"
}
base64_url_encode() {
openssl enc -base64 -A | tr '+/' '-_' | tr -d '='
}
gen_token() {
payload=$1
payload_base64=$(printf %s "$payload" | base64_url_encode)
header_base64=$(printf %s "$header" | base64_url_encode)
signed_content="${header_base64}.${payload_base64}"
signature=$(printf %s "$signed_content" | openssl dgst -binary -sha256 -hmac "$jwt_secret" | base64_url_encode)
printf '%s' "${signed_content}.${signature}"
}
if ! command -v openssl >/dev/null 2>&1; then
echo "Error: openssl is required but not found."
exit 1
fi
jwt_secret="$(gen_base64 30)"
# Used in gen_token()
header='{"alg":"HS256","typ":"JWT"}'
iat=$(date +%s)
exp=$((iat + 5 * 3600 * 24 * 365)) # 5 years
# Normalizes JSON formatting so that the token matches https://www.jwt.io/ results
anon_payload="{\"role\":\"anon\",\"iss\":\"supabase\",\"iat\":$iat,\"exp\":$exp}"
service_role_payload="{\"role\":\"service_role\",\"iss\":\"supabase\",\"iat\":$iat,\"exp\":$exp}"
#echo "anon_payload=$anon_payload"
#echo "service_role_payload=$service_role_payload"
anon_key=$(gen_token "$anon_payload")
service_role_key=$(gen_token "$service_role_payload")
secret_key_base=$(gen_base64 48)
realtime_db_enc_key=$(gen_hex 8)
vault_enc_key=$(gen_hex 16)
pg_meta_crypto_key=$(gen_base64 24)
logflare_public_access_token=$(gen_base64 24)
logflare_private_access_token=$(gen_base64 24)
s3_protocol_access_key_id=$(gen_hex 16)
s3_protocol_access_key_secret=$(gen_hex 32)
minio_root_password=$(gen_hex 16)
echo ""
echo "JWT_SECRET=${jwt_secret}"
echo ""
#echo "Issued at: $iat"
#echo "Expire: $exp"
echo "ANON_KEY=${anon_key}"
echo "SERVICE_ROLE_KEY=${service_role_key}"
echo ""
echo "SECRET_KEY_BASE=${secret_key_base}"
echo "REALTIME_DB_ENC_KEY=${realtime_db_enc_key}"
echo "VAULT_ENC_KEY=${vault_enc_key}"
echo "PG_META_CRYPTO_KEY=${pg_meta_crypto_key}"
echo "LOGFLARE_PUBLIC_ACCESS_TOKEN=${logflare_public_access_token}"
echo "LOGFLARE_PRIVATE_ACCESS_TOKEN=${logflare_private_access_token}"
echo "S3_PROTOCOL_ACCESS_KEY_ID=${s3_protocol_access_key_id}"
echo "S3_PROTOCOL_ACCESS_KEY_SECRET=${s3_protocol_access_key_secret}"
echo "MINIO_ROOT_PASSWORD=${minio_root_password}"
echo ""
postgres_password=$(gen_hex 16)
dashboard_password=$(gen_hex 16)
echo "POSTGRES_PASSWORD=${postgres_password}"
echo "DASHBOARD_PASSWORD=${dashboard_password}"
echo ""
if [ "$1" = "--update-env" ]; then
update_env=true
elif test -t 0; then
printf "Update .env file? (y/N) "
read -r REPLY
case "$REPLY" in
[Yy]) update_env=true ;;
*) update_env=false ;;
esac
else
echo "Running non-interactively. Pass --update-env to write to .env."
update_env=false
fi
if [ "$update_env" != "true" ]; then
exit 0
fi
echo "Updating .env..."
sed \
-i.old \
-e "s|^JWT_SECRET=.*$|JWT_SECRET=${jwt_secret}|" \
-e "s|^ANON_KEY=.*$|ANON_KEY=${anon_key}|" \
-e "s|^SERVICE_ROLE_KEY=.*$|SERVICE_ROLE_KEY=${service_role_key}|" \
-e "s|^SECRET_KEY_BASE=.*$|SECRET_KEY_BASE=${secret_key_base}|" \
-e "s|^REALTIME_DB_ENC_KEY=.*$|REALTIME_DB_ENC_KEY=${realtime_db_enc_key}|" \
-e "s|^VAULT_ENC_KEY=.*$|VAULT_ENC_KEY=${vault_enc_key}|" \
-e "s|^PG_META_CRYPTO_KEY=.*$|PG_META_CRYPTO_KEY=${pg_meta_crypto_key}|" \
-e "s|^LOGFLARE_PUBLIC_ACCESS_TOKEN=.*$|LOGFLARE_PUBLIC_ACCESS_TOKEN=${logflare_public_access_token}|" \
-e "s|^LOGFLARE_PRIVATE_ACCESS_TOKEN=.*$|LOGFLARE_PRIVATE_ACCESS_TOKEN=${logflare_private_access_token}|" \
-e "s|^S3_PROTOCOL_ACCESS_KEY_ID=.*$|S3_PROTOCOL_ACCESS_KEY_ID=${s3_protocol_access_key_id}|" \
-e "s|^S3_PROTOCOL_ACCESS_KEY_SECRET=.*$|S3_PROTOCOL_ACCESS_KEY_SECRET=${s3_protocol_access_key_secret}|" \
-e "s|^MINIO_ROOT_PASSWORD=.*$|MINIO_ROOT_PASSWORD=${minio_root_password}|" \
-e "s|^POSTGRES_PASSWORD=.*$|POSTGRES_PASSWORD=${postgres_password}|" \
-e "s|^DASHBOARD_PASSWORD=.*$|DASHBOARD_PASSWORD=${dashboard_password}|" \
.env
@@ -0,0 +1,153 @@
#!/bin/sh
#
# Reassign ownership of public schema objects from supabase_admin to postgres.
#
# Context and documentation:
# https://supabase.com/docs/guides/self-hosting/remove-superuser-access
#
# Credits:
# Original version by Inder Singh.
#
# Usage:
# sh utils/reassign-owner.sh
#
set -e
if ! docker compose version >/dev/null 2>&1; then
echo "Docker Compose not found."
exit 1
fi
# Check Postgres service
db_image_prefix="supabase.postgres:"
compose_output=$(docker compose ps \
--format '{{.Image}}\t{{.Service}}\t{{.Status}}' 2>/dev/null |
grep -m1 "^$db_image_prefix" || true)
if [ -z "$compose_output" ]; then
echo "Postgres container not found. Exiting."
exit 1
fi
db_srv_name=$(echo "$compose_output" | cut -f2)
db_srv_status=$(echo "$compose_output" | cut -f3)
case "$db_srv_status" in
Up*)
;;
*)
echo "Postgres container status: $db_srv_status"
echo "Exiting."
exit 1
;;
esac
if ! test -t 0; then
echo ""
echo "Running non-interactively. Not reassigning ownership."
exit 0
fi
printf "Reassign public schema objects to postgres user? (y/N) "
read -r REPLY
case "$REPLY" in
[Yy])
;;
*)
echo "Canceled. Not reassigning ownership."
exit 0
;;
esac
docker compose exec -T "$db_srv_name" psql -v ON_ERROR_STOP=1 -U supabase_admin -d postgres <<'EOF'
\echo 'Current supabase_admin-owned objects in public schema:'
SELECT c.relname, c.relkind, c.relowner::regrole
FROM pg_class c
WHERE c.relnamespace = 'public'::regnamespace
AND c.relowner = 'supabase_admin'::regrole;
-- Reassign user objects in public schema from supabase_admin to postgres.
-- (Only affects public schema; Supabase-managed schemas stay as-is.
-- Extension-owned objects are skipped.)
DO $$
DECLARE
rec record;
rel_count int := 0;
fn_count int := 0;
type_count int := 0;
BEGIN
-- Tables, views, sequences, materialized views, partitioned tables
FOR rec IN
SELECT c.relname, c.relkind
FROM pg_class c
WHERE c.relnamespace = 'public'::regnamespace
AND c.relowner = 'supabase_admin'::regrole
AND c.relkind IN ('r', 'v', 'S', 'm', 'p')
AND NOT EXISTS (
SELECT 1 FROM pg_depend d
WHERE d.classid = 'pg_class'::regclass
AND d.objid = c.oid
AND d.deptype = 'e'
)
ORDER BY CASE c.relkind
WHEN 'p' THEN 0 -- partitioned parents first; cascades ownership to partitions
WHEN 'm' THEN 1
WHEN 'r' THEN 2
WHEN 'v' THEN 3
WHEN 'S' THEN 4
END
LOOP
EXECUTE format('ALTER TABLE public.%I OWNER TO postgres', rec.relname);
rel_count := rel_count + 1;
END LOOP;
-- Functions and procedures
FOR rec IN
SELECT p.oid, p.proname, pg_get_function_identity_arguments(p.oid) AS args
FROM pg_proc p
WHERE p.pronamespace = 'public'::regnamespace
AND p.proowner = 'supabase_admin'::regrole
AND NOT EXISTS (
SELECT 1 FROM pg_depend d
WHERE d.classid = 'pg_proc'::regclass
AND d.objid = p.oid
AND d.deptype = 'e'
)
LOOP
EXECUTE format('ALTER ROUTINE public.%I(%s) OWNER TO postgres', rec.proname, rec.args);
fn_count := fn_count + 1;
END LOOP;
-- Types (excluding array types and table-bound composites)
FOR rec IN
SELECT t.typname
FROM pg_type t
WHERE t.typnamespace = 'public'::regnamespace
AND t.typowner = 'supabase_admin'::regrole
AND t.typrelid = 0
AND NOT EXISTS (
SELECT 1 FROM pg_type el
WHERE el.oid = t.typelem
AND el.typarray = t.oid
)
AND NOT EXISTS (
SELECT 1 FROM pg_depend d
WHERE d.classid = 'pg_type'::regclass
AND d.objid = t.oid
AND d.deptype = 'e'
)
LOOP
EXECUTE format('ALTER TYPE public.%I OWNER TO postgres', rec.typname);
type_count := type_count + 1;
END LOOP;
RAISE NOTICE 'Reassigned % relation(s), % routine(s), % type(s) from supabase_admin to postgres.',
rel_count, fn_count, type_count;
END
$$;
EOF
echo ""
echo "Done."
@@ -0,0 +1,117 @@
#!/bin/sh
#
# Rotate opaque API keys for a self-hosted Supabase installation.
#
# Regenerates SUPABASE_PUBLISHABLE_KEY and SUPABASE_SECRET_KEY
# without touching the asymmetric key pair (JWKS) or JWT tokens.
#
# Usage:
# sh rotate-new-api-keys.sh # Interactive: prints keys, prompts to update .env
# sh rotate-new-api-keys.sh --update-env # Prints keys and writes them to .env
# sh rotate-new-api-keys.sh | tee keys # Non-interactive: prints keys only
#
# Prerequisites:
# - .env file (run generate-keys.sh and add-new-auth-keys.sh first)
# - node (>= 16) or docker
#
set -e
node_ok() {
command -v node >/dev/null 2>&1 || return 1
major=$(node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1)
[ -n "$major" ] && [ "$major" -ge 16 ] 2>/dev/null
}
# Resolve how to run node: local install (>= 16) preferred, docker fallback.
if node_ok; then
node_runner="node"
else
if command -v node >/dev/null 2>&1; then
echo "Local node $(node -v) is too old (need >= 16), falling back to docker."
fi
if ! command -v docker >/dev/null 2>&1; then
echo "Error: requires either node (>= 16) or docker."
exit 1
fi
if ! docker info >/dev/null 2>&1; then
echo "Error: docker is installed but the daemon is not running."
exit 1
fi
if ! docker image inspect node:22-alpine >/dev/null 2>&1; then
echo "Pulling node:22-alpine (first-run only)..."
docker pull node:22-alpine
fi
node_runner="docker run --rm node:22-alpine node"
fi
if [ ! -f .env ]; then
echo "Error: .env file not found. Run generate-keys.sh first."
exit 1
fi
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
$node_runner -e '
const crypto = require("crypto");
const PROJECT_REF = "supabase-self-hosted";
function generateOpaqueKey(prefix) {
const random = crypto.randomBytes(17).toString("base64url").slice(0, 22);
const intermediate = prefix + random;
const checksum = crypto.createHash("sha256")
.update(PROJECT_REF + "|" + intermediate)
.digest("base64url")
.slice(0, 8);
return intermediate + "_" + checksum;
}
const publishableKey = generateOpaqueKey("sb_publishable_");
const secretKey = generateOpaqueKey("sb_secret_");
console.log("SUPABASE_PUBLISHABLE_KEY=" + publishableKey);
console.log("SUPABASE_SECRET_KEY=" + secretKey);
' > "$tmpdir/output"
SUPABASE_PUBLISHABLE_KEY=$(grep '^SUPABASE_PUBLISHABLE_KEY=' "$tmpdir/output" | cut -d= -f2-)
SUPABASE_SECRET_KEY=$(grep '^SUPABASE_SECRET_KEY=' "$tmpdir/output" | cut -d= -f2-)
echo ""
echo "SUPABASE_PUBLISHABLE_KEY=${SUPABASE_PUBLISHABLE_KEY}"
echo "SUPABASE_SECRET_KEY=${SUPABASE_SECRET_KEY}"
echo ""
if [ "$1" = "--update-env" ]; then
update_env=true
elif test -t 0; then
printf "Update .env file? (y/N) "
read -r REPLY
case "$REPLY" in
[Yy]) update_env=true ;;
*) update_env=false ;;
esac
else
echo "Running non-interactively. Pass --update-env to write to .env."
update_env=false
fi
if [ "$update_env" != "true" ]; then
exit 0
fi
echo "Updating .env..."
for var in SUPABASE_PUBLISHABLE_KEY SUPABASE_SECRET_KEY; do
eval "val=\$$var"
if grep -q "^${var}=" .env; then
sed -i.old -e "s|^${var}=.*$|${var}=${val}|" .env
else
echo "${var}=${val}" >> .env
fi
done
+822
View File
@@ -0,0 +1,822 @@
#!/usr/bin/env bash
#
# Requires bash (not sh) for pipefail, which ensures failures in piped
# commands are caught during the upgrade.
#
# Upgrade self-hosted Supabase Postgres from 15 to 17.
#
# Uses Supabase's pg_upgrade scripts (initiate.sh + complete.sh) inside a
# temporary PG 15 container, then swaps data directories and starts Postgres 17.
#
# Usage (must be run as root or with sudo):
# cd docker/
# sudo bash utils/upgrade-pg17.sh # Interactive (prompts for confirmation)
# sudo bash utils/upgrade-pg17.sh --yes # Non-interactive (skip all prompts)
#
# Requirements:
# - Docker with Docker Compose (docker compose, not docker-compose)
# - Running Supabase self-hosted setup with Postgres 15
# - At least 2x current database size + 5 GB free disk space
#
# Backup:
# The original Postgres 15 data directory is preserved as
# ./volumes/db/data.bak.pg15 during the upgrade.
# DO NOT DELETE it until you have verified the upgrade was successful.
#
# Rollback (if the upgrade fails or you want to revert):
# 1. docker compose -f docker-compose.yml -f docker-compose.pg17.yml down
# 2. rm -rf ./volumes/db/data
# 3. mv ./volumes/db/data.bak.pg15 ./volumes/db/data
# 4. docker compose -f docker-compose.yml -f docker-compose.pg15.yml run --rm db chown -R postgres:postgres /etc/postgresql-custom/
# 5. docker compose -f docker-compose.yml -f docker-compose.pg15.yml up -d
#
# Ensure we're running under bash (not sh/zsh/dash).
# Check that $BASH ends with /bash (not /sh, /zsh, etc.).
case "${BASH:-}" in
*/bash) ;;
*) echo "Error: This script requires bash. Run it with: sudo bash $0" >&2; exit 1 ;;
esac
set -euo pipefail
AUTO_CONFIRM=false
for arg in "$@"; do
case "$arg" in
--yes|-y) AUTO_CONFIRM=true ;;
esac
done
# --- Configuration ----------------------------------------------------------
# Image used for the upgrade tarball + complete.sh container.
# Must share glibc with PG 15 (the extracted ELF binaries run inside PG15).
# Pinned to .063: later images bumped glibc, which breaks the ELF extraction.
PG17_UPGRADE_IMAGE="supabase/postgres:17.6.1.063"
# Tag in supabase/postgres repo matching the upgrade image (for downloading scripts)
PG17_SCRIPTS_REF="17.6.1.063"
# Final Postgres 17 image that runs after the upgrade. Pinned here (not read from
# the compose files). Keep in sync with the db image in docker-compose.yml (and
# docker-compose.pg17.yml). Used for pulling, chowning data/db-config to the
# target's postgres UID, and running the post-upgrade migrations.
PG17_TARGET_IMAGE="supabase/postgres:17.6.1.136"
DB_CONTAINER="supabase-db"
UPGRADE_CONTAINER="supabase-pg-upgrade"
COMPLETE_CONTAINER="supabase-pg-complete"
DATA_DIR="./volumes/db/data"
BACKUP_DIR="./volumes/db/data.bak.pg15"
# Include image tag in cache filename so changing PG17_UPGRADE_IMAGE invalidates it
PG17_TAG="${PG17_UPGRADE_IMAGE##*:}"
TARBALL_CACHE="./volumes/db/pg17_upgrade_bin_${PG17_TAG}.tar.gz"
# initiate.sh writes pg_upgrade output here: pgdata/, conf/, sql/
MIGRATION_DIR="./volumes/db/data_migration"
# --- Helpers ----------------------------------------------------------------
die() { printf 'Error: %s\n' "$*" >&2; exit 1; }
info() { printf '\n==> %s\n' "$*"; }
warn() { printf 'Warning: %s\n' "$*" >&2; }
# Temp dir on host for tarball + scripts (mounted into containers)
staging_dir=""
pg_password=""
current_image=""
drop_extensions=""
db_config_vol=""
# Remove leftover containers and staging dir on exit.
# Uses an alpine container for rm because the tarball build runs as root
# inside Docker - the resulting files are root-owned and can't be deleted
# by the host user on macOS.
cleanup() {
docker rm -f "$UPGRADE_CONTAINER" >/dev/null 2>&1 || true
docker rm -f "$COMPLETE_CONTAINER" >/dev/null 2>&1 || true
if [ -n "$staging_dir" ] && [ -d "$staging_dir" ]; then
docker run --rm -v "$staging_dir:/cleanup" alpine rm -rf /cleanup 2>/dev/null || true
rm -rf "$staging_dir" 2>/dev/null || true
fi
}
trap cleanup EXIT
on_interrupt() {
echo ""
warn "Interrupted. Cleaning up..."
# If db-config was chowned to PG17, restore for PG 15 rollback
if [ -n "$db_config_vol" ] && [ -n "$current_image" ]; then
docker run --rm -v "${db_config_vol}:/vol" "$current_image" \
chown -R postgres:postgres /vol/ 2>/dev/null || true
fi
die "Interrupted."
}
trap on_interrupt INT
confirm() {
if [ "$AUTO_CONFIRM" = true ]; then return 0; fi
if ! test -t 0; then
die "This script must be run interactively, or use --yes to skip prompts."
fi
printf '%s (y/N) ' "$1"
read -r reply
case "$reply" in
[Yy]*) return 0 ;;
*) echo "Aborted."; exit 0 ;;
esac
}
run_sql_on() {
local container=$1; shift
docker exec -i \
-e PGPASSWORD="$pg_password" \
"$container" \
psql -h localhost -U supabase_admin -d postgres -v ON_ERROR_STOP=1 "$@"
}
wait_for_healthy() {
local container=$1 retries=30
while [ $retries -gt 0 ]; do
if docker exec "$container" pg_isready -U postgres -h localhost >/dev/null 2>&1; then
return 0
fi
retries=$((retries - 1))
sleep 1
done
die "Postgres in '$container' did not become ready in 30 seconds."
}
# --- Pre-flight checks -----------------------------------------------------
preflight() {
info "Running pre-flight checks"
if [ "$(id -u)" -ne 0 ]; then
die "This script must be run as root (e.g. sudo bash $0)."
fi
docker compose version >/dev/null 2>&1 || die "Docker Compose not found."
command -v curl >/dev/null 2>&1 || die "curl is required (for downloading upgrade scripts)."
[ -f docker-compose.yml ] || die "Run this script from the docker/ directory."
[ -f docker-compose.pg17.yml ] || die "Missing docker-compose.pg17.yml."
[ -f .env ] || die "Missing .env file."
# Resolve db-config volume (exact match on _db-config suffix or bare db-config)
db_config_vol=$(docker volume ls --filter "name=db-config" --format '{{.Name}}' \
| grep -E '^db-config$|_db-config$' | head -n 1)
[ -n "$db_config_vol" ] || die "Could not find db-config volume. Is Supabase running?"
pg_password=$(grep '^POSTGRES_PASSWORD=' .env | cut -d '=' -f 2- | sed "s/^['\"]//;s/['\"]$//" | head -n 1)
[ -n "$pg_password" ] || die "POSTGRES_PASSWORD not set in .env."
docker inspect "$DB_CONTAINER" >/dev/null 2>&1 \
|| die "Container '$DB_CONTAINER' not found. Is Supabase running?"
current_image=$(docker inspect "$DB_CONTAINER" --format '{{.Config.Image}}')
case "$current_image" in
supabase/postgres:15.*|supabase.postgres:15.*) ;;
supabase/postgres:17.*|supabase.postgres:17.*) die "Already running Postgres 17 ($current_image)." ;;
*) die "Unexpected database image: $current_image" ;;
esac
local status
status=$(docker inspect "$DB_CONTAINER" --format '{{.State.Status}}')
[ "$status" = "running" ] || die "'$DB_CONTAINER' is not running (status: $status)."
[ -d "$DATA_DIR" ] || die "Data directory not found: $DATA_DIR"
if [ -d "$BACKUP_DIR" ]; then
warn "Backup directory already exists: $BACKUP_DIR"
warn "This is likely from a previous upgrade attempt."
warn "If you haven't verified that previous upgrade, roll back first:"
warn " 1. docker compose -f docker-compose.yml -f docker-compose.pg17.yml down"
warn " 2. rm -rf $DATA_DIR"
warn " 3. mv $BACKUP_DIR $DATA_DIR"
warn " 4. docker compose -f docker-compose.yml -f docker-compose.pg15.yml run --rm db chown -R postgres:postgres /etc/postgresql-custom/"
warn " 5. docker compose -f docker-compose.yml -f docker-compose.pg15.yml up -d"
echo ""
warn "Continuing will DELETE the existing backup permanently."
confirm "Delete $BACKUP_DIR and start a fresh upgrade?"
rm -rf "$BACKUP_DIR"
fi
if [ -d "$MIGRATION_DIR" ]; then
rm -rf "$MIGRATION_DIR"
fi
# Disk space
local data_size_kb data_size_mb avail_kb avail_mb needed_mb
data_size_kb=$(du -sk "$DATA_DIR" 2>/dev/null | cut -f1)
[ -n "$data_size_kb" ] || die "Could not calculate data size for $DATA_DIR"
data_size_mb=$((data_size_kb / 1024))
avail_kb=$(df -k "$(dirname "$DATA_DIR")" | awk 'NR==2 { print $4 }')
[ -n "$avail_kb" ] || die "Could not calculate available disk space for $(dirname "$DATA_DIR")"
avail_mb=$((avail_kb / 1024))
needed_mb=$((data_size_mb * 2 + 5000))
echo " Data size: ${data_size_mb} MB"
echo " Available space: ${avail_mb} MB"
echo " Estimated need: ${needed_mb} MB"
if [ "$avail_mb" -lt "$needed_mb" ]; then
warn "Disk space may be insufficient."
warn "pg_upgrade copies data; need ~2x data size + ~5 GB for the upgrade tarball."
confirm "Continue anyway?"
fi
# Incompatible extensions
info "Checking for incompatible extensions"
local incompatible
incompatible=$(run_sql_on "$DB_CONTAINER" -A -t -c "
SELECT string_agg(extname, ', ')
FROM pg_extension
WHERE extname IN ('timescaledb', 'plv8', 'plcoffee', 'plls');
" 2>/dev/null | tr -d '[:space:]') || true
if [ -n "$incompatible" ]; then
warn "Incompatible extensions found: $incompatible"
warn "These do not exist in Postgres 17 and must be dropped before upgrading."
warn "If you proceed, they will be dropped automatically."
warn "The original data is preserved as a backup so you can roll back."
confirm "Drop these extensions and continue with the upgrade?"
drop_extensions="$incompatible"
fi
echo ""
echo "This script will:"
echo " 1. Pull the Postgres 17 image"
echo " 2. Build an upgrade tarball from the image (~1.2 GB compressed, temporary)"
echo " 3. Stop all Supabase services"
echo " 4. Run pg_upgrade (Postgres 15 -> 17)"
echo " 5. Apply post-upgrade patches"
echo " 6. Start Supabase with Postgres 17"
echo " 7. Apply additional migrations"
echo ""
echo " Current image: $current_image"
echo " Target image: $PG17_TARGET_IMAGE"
echo " Upgrade image: $PG17_UPGRADE_IMAGE"
echo " Data directory: $DATA_DIR"
echo " Backup location: $BACKUP_DIR"
echo ""
confirm "Proceed with the upgrade?"
}
# --- Step 1: Pull Postgres 17 image ----------------------------------------
pull_image() {
info "Pulling Postgres 17 images"
docker pull "$PG17_UPGRADE_IMAGE"
if [ "$PG17_TARGET_IMAGE" != "$PG17_UPGRADE_IMAGE" ]; then
docker pull "$PG17_TARGET_IMAGE"
fi
}
# --- Step 2: Build upgrade tarball -----------------------------------------
#
# Extracts PG 17 binaries, libraries, share data, and upgrade scripts from
# the PG 17 Docker image into a tarball that initiate.sh can consume.
#
# The tarball uses the "non-nix" layout (17/bin, 17/lib, 17/share - no
# nix_flake_version file), so initiate.sh sets LD_LIBRARY_PATH to find
# the bundled libraries.
build_tarball() {
local tmpbase="${TMPDIR:-/tmp}"
staging_dir=$(mktemp -d "${tmpbase%/}/supabase-pg17-upgrade.XXXXXX")
# World-writable so Docker containers can write to bind mounts on macOS,
# where the VM's root user has no special access to host directories.
chmod 777 "$staging_dir"
echo " Staging directory: $staging_dir"
# Download upgrade scripts from the supabase/postgres repo (pinned to PG17_SCRIPTS_REF).
# These are no longer bundled in the latest PG 17 Docker images.
info "Downloading upgrade scripts (ref: $PG17_SCRIPTS_REF)"
local scripts_base="https://raw.githubusercontent.com/supabase/postgres/${PG17_SCRIPTS_REF}/ansible/files/admin_api_scripts/pg_upgrade_scripts"
mkdir -p "$staging_dir/scripts"
for script in initiate.sh complete.sh common.sh pgsodium_getkey.sh check.sh prepare.sh; do
curl -fsSL "$scripts_base/$script" -o "$staging_dir/scripts/$script" \
|| die "Failed to download $script from GitHub"
done
if [ -f "$TARBALL_CACHE" ]; then
info "Using cached upgrade tarball: $TARBALL_CACHE"
cp "$TARBALL_CACHE" "$staging_dir/pg_upgrade_bin.tar.gz"
return
fi
info "Building upgrade tarball from Postgres 17 image (first run)"
docker run --rm --user root --entrypoint bash \
-v "$staging_dir:/export" \
"$PG17_UPGRADE_IMAGE" \
-c '
set -euo pipefail
mkdir -p /export/17/bin /export/17/lib /export/17/share
echo " Copying binaries..."
# Binaries in the nix profile are either ELF binaries or shell
# wrappers that exec a .xxx-wrapped ELF from the nix store.
# Extract the actual ELF binaries so they work outside nix.
BIN_DIR=$(dirname $(readlink -f /usr/lib/postgresql/bin/postgres))
for f in "$BIN_DIR"/*; do
name=$(basename "$f")
# Skip nix wrapper-internal files
case "$name" in .*-wrapped) continue ;; esac
# Check for ELF
if [ -x "$f" ] && file -b "$f" | grep -q "ELF .* executable"; then
cp "$f" /export/17/bin/"$name"
else
# Shell wrapper - extract the real .xxx-wrapped ELF path
wrapped=$(grep -o "/nix/store/[^ \"]*-wrapped" "$f" 2>/dev/null | head -n 1 || true)
if [ -n "$wrapped" ] && [ -f "$wrapped" ]; then
cp "$wrapped" /export/17/bin/"$name"
else
cp "$f" /export/17/bin/"$name"
fi
fi
done
echo " Copying libraries..."
PKGLIBDIR=$(pg_config --pkglibdir)
LIBDIR=$(pg_config --libdir)
# These paths may overlap (PKGLIBDIR and LIBDIR often point to the
# same nix store path). Use cp -Lf to handle overwrites from
# read-only nix store source files.
cp -Lf "$PKGLIBDIR"/*.so /export/17/lib/ || echo " Warning: cp from $PKGLIBDIR failed" >&2
cp -Lf "$LIBDIR"/*.so* /export/17/lib/ || echo " Warning: cp from $LIBDIR failed" >&2
cp -Lf /nix/var/nix/profiles/default/lib/*.so* /export/17/lib/ || echo " Warning: cp from nix profile lib failed" >&2
echo " Copying share data..."
# Nix-built binaries resolve share dir relative to their location:
# <bindir>/../share/postgresql/
# so we need share/postgresql/ not just share/
mkdir -p /export/17/share/postgresql
# Remove cyclic symlink (timezonesets/timezonesets -> timezonesets).
rm -f /usr/share/postgresql/timezonesets/timezonesets 2>/dev/null || true
# Pre-create subdirectories so cp -rL does not need to mkdir them.
# On macOS with Docker Desktop bind mount rejects mkdir with the nix store
# read-only (dr-xr-xr-x) permissions; pre-creating with default
# writable permissions fixed this
mkdir -p /export/17/share/postgresql/{extension,timezonesets,tsearch_data}
mkdir -p /export/17/share/postgresql/extension/{functions,procedures,tables,types}
cp -rL /usr/share/postgresql/* /export/17/share/postgresql/ || echo " Warning: cp share data had errors" >&2
# initiate.sh copies .control/.sql from PGLIBNEW to PGSHARENEW/extension/
echo " Copying extension definitions to lib..."
SHAREDIR=$(pg_config --sharedir)
cp "$SHAREDIR"/extension/*.control /export/17/lib/ || echo " Warning: cp .control from $SHAREDIR/extension failed" >&2
cp "$SHAREDIR"/extension/*.sql /export/17/lib/ || echo " Warning: cp .sql from $SHAREDIR/extension failed" >&2
# Verify critical files before creating tarball
echo " Checking for key files..."
[ -f /export/17/bin/postgres ] || { echo "Error: bin/postgres missing"; exit 1; }
[ -f /export/17/share/postgresql/timezonesets/Default ] || { echo "Error: timezonesets/Default missing"; exit 1; }
ls /export/17/share/postgresql/extension/*.control >/dev/null 2>&1 || { echo "Error: no .control files in extension/"; exit 1; }
ls /export/17/lib/*.so >/dev/null 2>&1 || { echo "Error: no .so files in lib/"; exit 1; }
echo " Creating tarball (this may take several minutes)..."
cd /export && tar czf pg_upgrade_bin.tar.gz 17/
echo " Tarball: $(du -sh /export/pg_upgrade_bin.tar.gz | cut -f1)"
'
# Cache for next run
cp "$staging_dir/pg_upgrade_bin.tar.gz" "$TARBALL_CACHE"
info "Tarball cached at $TARBALL_CACHE"
}
# --- Step 3: Drop incompatible extensions ----------------------------------
drop_incompatible_extensions() {
if [ -z "$drop_extensions" ]; then
return
fi
info "Dropping incompatible extensions"
local ext
echo "$drop_extensions" | tr ',' '\n' | while read -r ext; do
ext=$(echo "$ext" | tr -d '[:space:]')
[ -z "$ext" ] && continue
echo " DROP EXTENSION $ext CASCADE"
run_sql_on "$DB_CONTAINER" -c "DROP EXTENSION IF EXISTS \"$ext\" CASCADE;"
done
}
# --- Step 4: Stop services and back up -------------------------------------
stop_and_backup() {
info "Backing up pgsodium root key"
local key_backup="./volumes/db/pgsodium_root.key.bak.pg15"
docker run --rm -v "${db_config_vol}:/src:ro" -v "$(pwd)/volumes/db:/dst" \
alpine cp /src/pgsodium_root.key /dst/pgsodium_root.key.bak.pg15 \
|| die "Failed to back up pgsodium root key from db-config volume."
echo " Saved to: $key_backup"
info "Stopping all Supabase services"
docker compose down
echo " Original data will be preserved as: $BACKUP_DIR"
}
# --- Step 5: Run pg_upgrade via initiate.sh + complete.sh ------------------
#
# Host directories are mounted at non-standard paths (/mnt/host-*) with
# symlinks at the paths the upgrade scripts expect. This lets complete.sh's
# CI wrapper (which does rm/mv/ln on /var/lib/postgresql/data and
# /data_migration) operate on symlinks rather than bind mounts.
run_upgrade() {
local abs_data_dir abs_migration_dir
mkdir -p "$MIGRATION_DIR"
# World-writable for macOS Docker bind mount compatibility (see build_tarball)
chmod 777 "$MIGRATION_DIR"
abs_data_dir=$(cd "$DATA_DIR" && pwd)
abs_migration_dir=$(cd "$MIGRATION_DIR" && pwd)
info "Starting upgrade container"
docker run -d --name "$UPGRADE_CONTAINER" \
--entrypoint sleep \
-v "${abs_data_dir}:/mnt/host-pgdata" \
-v "${abs_migration_dir}:/mnt/host-migration" \
-v "${db_config_vol}:/etc/postgresql-custom" \
-v "${staging_dir}:/tmp/staging:ro" \
-e PGPASSWORD="$pg_password" \
"$current_image" \
infinity
info "Preparing upgrade environment"
docker exec "$UPGRADE_CONTAINER" bash -c '
# Symlink bind mounts to the paths the upgrade scripts expect
rm -rf /var/lib/postgresql/data
ln -s /mnt/host-pgdata /var/lib/postgresql/data
ln -s /mnt/host-migration /data_migration
mkdir -p /tmp/persistent /tmp/upgrade /tmp/pg_upgrade
cp /tmp/staging/pg_upgrade_bin.tar.gz /tmp/persistent/
cp /tmp/staging/scripts/*.sh /tmp/upgrade/
chmod +x /tmp/upgrade/*.sh
# Patch CI_start_postgres to use "restart" instead of "start" so it
# is idempotent (initiate.sh starts postgres for top-level queries,
# then handle_extensions calls CI_start_postgres again)
sed -i "s/pg_ctl start -o/pg_ctl restart -o/g" /tmp/upgrade/common.sh
# Patch PGSHARENEW to match nix binary expectations (share/postgresql/)
sed -i "s|PGSHARENEW=\"\$PG_UPGRADE_BIN_DIR/share\"|PGSHARENEW=\"\$PG_UPGRADE_BIN_DIR/share/postgresql\"|" /tmp/upgrade/initiate.sh
'
info "Starting Postgres 15 in upgrade container"
docker exec "$UPGRADE_CONTAINER" bash -c '
su postgres -c "pg_ctl start -o \"-c config_file=/etc/postgresql/postgresql.conf\" -l /tmp/postgres.log"
'
wait_for_healthy "$UPGRADE_CONTAINER"
# initiate.sh expects the PG 17 binaries tarball at /tmp/persistent/pg_upgrade_bin.tar.gz
# (hardcoded path - copied there during container setup above).
#
# Env vars for the unwrapped nix ELF binaries in the tarball:
# LD_LIBRARY_PATH - find libpq, libssl, etc. (RUNPATH points to absent nix store paths)
# NIX_PGLIBDIR - postgres uses this to find extension .so files
info "Running initiate.sh (pg_upgrade: Postgres 15 -> 17)"
echo " This may take several minutes depending on database size..."
echo ""
if ! docker exec \
-e IS_CI=true \
-e PG_MAJOR_VERSION=17 \
-e PGPASSWORD="$pg_password" \
-e LD_LIBRARY_PATH=/tmp/pg_upgrade_bin/17/lib \
-e NIX_PGLIBDIR=/tmp/pg_upgrade_bin/17/lib \
"$UPGRADE_CONTAINER" \
/tmp/upgrade/initiate.sh 17; then
echo ""
warn "initiate.sh failed. Its cleanup may have restored the original state"
warn "(re-enabled extensions, revoked superuser). Your data directory is"
warn "unchanged - no data was moved or deleted."
warn ""
warn "Check the output above for the root cause, fix it, and re-run."
docker rm -f "$UPGRADE_CONTAINER" >/dev/null 2>&1 || true
die "initiate.sh failed"
fi
info "initiate.sh completed successfully"
docker rm -f "$UPGRADE_CONTAINER" >/dev/null 2>&1 || true
}
# --- Step 6: Run complete.sh in a native PG 17 container -------------------
#
# complete.sh applies post-upgrade patches (pg_net grants, vault re-encryption,
# pg_cron, predefined roles, vacuumdb, etc.). We run it in a PG17 container
# where the binaries are native - no nix extraction or LD_LIBRARY_PATH needed.
run_complete() {
local abs_migration_dir
abs_migration_dir=$(cd "$MIGRATION_DIR" && pwd)
info "Starting PG17 container for complete.sh"
docker run -d --name "$COMPLETE_CONTAINER" \
--entrypoint sleep \
-v "${abs_migration_dir}:/mnt/host-migration" \
-v "${db_config_vol}:/etc/postgresql-custom" \
-v "${staging_dir}:/tmp/staging:ro" \
-e PGPASSWORD="$pg_password" \
"$PG17_UPGRADE_IMAGE" \
infinity
info "Preparing complete.sh environment"
# Save original db-config ownership so we can restore it if complete.sh fails.
# complete.sh needs PG 17 ownership to start postgres, but if it fails the
# user needs to fall back to PG 15 which uses a different uid.
docker exec "$COMPLETE_CONTAINER" bash -c '
stat -c "%u:%g" /etc/postgresql-custom/pgsodium_root.key 2>/dev/null > /tmp/dbconfig_owner || true
'
docker exec "$COMPLETE_CONTAINER" bash -c '
# Symlink bind mount so complete.sh CI wrapper can mv/rm/ln
ln -s /mnt/host-migration /data_migration
# Remove the image default data dir (complete.sh creates a symlink here)
rm -rf /var/lib/postgresql/data
# Fix ownership on db-config volume (PG15 uid differs from PG17)
chown -R postgres:postgres /etc/postgresql-custom/
# PG17 config includes this directory; may not exist from PG15
mkdir -p /etc/postgresql-custom/conf.d
mkdir -p /tmp/upgrade
# Copy upgrade scripts
cp /tmp/staging/scripts/*.sh /tmp/upgrade/
chmod +x /tmp/upgrade/*.sh
# Patch --new-bin to use native bindir (we are in a PG17 container,
# no need for /tmp/pg_upgrade_bin/ paths)
sed -i "s|BINDIR=\"/tmp/pg_upgrade_bin/\$PG_MAJOR_VERSION/bin\"|BINDIR=\$(pg_config --bindir)|g" /tmp/upgrade/common.sh
'
info "Running complete.sh (post-upgrade patches, vacuum analyze)"
docker exec \
-e IS_CI=true \
-e PG_MAJOR_VERSION=17 \
-e PGPASSWORD="$pg_password" \
"$COMPLETE_CONTAINER" \
/tmp/upgrade/complete.sh || true
# complete.sh's ERR trap exits with 0 in some cases; check status file
local status
status=$(docker exec "$COMPLETE_CONTAINER" cat /tmp/pg-upgrade-status 2>/dev/null || echo "unknown")
if [ "$status" != "complete" ]; then
warn "complete.sh failed. Postgres log:"
docker exec "$COMPLETE_CONTAINER" cat /tmp/postgres.log 2>/dev/null || true
echo ""
# Restore db-config ownership so PG 15 can start for rollback
warn "Restoring db-config ownership for PG15..."
local orig_owner
orig_owner=$(docker exec "$COMPLETE_CONTAINER" cat /tmp/dbconfig_owner 2>/dev/null || true)
if [ -n "$orig_owner" ]; then
docker exec "$COMPLETE_CONTAINER" chown -R "$orig_owner" /etc/postgresql-custom/ 2>/dev/null || true
fi
docker rm -f "$COMPLETE_CONTAINER" >/dev/null 2>&1 || true
echo ""
echo " Your Postgres 15 data is unchanged (data swap has not happened yet)."
echo " To restart Postgres 15:"
echo " rm -rf $MIGRATION_DIR"
echo " docker compose -f docker-compose.yml -f docker-compose.pg15.yml up -d"
echo ""
die "complete.sh failed (status: $status)"
fi
info "complete.sh finished successfully"
docker rm -f "$COMPLETE_CONTAINER" >/dev/null 2>&1 || true
}
# --- Step 7: Swap data directories -----------------------------------------
swap_data() {
info "Swapping data directories"
echo " $DATA_DIR -> $BACKUP_DIR"
mv "$DATA_DIR" "$BACKUP_DIR"
echo " $MIGRATION_DIR/pgdata -> $DATA_DIR"
mv "$MIGRATION_DIR/pgdata" "$DATA_DIR"
rm -rf "$MIGRATION_DIR"
}
# --- Step 8: Start Postgres 17 ---------------------------------------------
start_pg17() {
info "Starting Supabase with Postgres 17"
# Ensure db-config volume has correct ownership and structure for PG 17.
# complete.sh does this too, but just in case of partial
# failures from previous runs.
docker run --rm -v "${db_config_vol}:/vol" "$PG17_TARGET_IMAGE" sh -c '
mkdir -p /vol/conf.d
chown -R postgres:postgres /vol/
'
docker compose -f docker-compose.yml -f docker-compose.pg17.yml up -d
echo " Waiting for Postgres 17 to be ready..."
local retries=60
while [ $retries -gt 0 ]; do
if docker exec "$DB_CONTAINER" pg_isready -U postgres -h localhost >/dev/null 2>&1; then
break
fi
retries=$((retries - 1))
sleep 2
done
[ $retries -gt 0 ] || die "Postgres 17 did not start within 120 seconds."
local new_version
new_version=$(run_sql_on "$DB_CONTAINER" -A -t -c "SHOW server_version;" 2>/dev/null | head -n 1)
echo " Postgres version: $new_version"
case "$new_version" in
17.*) ;;
*) die "Expected Postgres 17.x, got: $new_version" ;;
esac
}
# --- Step 9: Apply migrations not covered by complete.sh -------------------
#
# These PG 17 migrations run on fresh installs via initdb but not after
# pg_upgrade (init scripts don't rerun when PG_VERSION already exists).
# complete.sh doesn't cover them either. On the platform a tracked migration
# runner (dbmate) applies them; self-hosted has no such tracking, so we apply
# the idempotent, safe-on-an-existing-DB ones directly here.
#
# Source: postgres/migrations/db/migrations/
# - 20250710151649_supabase_read_only_user_default_transaction_read_only.sql
# - 20251001204436_predefined_role_grants.sql (supabase_etl_admin + pg_monitor)
# - 20251105172723_grant_pg_reload_conf_to_postgres.sql
# - 20251121132723_correct_search_path_pgbouncer.sql
# - 20260211120934_supabase_privileged_role.sql (target image's supautils.conf
# references this role; it must exist or supautils config is broken)
# - 20260413000000_fix-authenticator-session-preload-libraries.sql
# - 20260421000001_rescope_pg_graphql_access_trigger.sql
#
# NOT applied:
# - 20260421000000_pg_graphql-off-by-default.sql: drops pg_graphql. Safe on a
# fresh install, but destructive on an existing DB that uses pg_graphql.
#
# This step also reconciles extension versions (complete.sh ran with the .063
# binaries) and the pg_cron 1.6 -> 1.6.4 version label. See below.
apply_role_migrations() {
info "Applying Postgres 17 migrations"
# Fix collation version mismatch first (upgrade used glibc 2.39, target
# image may use glibc 2.40). Do this before any other SQL to suppress
# the noisy warnings on every subsequent command.
for db in postgres template1 _supabase; do
docker exec -i -e PGPASSWORD="$pg_password" "$DB_CONTAINER" \
psql -h localhost -U supabase_admin -d "$db" \
-c "ALTER DATABASE \"$db\" REFRESH COLLATION VERSION;" || true
done
# Create supabase_etl_admin role (doesn't exist in PG 15 images).
# Must be created before running predefined_role_grants.sql which
# assumes it exists.
run_sql_on "$DB_CONTAINER" -c "
DO \$\$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'supabase_etl_admin') THEN
CREATE USER supabase_etl_admin WITH LOGIN REPLICATION;
GRANT pg_read_all_data TO supabase_etl_admin;
GRANT CREATE ON DATABASE postgres TO supabase_etl_admin;
END IF;
END
\$\$;" || true
# Run the migration files directly from the PG 17 container image.
# They're idempotent (IF EXISTS / IF NOT EXISTS guards).
local migration_dir="/docker-entrypoint-initdb.d/migrations"
local migrations="
20250710151649_supabase_read_only_user_default_transaction_read_only.sql
20251001204436_predefined_role_grants.sql
20251105172723_grant_pg_reload_conf_to_postgres.sql
20251121132723_correct_search_path_pgbouncer.sql
20260211120934_supabase_privileged_role.sql
20260413000000_fix-authenticator-session-preload-libraries.sql
20260421000001_rescope_pg_graphql_access_trigger.sql
"
for m in $migrations; do
echo " Running: $m"
docker exec -i \
-e PGPASSWORD="$pg_password" \
"$DB_CONTAINER" \
psql -h localhost -U supabase_admin -d postgres -v ON_ERROR_STOP=1 \
-f "${migration_dir}/${m}" || warn " $m failed (non-fatal)"
done
# Reconcile extension versions to the target image. pg_upgrade generates
# update_extensions.sql, but complete.sh runs it inside the .063 container, so
# extensions only get updated to .063's versions. The final image ships newer
# .so files, leaving the catalog version behind what's loaded.
#
# Mirror what update_extensions.sql does, but against the running target image
# and for every installed extension: ALTER EXTENSION ... UPDATE brings each one
# up to the image's default version. This is generic on purpose - it tracks the
# image (matching platform behavior) and stays correct across future image
# bumps without maintaining a hardcoded list. ALTER ... UPDATE is a no-op when
# already at the default. Per-extension exceptions are caught so one extension
# with no update path (e.g. pg_cron 1.6 -> 1.6.4, handled below) does not abort
# the rest.
info "Reconciling extension versions to the target image"
run_sql_on "$DB_CONTAINER" -c "
DO \$\$
DECLARE r record;
BEGIN
FOR r IN SELECT extname FROM pg_extension LOOP
BEGIN
EXECUTE format('ALTER EXTENSION %I UPDATE', r.extname);
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'skipped extension %: %', r.extname, SQLERRM;
END;
END LOOP;
END
\$\$;" || warn "extension version reconcile had errors (non-fatal)"
# pg_cron: PG 15 registers the extension as '1.6'; PG 17 images package it as
# '1.6.4' with no '1.6' -> '1.6.4' update path, so ALTER EXTENSION ... UPDATE
# has no path to follow. complete.sh only fixes this when pg_cron is owned by
# 'postgres' (it drops + recreates). Manually-enabled instances are often
# owned by supabase_admin, where that path is skipped. Reconcile the catalog
# label directly: the loaded .so and the SQL objects are already
# 1.6.4-equivalent (no SQL delta between 1.6 and 1.6.4). No DROP, so any
# scheduled jobs are untouched.
run_sql_on "$DB_CONTAINER" -c "
DO \$\$
DECLARE want text;
BEGIN
SELECT default_version INTO want FROM pg_available_extensions WHERE name = 'pg_cron';
IF want IS NOT NULL
AND EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_cron' AND extversion <> want) THEN
UPDATE pg_extension SET extversion = want WHERE extname = 'pg_cron';
END IF;
END
\$\$;" || warn "pg_cron version reconcile had errors (non-fatal)"
}
# --- Step 10: Verify ------------------------------------------------------
verify() {
info "Verification"
local version
version=$(run_sql_on "$DB_CONTAINER" -A -t -c "SELECT version();" 2>/dev/null | head -n 1)
echo " $version"
echo ""
echo " Extensions:"
run_sql_on "$DB_CONTAINER" -c \
"SELECT extname, extversion FROM pg_extension ORDER BY extname;"
echo ""
info "Upgrade complete!"
echo ""
echo " To use Postgres 17 going forward, always include the override:"
echo " docker compose -f docker-compose.yml -f docker-compose.pg17.yml up -d"
echo ""
echo " Postgres 15 backup: $BACKUP_DIR"
echo " pgsodium key backup: ./volumes/db/pgsodium_root.key.bak.pg15"
echo " Once satisfied, you can reclaim space:"
echo " rm -rf $BACKUP_DIR ./volumes/db/pg17_upgrade_bin_*.tar.gz"
echo ""
echo " Rollback (if needed):"
echo " 1. docker compose -f docker-compose.yml -f docker-compose.pg17.yml down"
echo " 2. rm -rf $DATA_DIR"
echo " 3. mv $BACKUP_DIR $DATA_DIR"
echo " 4. docker compose -f docker-compose.yml -f docker-compose.pg15.yml run --rm db chown -R postgres:postgres /etc/postgresql-custom/"
echo " 5. docker compose -f docker-compose.yml -f docker-compose.pg15.yml up -d"
echo ""
}
# --- Main -------------------------------------------------------------------
main() {
echo ""
echo "Supabase Self-Hosted: Postgres 15 -> 17 Upgrade"
echo "================================================"
preflight
pull_image
build_tarball
drop_incompatible_extensions
stop_and_backup
run_upgrade
run_complete
swap_data
start_pg17
apply_role_migrations
verify
}
main "$@"
+145
View File
@@ -0,0 +1,145 @@
# Docker image version updates in docker-compose.yml
## 2026-08-03
- supabase/studio:2026.08.03-sha-022b374 (prev supabase/studio:2026.07.07-sha-a6a04f2)
- kong/kong:3.9.3 (prev kong/kong:3.9.1)
## 2026-07-07
- supabase/studio:2026.07.07-sha-a6a04f2 (prev supabase/studio:2026.06.03-sha-0bca601)
## 2026-06-17
- supabase/postgres:17.6.1.136 (prev supabase/postgres:15.8.1.085)
## 2026-06-03
- supabase/studio:2026.06.03-sha-0bca601 (prev supabase/studio:2026.04.27-sha-5f60601)
- supabase/gotrue:v2.189.0 (prev supabase/gotrue:v2.186.0)
- postgrest/postgrest:v14.12 (prev postgrest/postgrest:v14.8)
- supabase/realtime:v2.102.3 (prev supabase/realtime:v2.76.5)
- supabase/storage-api:v1.60.4 (prev supabase/storage-api:v1.48.26)
- supabase/postgres-meta:v0.96.6 (prev supabase/postgres-meta:v0.96.3)
- supabase/edge-runtime:v1.74.0 (prev supabase/edge-runtime:v1.71.2)
- supabase/supavisor:2.9.5 (prev supabase/supavisor:2.7.4)
- supabase/logflare:1.43.1 (prev supabase/logflare:1.36.1)
## 2026-04-27
- supabase/studio:2026.04.27-sha-5f60601 (prev supabase/studio:2026.04.08-sha-205cbe7)
## 2026-04-08
- supabase/studio:2026.04.08-sha-205cbe7 (prev supabase/studio:2026.03.16-sha-5528817)
- postgrest/postgrest:v14.8 (prev postgrest/postgrest:v14.6)
- supabase/storage-api:v1.48.26 (prev supabase/storage-api:v1.44.2)
- supabase/postgres-meta:v0.96.3 (prev supabase/postgres-meta:v0.95.2)
- supabase/logflare:1.36.1 (prev supabase/logflare:1.31.2)
## 2026-03-16
- supabase/studio:2026.03.16-sha-5528817 (prev supabase/studio:2026.02.16-sha-26c615c)
- kong/kong:3.9.1 (prev kong:2.8.1)
- postgrest/postgrest:v14.6 (prev postgrest/postgrest:v14.5)
- supabase/storage-api:v1.44.2 (prev supabase/storage-api:v1.37.8)
- supabase/edge-runtime:v1.71.2 (prev supabase/edge-runtime:v1.70.3)
## 2026-02-16
- supabase/studio:2026.02.16-sha-26c615c (prev supabase/studio:2026.01.27-sha-6aa59ff)
- supabase/gotrue:v2.186.0 (prev supabase/gotrue:v2.185.0)
- postgrest/postgrest:v14.5 (prev postgrest/postgrest:v14.3)
- supabase/realtime:v2.76.5 (prev supabase/realtime:v2.72.0)
- supabase/storage-api:v1.37.8 (prev supabase/storage-api:v1.37.1)
- supabase/edge-runtime:v1.70.3 (prev supabase/edge-runtime:v1.70.0)
- supabase/logflare:1.31.2 (prev supabase/logflare:1.30.3)
- timberio/vector:0.53.0-alpine (prev timberio/vector:0.28.1-alpine)
## 2026-02-05
- supabase/storage-api:v1.37.1 (prev supabase/storage-api:v1.33.5)
## 2026-01-27
- supabase/studio:2026.01.27-sha-6aa59ff (prev supabase/studio:2025.12.17-sha-43f4f7f)
- supabase/gotrue:v2.185.0 (prev supabase/gotrue:v2.184.0)
- postgrest/postgrest:v14.3 (prev postgrest/postgrest:v14.1)
- supabase/realtime:v2.72.0 (prev supabase/realtime:v2.68.0)
- supabase/storage-api:v1.33.5 (prev supabase/storage-api:v1.33.0)
- darthsim/imgproxy:v3.30.1 (prev darthsim/imgproxy:v3.8.0)
- supabase/postgres-meta:v0.95.2 (prev supabase/postgres-meta:v0.95.1)
- supabase/edge-runtime:v1.70.0 (prev supabase/edge-runtime:v1.69.28)
- supabase/logflare:1.30.3 (prev supabase/logflare:1.27.0)
## 2025-12-18
- supabase/studio:2025.12.17-sha-43f4f7f (prev supabase/studio:2025.12.09-sha-434634f)
- supabase/gotrue:v2.184.0 (prev supabase/gotrue:v2.183.0)
- supabase/postgres-meta:v0.95.1 (prev supabase/postgres-meta:v0.93.1)
- supabase/logflare:1.27.0 (prev supabase/logflare:1.26.25)
## 2025-12-10
- supabase/studio:2025.12.09-sha-434634f (prev supabase/studio:2025.11.26-sha-8f096b5)
- postgrest/postgrest:v14.1 (prev postgrest/postgrest:v13.0.7)
- supabase/realtime:v2.68.0 (prev supabase/realtime:v2.65.3)
- supabase/storage-api:v1.33.0 (prev supabase/storage-api:v1.32.0)
- supabase/edge-runtime:v1.69.28 (prev supabase/edge-runtime:v1.69.25)
- supabase/logflare:1.26.25 (prev supabase/logflare:1.26.13)
## 2025-11-26
- supabase/studio:2025.11.26-sha-8f096b5 (prev supabase/studio:2025.11.24-sha-d990ae8)
- supabase/realtime:v2.65.3 (prev supabase/realtime:v2.65.2)
- supabase/logflare:1.26.13 (prev supabase/logflare:1.26.12)
## 2025-11-25
- supabase/studio:2025.11.24-sha-d990ae8 (prev supabase/studio:2025.11.10-sha-5291fe3)
- supabase/gotrue:v2.183.0 (prev supabase/gotrue:v2.182.1)
- supabase/realtime:v2.65.2 (prev supabase/realtime:v2.63.0)
- supabase/storage-api:v1.32.0 (prev supabase/storage-api:v1.29.0)
- supabase/edge-runtime:v1.69.25 (prev supabase/edge-runtime:v1.69.23)
- supabase/logflare:1.26.12 (prev supabase/logflare:1.22.6)
## 2025-11-12
- supabase/studio:2025.11.10-sha-5291fe3 (prev supabase/studio:2025.10.27-sha-85b84e0)
- supabase/gotrue:v2.182.1 (prev supabase/gotrue:v2.180.0)
- supabase/realtime:v2.63.0 (prev supabase/realtime:v2.57.2)
- supabase/storage-api:v1.29.0 (prev supabase/storage-api:v1.28.2)
- supabase/edge-runtime:v1.69.23 (prev supabase/edge-runtime:v1.69.15)
- supabase/supavisor:2.7.4 (prev supabase/supavisor:2.7.3)
## 2025-10-28
- supabase/studio:2025.10.27-sha-85b84e0 (prev supabase/studio:2025.10.20-sha-5005fc6)
- supabase/realtime:v2.57.2 (prev supabase/realtime:v2.56.0)
- supabase/storage-api:v1.28.2 (prev supabase/storage-api:v1.28.1)
- supabase/postgres-meta:v0.93.1 (prev supabase/postgres-meta:v0.93.0)
- supabase/edge-runtime:v1.69.15 (prev supabase/edge-runtime:v1.69.14)
## 2025-10-21
- supabase/studio:2025.10.20-sha-5005fc6 (prev supabase/studio:2025.10.01-sha-8460121)
- supabase/realtime:v2.56.0 (prev supabase/realtime:v2.51.11)
- supabase/storage-api:v1.28.1 (prev supabase/storage-api:v1.28.0)
- supabase/postgres-meta:v0.93.0 (prev supabase/postgres-meta:v0.91.6)
- supabase/edge-runtime:v1.69.14 (prev supabase/edge-runtime:v1.69.6)
- supabase/supavisor:2.7.3 (prev supabase/supavisor:2.7.0)
## 2025-10-13
- supabase/logflare:1.22.6 (prev supabase/logflare:1.22.4)
## 2025-10-08
- supabase/studio:2025.10.01-sha-8460121 (prev supabase/studio:2025.06.30-sha-6f5982d)
- supabase/gotrue:v2.180.0 (prev supabase/gotrue:v2.177.0)
- postgrest/postgrest:v13.0.7 (prev postgrest/postgrest:v12.2.12)
- supabase/realtime:v2.51.11 (prev supabase/realtime:v2.34.47)
- supabase/storage-api:v1.28.0 (prev supabase/storage-api:v1.25.7)
- supabase/postgres-meta:v0.91.6 (prev supabase/postgres-meta:v0.91.0)
- supabase/logflare:1.22.4 (prev supabase/logflare:1.14.2)
- supabase/postgres:15.8.1.085 (prev supabase/postgres:15.8.1.060)
- supabase/supavisor:2.7.0 (prev supabase/supavisor:2.5.7)
## 2025-07-15
- supabase/gotrue:v2.177.0 (prev supabase/gotrue:v2.176.1)
- supabase/storage-api:v1.25.7 (prev supabase/storage-api:v1.24.7)
- supabase/postgres-meta:v0.91.0 (prev supabase/postgres-meta:v0.89.3)
- supabase/supavisor:2.5.7 (prev supabase/supavisor:2.5.6)
## 2025-07-02
- supabase/studio:2025.06.30-sha-6f5982d (prev supabase/studio:2025.06.02-sha-8f2993d)
- supabase/gotrue:v2.176.1 (prev supabase/gotrue:v2.174.0)
- supabase/storage-api:v1.24.7 (prev supabase/storage-api:v1.23.0)
- supabase/supavisor:2.5.6 (prev supabase/supavisor:2.5.1)
## 2025-06-03
- supabase/studio:2025.06.02-sha-8f2993d (prev supabase/studio:2025.05.19-sha-3487831)
- supabase/gotrue:v2.174.0 (prev supabase/gotrue:v2.172.1)
- supabase/storage-api:v1.23.0 (prev supabase/storage-api:v1.22.17)
- supabase/postgres-meta:v0.89.3 (prev supabase/postgres-meta:v0.89.0)
@@ -0,0 +1,223 @@
resources:
- '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster
name: auth
connect_timeout: 5s
type: STRICT_DNS
dns_refresh_rate: 5s
dns_failure_refresh_rate:
base_interval: 1s
max_interval: 1s
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: auth
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: auth
port_value: 9999
health_checks:
- timeout: 2s
interval: 5s
unhealthy_threshold: 3
healthy_threshold: 2
http_health_check:
path: /health
circuit_breakers:
thresholds:
- priority: DEFAULT
max_connections: 10000
max_pending_requests: 10000
max_requests: 10000
- '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster
name: rest
connect_timeout: 5s
type: STRICT_DNS
dns_refresh_rate: 5s
dns_failure_refresh_rate:
base_interval: 1s
max_interval: 1s
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: rest
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: rest
port_value: 3000
health_checks:
- timeout: 2s
interval: 5s
unhealthy_threshold: 3
healthy_threshold: 2
http_health_check:
path: /
circuit_breakers:
thresholds:
- priority: DEFAULT
max_connections: 10000
max_pending_requests: 10000
max_requests: 10000
- '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster
name: realtime
connect_timeout: 5s
type: STRICT_DNS
dns_refresh_rate: 5s
dns_failure_refresh_rate:
base_interval: 1s
max_interval: 1s
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: realtime
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: realtime-dev.supabase-realtime
port_value: 4000
health_checks:
- timeout: 2s
interval: 5s
unhealthy_threshold: 3
healthy_threshold: 2
http_health_check:
path: /
circuit_breakers:
thresholds:
- priority: DEFAULT
max_connections: 10000
max_pending_requests: 10000
max_requests: 10000
- '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster
name: storage
connect_timeout: 5s
type: STRICT_DNS
dns_refresh_rate: 5s
dns_failure_refresh_rate:
base_interval: 1s
max_interval: 1s
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: storage
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: storage
port_value: 5000
health_checks:
- timeout: 2s
interval: 5s
unhealthy_threshold: 3
healthy_threshold: 2
http_health_check:
path: /status
circuit_breakers:
thresholds:
- priority: DEFAULT
max_connections: 10000
max_pending_requests: 10000
max_requests: 10000
- '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster
name: functions
connect_timeout: 5s
type: STRICT_DNS
dns_refresh_rate: 5s
dns_failure_refresh_rate:
base_interval: 1s
max_interval: 1s
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: functions
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: functions
port_value: 9000
health_checks:
- timeout: 2s
interval: 5s
unhealthy_threshold: 3
healthy_threshold: 2
tcp_health_check: {}
circuit_breakers:
thresholds:
- priority: DEFAULT
max_connections: 10000
max_pending_requests: 10000
max_requests: 10000
- '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster
name: meta
connect_timeout: 5s
type: STRICT_DNS
dns_refresh_rate: 5s
dns_failure_refresh_rate:
base_interval: 1s
max_interval: 1s
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: meta
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: meta
port_value: 8080
health_checks:
- timeout: 2s
interval: 5s
unhealthy_threshold: 3
healthy_threshold: 2
http_health_check:
path: /health
circuit_breakers:
thresholds:
- priority: DEFAULT
max_connections: 10000
max_pending_requests: 10000
max_requests: 10000
- '@type': type.googleapis.com/envoy.config.cluster.v3.Cluster
name: studio
connect_timeout: 5s
type: STRICT_DNS
dns_refresh_rate: 5s
dns_failure_refresh_rate:
base_interval: 1s
max_interval: 1s
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: studio
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: studio
port_value: 3000
health_checks:
- timeout: 2s
interval: 5s
unhealthy_threshold: 3
healthy_threshold: 2
http_health_check:
path: /project/default
circuit_breakers:
thresholds:
- priority: DEFAULT
max_connections: 10000
max_pending_requests: 10000
max_requests: 10000
@@ -0,0 +1,35 @@
#!/bin/sh
set -e
# Generate SHA1 base64 hash for Envoy basic auth user list
PASSWORD_HASH=$(printf '%s' "${DASHBOARD_PASSWORD}" | openssl sha1 -binary | openssl base64)
DASHBOARD_BASIC_AUTH="${DASHBOARD_USERNAME}:{SHA}${PASSWORD_HASH}"
echo "Generating Envoy configuration..."
# Process the lds.yaml template with environment variables using sed
# Using | as delimiter since JWT tokens contain /
sed -e "s|\${ANON_KEY}|${ANON_KEY}|g" \
-e "s|\${ANON_KEY_ASYMMETRIC}|${ANON_KEY_ASYMMETRIC}|g" \
-e "s|\${SERVICE_ROLE_KEY}|${SERVICE_ROLE_KEY}|g" \
-e "s|\${SERVICE_ROLE_KEY_ASYMMETRIC}|${SERVICE_ROLE_KEY_ASYMMETRIC}|g" \
-e "s|\${SUPABASE_PUBLISHABLE_KEY}|${SUPABASE_PUBLISHABLE_KEY}|g" \
-e "s|\${SUPABASE_SECRET_KEY}|${SUPABASE_SECRET_KEY}|g" \
-e "s|\${SUPABASE_PUBLIC_URL}|${SUPABASE_PUBLIC_URL}|g" \
-e "s|\${DASHBOARD_BASIC_AUTH}|${DASHBOARD_BASIC_AUTH}|g" \
/etc/envoy/lds.template.yaml > /etc/envoy/lds.yaml
if [ -n "$SUPABASE_SECRET_KEY" ] && \
[ -n "$SUPABASE_PUBLISHABLE_KEY" ] && \
[ -n "$SERVICE_ROLE_KEY_ASYMMETRIC" ] && \
[ -n "$ANON_KEY_ASYMMETRIC" ]; then
echo "Envoy sb_ key translation enabled"
else
echo "Envoy running in legacy API key mode (sb_ keys disabled)"
fi
echo "Envoy configuration generated successfully"
echo "Starting Envoy..."
# Start Envoy
exec envoy -c /etc/envoy/envoy.yaml "$@"
@@ -0,0 +1,27 @@
dynamic_resources:
cds_config:
path_config_source:
path: /etc/envoy/cds.yaml
resource_api_version: V3
lds_config:
path_config_source:
path: /etc/envoy/lds.yaml
resource_api_version: V3
node:
cluster: supabase_cluster
id: supabase_node
overload_manager:
resource_monitors:
- name: envoy.resource_monitors.global_downstream_max_connections
typed_config:
'@type': >-
type.googleapis.com/envoy.extensions.resource_monitors.downstream_connections.v3.DownstreamConnectionsConfig
max_active_downstream_connections: 30000
admin:
address:
socket_address:
address: 127.0.0.1
port_value: 9901
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
#!/bin/sh
# Custom entrypoint for Kong that builds Lua expressions for request-transformer
# and performs environment variable substitution in the declarative config.
# Build Lua expressions for translating opaque API keys to asymmetric JWTs.
# When opaque keys are not configured (empty env vars), expressions fall through
# to legacy-only behavior - just passing apikey as-is.
#
# Full expression logic (when opaque keys are configured):
# 1. If Authorization header exists and is NOT an sb_ key -> pass through (user session JWT)
# 2. If apikey matches secret key -> set service_role asymmetric JWT internal "API key"
# 3. If apikey matches publishable key -> set anon asymmetric JWT internal "API key"
# 4. Fallback: pass apikey as-is (legacy HS256 JWT)
if [ -n "$SUPABASE_SECRET_KEY" ] && [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then
# Opaque keys configured -> full translation expressions
export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == '$SUPABASE_SECRET_KEY' and 'Bearer $SERVICE_ROLE_KEY_ASYMMETRIC') or (headers.apikey == '$SUPABASE_PUBLISHABLE_KEY' and 'Bearer $ANON_KEY_ASYMMETRIC') or headers.apikey)"
# Realtime WebSocket: reads from query_params.apikey (supabase-js sends apikey
# via query string), outputs to x-api-key header which Realtime checks first.
export LUA_RT_WS_EXPR="\$((query_params.apikey == '$SUPABASE_SECRET_KEY' and '$SERVICE_ROLE_KEY_ASYMMETRIC') or (query_params.apikey == '$SUPABASE_PUBLISHABLE_KEY' and '$ANON_KEY_ASYMMETRIC') or query_params.apikey)"
else
# Legacy API keys, not sb_ API keys -> pass apikey through unchanged
export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or headers.apikey)"
export LUA_RT_WS_EXPR="\$(query_params.apikey)"
fi
# Substitute environment variables in the Kong declarative config.
# Uses awk instead of eval/echo to preserve YAML quoting (eval strips double
# quotes, breaking "Header: value" patterns that YAML parses as mappings).
awk '{
result = ""
rest = $0
while (match(rest, /\$[A-Za-z_][A-Za-z_0-9]*/)) {
varname = substr(rest, RSTART + 1, RLENGTH - 1)
if (varname in ENVIRON) {
result = result substr(rest, 1, RSTART - 1) ENVIRON[varname]
} else {
result = result substr(rest, 1, RSTART + RLENGTH - 1)
}
rest = substr(rest, RSTART + RLENGTH)
}
print result rest
}' /home/kong/temp.yml > "$KONG_DECLARATIVE_CONFIG"
# Remove empty key-auth credentials (unconfigured opaque keys)
sed -i '/^[[:space:]]*- key:[[:space:]]*$/d' "$KONG_DECLARATIVE_CONFIG"
exec /entrypoint.sh kong docker-start
+467
View File
@@ -0,0 +1,467 @@
_format_version: '2.1'
_transform: true
###
### Consumers / Users
###
consumers:
- username: DASHBOARD
- username: anon
keyauth_credentials:
- key: $SUPABASE_ANON_KEY
- key: $SUPABASE_PUBLISHABLE_KEY
- username: service_role
keyauth_credentials:
- key: $SUPABASE_SERVICE_KEY
- key: $SUPABASE_SECRET_KEY
###
### Access Control List
###
acls:
- consumer: anon
group: anon
- consumer: service_role
group: admin
###
### Dashboard credentials
###
basicauth_credentials:
- consumer: DASHBOARD
username: '$DASHBOARD_USERNAME'
password: '$DASHBOARD_PASSWORD'
###
### API Routes
###
services:
## Open Auth routes
- name: auth-v1-open
_comment: 'Auth: /auth/v1/verify* -> http://auth:9999/verify*'
url: http://auth:9999/verify
routes:
- name: auth-v1-open
strip_path: true
paths:
- /auth/v1/verify
plugins:
- name: cors
- name: auth-v1-open-callback
_comment: 'Auth: /auth/v1/callback* -> http://auth:9999/callback*'
url: http://auth:9999/callback
routes:
- name: auth-v1-open-callback
strip_path: true
paths:
- /auth/v1/callback
plugins:
- name: cors
- name: auth-v1-open-authorize
_comment: 'Auth: /auth/v1/authorize* -> http://auth:9999/authorize*'
url: http://auth:9999/authorize
routes:
- name: auth-v1-open-authorize
strip_path: true
paths:
- /auth/v1/authorize
plugins:
- name: cors
- name: auth-v1-open-jwks
_comment: 'Auth: /auth/v1/.well-known/jwks.json -> http://auth:9999/.well-known/jwks.json'
url: http://auth:9999/.well-known/jwks.json
routes:
- name: auth-v1-open-jwks
strip_path: true
paths:
- /auth/v1/.well-known/jwks.json
plugins:
- name: cors
- name: auth-v1-open-sso-acs
url: "http://auth:9999/sso/saml/acs"
routes:
- name: auth-v1-open-sso-acs
strip_path: true
paths:
- /auth/v1/sso/saml/acs
plugins:
- name: cors
- name: auth-v1-open-sso-metadata
url: "http://auth:9999/sso/saml/metadata"
routes:
- name: auth-v1-open-sso-metadata
strip_path: true
paths:
- /auth/v1/sso/saml/metadata
plugins:
- name: cors
## Secure Auth routes
- name: auth-v1
_comment: 'Auth: /auth/v1/* -> http://auth:9999/*'
url: http://auth:9999/
routes:
- name: auth-v1-all
strip_path: true
paths:
- /auth/v1/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: request-transformer
config:
add:
headers:
- "Authorization: $LUA_AUTH_EXPR"
replace:
headers:
- "Authorization: $LUA_AUTH_EXPR"
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
## OpenAPI root - admin only
- name: rest-v1-openapi
_comment: 'PostgREST OpenAPI root: /rest/v1/ -> <http://rest:3000/> (admin only). See <https://github.com/orgs/supabase/discussions/42949>'
url: http://rest:3000/
routes:
- name: rest-v1-openapi-root
strip_path: true
expression: 'http.path == "/rest/v1/"'
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: request-transformer
config:
add:
headers:
- "Authorization: $LUA_AUTH_EXPR"
replace:
headers:
- "Authorization: $LUA_AUTH_EXPR"
- name: acl
config:
hide_groups_header: true
allow:
- admin
## Secure PostgREST routes
- name: rest-v1
_comment: 'PostgREST: /rest/v1/* -> http://rest:3000/*'
url: http://rest:3000/
routes:
- name: rest-v1-all
strip_path: true
paths:
- /rest/v1/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: request-transformer
config:
add:
headers:
- "Authorization: $LUA_AUTH_EXPR"
replace:
headers:
- "Authorization: $LUA_AUTH_EXPR"
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
## Secure GraphQL routes
- name: graphql-v1
_comment: 'PostgREST: /graphql/v1/* -> http://rest:3000/rpc/graphql'
url: http://rest:3000/rpc/graphql
routes:
- name: graphql-v1-all
strip_path: true
paths:
- /graphql/v1
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: request-transformer
config:
add:
headers:
- "Content-Profile: graphql_public"
- "Authorization: $LUA_AUTH_EXPR"
replace:
headers:
- "Authorization: $LUA_AUTH_EXPR"
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
## Secure Realtime routes
- name: realtime-v1-ws
_comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*'
url: http://realtime-dev.supabase-realtime:4000/socket
protocol: ws
routes:
- name: realtime-v1-ws
strip_path: true
paths:
- /realtime/v1/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: request-transformer
config:
add:
headers:
- "x-api-key:$LUA_RT_WS_EXPR"
replace:
querystring:
- "apikey:$LUA_RT_WS_EXPR"
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
# Block access to /realtime/v1/api/openapi
- name: realtime-v1-rest-openapi
_comment: 'Realtime: /realtime/v1/api/openapi/* -> http://realtime:4000/api/openapi/* (blocked)'
url: http://realtime-dev.supabase-realtime:4000/api/openapi
protocol: http
routes:
- name: realtime-v1-rest-openapi
strip_path: true
paths:
- /realtime/v1/api/openapi
plugins:
- name: request-termination
config:
status_code: 403
message: "Access is forbidden."
# Block access to /realtime/v1/api/tenants
- name: realtime-v1-rest-tenants
_comment: 'Realtime: /realtime/v1/api/tenants/* -> http://realtime:4000/api/tenants/* (blocked)'
url: http://realtime-dev.supabase-realtime:4000/api/tenants
protocol: http
routes:
- name: realtime-v1-rest-tenants
strip_path: true
paths:
- /realtime/v1/api/tenants
plugins:
- name: request-termination
config:
status_code: 403
message: "Access is forbidden."
- name: realtime-v1-rest
_comment: 'Realtime: /realtime/v1/api/* -> http://realtime:4000/api/*'
url: http://realtime-dev.supabase-realtime:4000/api
protocol: http
routes:
- name: realtime-v1-rest
strip_path: true
paths:
- /realtime/v1/api
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: request-transformer
config:
add:
headers:
- "Authorization: $LUA_AUTH_EXPR"
replace:
headers:
- "Authorization: $LUA_AUTH_EXPR"
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
## Storage API endpoint (with Authorization header transformation).
## No key-auth - S3 protocol requests don't carry an apikey header.
##
## The request-transformer translates opaque API keys to asymmetric JWTs
## and passes through existing Authorization headers (user JWTs, AWS SigV4).
## When no Authorization or apikey header is present (S3 presigned URLs),
## the Lua expression evaluates to nil which Kong renders as empty string.
## The post-function strips this empty header so Storage's S3 signature
## verification falls through to query-parameter parsing.
- name: storage-v1
_comment: 'Storage: /storage/v1/* -> http://storage:5000/*'
url: http://storage:5000/
routes:
- name: storage-v1-all
strip_path: true
paths:
- /storage/v1/
plugins:
- name: cors
- name: request-transformer
config:
add:
headers:
- "Authorization: $LUA_AUTH_EXPR"
replace:
headers:
- "Authorization: $LUA_AUTH_EXPR"
- name: post-function
config:
access:
- |
local auth = kong.request.get_header("authorization")
if auth == nil or auth == "" or auth:find("^%s*$") then
kong.service.request.clear_header("authorization")
end
## Edge Functions routes
- name: functions-v1
_comment: 'Edge Functions: /functions/v1/* -> http://functions:9000/*'
url: http://functions:9000/
read_timeout: 150000
routes:
- name: functions-v1-all
strip_path: true
paths:
- /functions/v1/
plugins:
- name: cors
## OAuth 2.0 Authorization Server Metadata (RFC 8414)
- name: well-known-oauth
_comment: 'Auth: /.well-known/oauth-authorization-server -> http://auth:9999/.well-known/oauth-authorization-server'
url: http://auth:9999/.well-known/oauth-authorization-server
routes:
- name: well-known-oauth
strip_path: true
paths:
- /.well-known/oauth-authorization-server
plugins:
- name: cors
## Analytics routes
## Not used - Studio and Vector talk directly to analytics via Docker networking.
## If external access is needed, add routes with key-auth matching Logflare's x-api-key auth.
# - name: analytics-v1-api
# _comment: 'Analytics: /analytics/v1/api/endpoints/* -> http://logflare:4000/api/endpoints/*'
# url: http://analytics:4000/api/endpoints
# routes:
# - name: analytics-v1-api
# strip_path: true
# paths:
# - /analytics/v1/api/endpoints/
# - name: analytics-v1
# _comment: 'Analytics: /analytics/v1/* -> http://logflare:4000/*'
# url: http://analytics:4000/
# routes:
# - name: dashboard-v1-all
# strip_path: true
# paths:
# - /analytics/v1
# plugins:
# - name: cors
# - name: basic-auth
# config:
# hide_credentials: true
## Secure Database routes
- name: meta
_comment: 'pg-meta: /pg/* -> http://pg-meta:8080/*'
url: http://meta:8080/
routes:
- name: meta-all
strip_path: true
paths:
- /pg/
plugins:
- name: key-auth
config:
hide_credentials: false
- name: acl
config:
hide_groups_header: true
allow:
- admin
## Block access to /api/mcp
- name: mcp-blocker
_comment: 'Block direct access to /api/mcp'
url: http://studio:3000/api/mcp
routes:
- name: mcp-blocker-route
strip_path: true
paths:
- /api/mcp
plugins:
- name: request-termination
config:
status_code: 403
message: "Access is forbidden."
## MCP endpoint - local access
- name: mcp
_comment: 'MCP: /mcp -> http://studio:3000/api/mcp (local access)'
url: http://studio:3000/api/mcp
routes:
- name: mcp
strip_path: true
paths:
- /mcp
plugins:
# Block access to /mcp by default
- name: request-termination
config:
status_code: 403
message: "Access is forbidden."
# Enable local access (danger zone!)
# 1. Comment out the 'request-termination' section above
# 2. Uncomment the entire section below, including 'deny'
# 3. Add your local IPs to the 'allow' list
#- name: cors
#- name: ip-restriction
# config:
# allow:
# - 127.0.0.1
# - ::1
# deny: []
## Protected Dashboard - catch all remaining routes
- name: dashboard
_comment: 'Studio: /* -> http://studio:3000/*'
url: http://studio:3000/
routes:
- name: dashboard-all
strip_path: true
paths:
- /
plugins:
- name: cors
- name: basic-auth
config:
hide_credentials: true
@@ -0,0 +1,3 @@
\set pguser `echo "$POSTGRES_USER"`
CREATE DATABASE _supabase WITH OWNER :pguser;
View File
+5
View File
@@ -0,0 +1,5 @@
\set jwt_secret `echo "$JWT_SECRET"`
\set jwt_exp `echo "$JWT_EXP"`
ALTER DATABASE postgres SET "app.settings.jwt_secret" TO :'jwt_secret';
ALTER DATABASE postgres SET "app.settings.jwt_exp" TO :'jwt_exp';
@@ -0,0 +1,6 @@
\set pguser `echo "$POSTGRES_USER"`
\c _supabase
create schema if not exists _analytics;
alter schema _analytics owner to :pguser;
\c postgres
@@ -0,0 +1,6 @@
\set pguser `echo "$POSTGRES_USER"`
\c _supabase
create schema if not exists _supavisor;
alter schema _supavisor owner to :pguser;
\c postgres

Some files were not shown because too many files have changed in this diff Show More