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.
This commit is contained in:
2026-08-28 14:23:20 +02:00
parent eddee2ec44
commit f496eb2f88
67 changed files with 12697 additions and 0 deletions
@@ -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
@@ -0,0 +1,4 @@
\set pguser `echo "$POSTGRES_USER"`
create schema if not exists _realtime;
alter schema _realtime owner to :pguser;
@@ -0,0 +1,8 @@
-- NOTE: change to your own passwords for production environments
\set pgpass `echo "$POSTGRES_PASSWORD"`
ALTER USER authenticator WITH PASSWORD :'pgpass';
ALTER USER pgbouncer WITH PASSWORD :'pgpass';
ALTER USER supabase_auth_admin WITH PASSWORD :'pgpass';
ALTER USER supabase_functions_admin WITH PASSWORD :'pgpass';
ALTER USER supabase_storage_admin WITH PASSWORD :'pgpass';
@@ -0,0 +1,208 @@
BEGIN;
-- Create pg_net extension
CREATE EXTENSION IF NOT EXISTS pg_net SCHEMA extensions;
-- Create supabase_functions schema
CREATE SCHEMA supabase_functions AUTHORIZATION supabase_admin;
GRANT USAGE ON SCHEMA supabase_functions TO postgres, anon, authenticated, service_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON FUNCTIONS TO postgres, anon, authenticated, service_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES TO postgres, anon, authenticated, service_role;
-- supabase_functions.migrations definition
CREATE TABLE supabase_functions.migrations (
version text PRIMARY KEY,
inserted_at timestamptz NOT NULL DEFAULT NOW()
);
-- Initial supabase_functions migration
INSERT INTO supabase_functions.migrations (version) VALUES ('initial');
-- supabase_functions.hooks definition
CREATE TABLE supabase_functions.hooks (
id bigserial PRIMARY KEY,
hook_table_id integer NOT NULL,
hook_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT NOW(),
request_id bigint
);
CREATE INDEX supabase_functions_hooks_request_id_idx ON supabase_functions.hooks USING btree (request_id);
CREATE INDEX supabase_functions_hooks_h_table_id_h_name_idx ON supabase_functions.hooks USING btree (hook_table_id, hook_name);
COMMENT ON TABLE supabase_functions.hooks IS 'Supabase Functions Hooks: Audit trail for triggered hooks.';
CREATE FUNCTION supabase_functions.http_request()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
DECLARE
request_id bigint;
payload jsonb;
url text := TG_ARGV[0]::text;
method text := TG_ARGV[1]::text;
headers jsonb DEFAULT '{}'::jsonb;
params jsonb DEFAULT '{}'::jsonb;
timeout_ms integer DEFAULT 1000;
BEGIN
IF url IS NULL OR url = 'null' THEN
RAISE EXCEPTION 'url argument is missing';
END IF;
IF method IS NULL OR method = 'null' THEN
RAISE EXCEPTION 'method argument is missing';
END IF;
IF TG_ARGV[2] IS NULL OR TG_ARGV[2] = 'null' THEN
headers = '{"Content-Type": "application/json"}'::jsonb;
ELSE
headers = TG_ARGV[2]::jsonb;
END IF;
IF TG_ARGV[3] IS NULL OR TG_ARGV[3] = 'null' THEN
params = '{}'::jsonb;
ELSE
params = TG_ARGV[3]::jsonb;
END IF;
IF TG_ARGV[4] IS NULL OR TG_ARGV[4] = 'null' THEN
timeout_ms = 1000;
ELSE
timeout_ms = TG_ARGV[4]::integer;
END IF;
CASE
WHEN method = 'GET' THEN
SELECT http_get INTO request_id FROM net.http_get(
url,
params,
headers,
timeout_ms
);
WHEN method = 'POST' THEN
payload = jsonb_build_object(
'old_record', OLD,
'record', NEW,
'type', TG_OP,
'table', TG_TABLE_NAME,
'schema', TG_TABLE_SCHEMA
);
SELECT http_post INTO request_id FROM net.http_post(
url,
payload,
params,
headers,
timeout_ms
);
ELSE
RAISE EXCEPTION 'method argument % is invalid', method;
END CASE;
INSERT INTO supabase_functions.hooks
(hook_table_id, hook_name, request_id)
VALUES
(TG_RELID, TG_NAME, request_id);
RETURN NEW;
END
$function$;
-- Supabase super admin
DO
$$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_roles
WHERE rolname = 'supabase_functions_admin'
)
THEN
CREATE USER supabase_functions_admin NOINHERIT CREATEROLE LOGIN NOREPLICATION;
END IF;
END
$$;
GRANT ALL PRIVILEGES ON SCHEMA supabase_functions TO supabase_functions_admin;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA supabase_functions TO supabase_functions_admin;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA supabase_functions TO supabase_functions_admin;
ALTER USER supabase_functions_admin SET search_path = "supabase_functions";
ALTER table "supabase_functions".migrations OWNER TO supabase_functions_admin;
ALTER table "supabase_functions".hooks OWNER TO supabase_functions_admin;
ALTER function "supabase_functions".http_request() OWNER TO supabase_functions_admin;
GRANT supabase_functions_admin TO postgres;
-- Remove unused supabase_pg_net_admin role
DO
$$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_roles
WHERE rolname = 'supabase_pg_net_admin'
)
THEN
REASSIGN OWNED BY supabase_pg_net_admin TO supabase_admin;
DROP OWNED BY supabase_pg_net_admin;
DROP ROLE supabase_pg_net_admin;
END IF;
END
$$;
-- pg_net grants when extension is already enabled
DO
$$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_extension
WHERE extname = 'pg_net'
)
THEN
GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role;
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
END IF;
END
$$;
-- Event trigger for pg_net
CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access()
RETURNS event_trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_event_trigger_ddl_commands() AS ev
JOIN pg_extension AS ext
ON ev.objid = ext.oid
WHERE ext.extname = 'pg_net'
)
THEN
GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role;
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
END IF;
END;
$$;
COMMENT ON FUNCTION extensions.grant_pg_net_access IS 'Grants access to pg_net';
DO
$$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_event_trigger
WHERE evtname = 'issue_pg_net_access'
) THEN
CREATE EVENT TRIGGER issue_pg_net_access ON ddl_command_end WHEN TAG IN ('CREATE EXTENSION')
EXECUTE PROCEDURE extensions.grant_pg_net_access();
END IF;
END
$$;
INSERT INTO supabase_functions.migrations (version) VALUES ('20210809183423_update_grants');
ALTER function supabase_functions.http_request() SECURITY DEFINER;
ALTER function supabase_functions.http_request() SET search_path = supabase_functions;
REVOKE ALL ON FUNCTION supabase_functions.http_request() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION supabase_functions.http_request() TO postgres, anon, authenticated, service_role;
COMMIT;
@@ -0,0 +1,6 @@
{
"imports": {
"@supabase/functions-js": "jsr:@supabase/functions-js@^2",
"@supabase/server": "npm:@supabase/server@^1"
}
}
@@ -0,0 +1,37 @@
// Follow this setup guide to integrate the Deno language server with your editor:
// https://deno.land/manual/getting_started/setup_your_environment
// This enables autocomplete, go to definition, etc.
// Setup type definitions for built-in Supabase Runtime APIs
import "@supabase/functions-js/edge-runtime.d.ts"
import { withSupabase } from "@supabase/server"
// Logs are visible from 'functions' container inspector
console.log("Hello from Functions!");
// This endpoint uses 'publishable' | 'secret' access, apiKey is required.
// Use publishable for Client-facing, key-validated endpoints
// Use secret for Server-to-server, internal calls
export default {
fetch: withSupabase({ auth: ["publishable", "secret"] }, async (req, ctx) => {
// Called by another service with a secret key
// ctx.supabaseAdmin bypasses RLS — use for privileged operations
/*
if (ctx.authMode === "secret") {
const { user_id } = await req.json();
const { data } = await ctx.supabaseAdmin.auth.admin.getUserById(user_id);
return Response.json({
email: data?.user?.email,
});
}
*/
return Response.json({ message: "Hello from Edge Functions!" });
}),
};
// To invoke:
// curl 'http://localhost:<API_GW_HTTP_PORT>/functions/v1/hello' \
// --header 'apiKey: <sb_publishable/sb_secret key>'
@@ -0,0 +1,177 @@
import * as jose from 'jsr:@panva/jose@6'
console.log('main function started')
const JWT_SECRET = Deno.env.get('JWT_SECRET')
const SUPABASE_JWKS = parseJwks(Deno.env.get('SUPABASE_JWKS'))
const VERIFY_JWT = Deno.env.get('VERIFY_JWT') === 'true'
// NOTE:(kallebysantos) We don't check for valid keys but just the bare array parsing,
// let this for 'jose' lib verification
export function parseJwks(raw: string | undefined): jose.JSONWebKeySet | null {
if (!raw) return null
try {
const parsed = JSON.parse(raw)
if (parsed?.keys && Array.isArray(parsed.keys)) {
return parsed as jose.JSONWebKeySet
}
return null
} catch {
return null
}
}
/**
* Extract JWT token from Authorization header
*
* Parses the Authorization header to extract the Bearer token.
* Expects format: "Bearer <token>"
*
* @param req - The HTTP request object
* @returns The JWT token string
* @throws Error if Authorization header is missing or malformed
*/
function getAuthToken(req: Request) {
const authHeader = req.headers.get('authorization')
if (!authHeader) {
throw new Error('Missing authorization header')
}
const [bearer, token] = authHeader.split(' ')
if (bearer !== 'Bearer') {
throw new Error(`Auth header is not 'Bearer {token}'`)
}
return token
}
async function isValidLegacyJWT(jwt: string): Promise<boolean> {
if (!JWT_SECRET) {
console.error('JWT_SECRET not available for HS256 token verification')
return false
}
const encoder = new TextEncoder();
const secretKey = encoder.encode(JWT_SECRET);
try {
await jose.jwtVerify(jwt, secretKey);
} catch (e) {
console.error('Symmetric Legacy JWT verification error', e);
return false;
}
return true;
}
async function isValidJWT(jwt: string): Promise<boolean> {
if (!SUPABASE_JWKS) {
console.error('JWKS not available for ES256/RS256 token verification')
return false
}
try {
const localJwks = jose.createLocalJWKSet(SUPABASE_JWKS);
await jose.jwtVerify(jwt, localJwks);
} catch (e) {
console.error('Asymmetric JWT verification error', e);
return false
}
return true;
}
/**
* Verify JWT token, handling both legacy (HS256) and newer (ES256/RS256) algorithms
*
* This function automatically detects the algorithm used in the token and applies
* the appropriate verification method:
* - HS256: Uses JWT_SECRET (symmetric key)
* - ES256/RS256: Uses JWKS endpoint (asymmetric public keys)
*
* This fix ensures compatibility with both legacy tokens and newer asymmetric tokens,
* resolving the "Key for the ES256 algorithm must be of type CryptoKey" error.
*
* @param jwt - The JWT token string to verify
* @returns Promise resolving to true if verification succeeds, false otherwise
*/
async function isValidHybridJWT(jwt: string): Promise<boolean> {
const { alg: jwtAlgorithm } = jose.decodeProtectedHeader(jwt)
if (jwtAlgorithm === 'HS256') {
console.log(`Legacy token type detected, attempting ${jwtAlgorithm} verification.`)
return await isValidLegacyJWT(jwt)
}
if (jwtAlgorithm === 'ES256' || jwtAlgorithm === 'RS256') {
return await isValidJWT(jwt)
}
return false;
}
Deno.serve(async (req: Request) => {
if (req.method !== 'OPTIONS' && VERIFY_JWT) {
try {
const token = getAuthToken(req)
const isValidJWT = await isValidHybridJWT(token);
if (!isValidJWT) {
return new Response(JSON.stringify({ msg: 'Invalid JWT' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})
}
} catch (e) {
console.error(e)
return new Response(JSON.stringify({ msg: e.toString() }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})
}
}
const url = new URL(req.url)
const { pathname } = url
const path_parts = pathname.split('/')
const service_name = path_parts[1]
if (!service_name || service_name === '') {
const error = { msg: 'missing function name in request' }
return new Response(JSON.stringify(error), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
const servicePath = `/home/deno/functions/${service_name}`
console.error(`serving the request with ${servicePath}`)
const memoryLimitMb = 150
const workerTimeoutMs = 1 * 60 * 1000
const noModuleCache = false
// Using a common Import Map for all functions
// to use a scope 'deno.json' it must be dinamically resolved base on the 'service_name'
const importMapPath = `/home/deno/functions/deno.jsonc`
// SUPABASE_FUNCTION_SLUG is listed after the container env snapshot so
// nothing in it can shadow the value, and it is per-request because only this
// worker knows which function the request resolved to.
const envVarsObj = { ...Deno.env.toObject(), SUPABASE_FUNCTION_SLUG: service_name }
const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]])
try {
const worker = await EdgeRuntime.userWorkers.create({
servicePath,
memoryLimitMb,
workerTimeoutMs,
noModuleCache,
importMapPath,
envVars,
})
return await worker.fetch(req)
} catch (e) {
const error = { msg: e.toString() }
return new Response(JSON.stringify(error), {
status: 500,
headers: { 'Content-Type': 'application/json' },
})
}
})
@@ -0,0 +1,30 @@
{:ok, _} = Application.ensure_all_started(:supavisor)
{:ok, version} =
case Supavisor.Repo.query!("select version()") do
%{rows: [[ver]]} -> Supavisor.Helpers.parse_pg_version(ver)
_ -> nil
end
params = %{
"external_id" => System.get_env("POOLER_TENANT_ID"),
"db_host" => System.get_env("POSTGRES_HOST") || "db",
"db_port" => System.get_env("POSTGRES_PORT"),
"db_database" => System.get_env("POSTGRES_DB"),
"require_user" => false,
"auth_query" => "SELECT * FROM pgbouncer.get_auth($1)",
"default_max_clients" => System.get_env("POOLER_MAX_CLIENT_CONN"),
"default_pool_size" => System.get_env("POOLER_DEFAULT_POOL_SIZE"),
"default_parameter_status" => %{"server_version" => version},
"users" => [%{
"db_user" => "pgbouncer",
"db_password" => System.get_env("POSTGRES_PASSWORD"),
"mode_type" => System.get_env("POOLER_POOL_MODE"),
"pool_size" => System.get_env("POOLER_DEFAULT_POOL_SIZE"),
"is_manager" => true
}]
}
if !Supavisor.Tenants.get_tenant_by_external_id(params["external_id"]) do
{:ok, _} = Supavisor.Tenants.create_tenant(params)
end
@@ -0,0 +1,18 @@
{$PROXY_DOMAIN} {
@supabase_api path /auth/v1/* /rest/v1/* /graphql/v1 /realtime/v1/* /storage/v1/* /functions/v1/* /mcp /sso/*
handle @supabase_api {
reverse_proxy api-gw:8000
}
handle {
basic_auth {
# PROXY_AUTH_PASSWORD is overwritten with the bcrypt on startup
{$PROXY_AUTH_USERNAME} {$PROXY_AUTH_PASSWORD}
}
reverse_proxy studio:3000
}
header -server
}
@@ -0,0 +1,95 @@
upstream api_gw_upstream {
server api-gw:8000;
keepalive 2;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name ${PROXY_DOMAIN};
server_tokens off;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Port $server_port;
ssl_certificate /etc/letsencrypt/live/${PROXY_DOMAIN}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/${PROXY_DOMAIN}/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/${PROXY_DOMAIN}/chain.pem;
ssl_dhparam /etc/letsencrypt/dhparams/dhparam.pem;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Prevent 502 errors from large Supabase auth cookies
large_client_header_buffers 4 16k;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
location / {
auth_basic "supabase";
auth_basic_user_file /etc/nginx/user_conf.d/dashboard-passwd;
proxy_pass http://studio:3000;
}
location /auth {
proxy_pass http://api_gw_upstream;
}
location /rest {
proxy_pass http://api_gw_upstream;
}
location /graphql {
proxy_pass http://api_gw_upstream;
}
location /realtime/v1/ {
proxy_pass http://api_gw_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
location /storage/v1/ {
proxy_pass http://api_gw_upstream;
proxy_buffering off;
proxy_request_buffering off;
chunked_transfer_encoding off;
client_max_body_size 0;
}
location /functions {
proxy_pass http://api_gw_upstream;
}
location /mcp {
proxy_pass http://api_gw_upstream;
}
location /sso {
proxy_pass http://api_gw_upstream;
}
}