apps/docs/content/guides/self-hosting/self-hosted-envoy.mdx
Self-hosted Supabase ships with an optional Envoy-based API gateway. It accepts incoming client requests, routes them to internal services (Auth, PostgREST, Realtime, Storage, Edge Functions, postgres-meta, Studio), and enforces API key authentication by translating opaque sb_ keys into the internal credentials used by those services.
This guide explains the architecture, configuration layout, and security posture of the Envoy gateway for operators who want to understand or customize it. It is not an Envoy tutorial - for reference on filters, routes, and clusters, see the Envoy documentation.
sb_ key translation, see New API Keys and Asymmetric AuthenticationThe Envoy gateway is provided as a Docker Compose override.
<Admonition type="note">If your stack is already running from the initial setup, bring it down first with docker compose down.
Start your self-hosted Supabase stack with both the base compose file and the Envoy override:
docker compose -f docker-compose.yml -f docker-compose.envoy.yml up -d
The override disables the default Kong gateway and starts Envoy on the same port (default 8000). It also reconfigures the Functions service to wait for Envoy via a dependency.
Envoy is registered as the api-gw service and also exposes kong as a network alias; the base Kong service likewise exposes api-gw. Either hostname resolves to whichever gateway is currently active, so internal configs that hardcode kong:8000 (for example, in Edge Functions or Studio) keep working without changes.
Confirm the gateway is routing requests and enforcing API keys:
curl -i -H "apikey: your-service-role-key" http://<your-domain>/rest/v1/
A 200 OK response from PostgREST confirms the gateway is up. A 401 Unauthorized without the apikey header confirms enforcement is active.
Envoy runs as a single container (supabase-envoy) with one listener on port 8000. Every incoming request passes through an ordered chain of HTTP filters before being forwarded to an upstream cluster:
Client
│
▼
Listener (port 8000)
│
▼
HTTP filter chain
├─ CORS
├─ Basic Auth (dashboard only)
├─ Lua: copy ?apikey query to header
├─ Lua: translate opaque keys in query
├─ Lua: translate opaque keys in header
├─ Lua: mirror apikey to x-api-key (Realtime WS)
├─ Lua: synthesize Authorization header
├─ Lua: 401 for missing/invalid API key
├─ RBAC (global: service_role → /pg/, apikey → other API routes;
│ per-route DENY override on /mcp)
└─ Router
│
▼
Upstream clusters
├─ auth (auth:9999)
├─ rest (rest:3000)
├─ realtime (realtime-dev.supabase-realtime:4000)
├─ storage (storage:5000)
├─ functions (functions:9000)
├─ meta (meta:8080)
└─ studio (studio:3000)
Routes are matched in the order declared in the listener config. Each route selects an upstream cluster, rewrites the request path prefix, and can override filter behavior (for example, disabling basic auth on API routes or denying all traffic on MCP routes).
All Envoy configuration lives in ./volumes/api/envoy/ (relative to the directory containing docker-compose.yml):
| File | Purpose |
|---|---|
envoy.yaml | Bootstrap configuration. Points Envoy at the CDS and LDS files, configures the admin interface, and sets an overload manager limit on downstream connections. |
cds.yaml | Cluster Discovery Service - upstream service definitions (DNS, ports, health checks, connect timeouts, circuit breakers). |
lds.template.yaml | Listener Discovery Service template. Defines the listener, filter chain, routes, RBAC policy, and CORS policy. Contains placeholders for keys and credentials. |
docker-entrypoint.sh | Renders the LDS template into lds.yaml at container startup by substituting environment variables, computes a SHA1+base64 hash of DASHBOARD_PASSWORD for basic auth, then starts Envoy. |
Envoy cannot natively read environment variables inside its config. The entrypoint script renders environment-specific values into the listener config with sed before launching Envoy:
DASHBOARD_PASSWORD is computed and joined with DASHBOARD_USERNAME into a single DASHBOARD_BASIC_AUTH string in the username:{SHA}<base64-encoded-sha1> format that Envoy's basic_auth filter expects. Other hash formats are not supported.lds.template.yaml and the result is written to lds.yaml:| Variable | Used for |
|---|---|
ANON_KEY | Legacy HS256 anon JWT (API key validation, RBAC) |
SERVICE_ROLE_KEY | Legacy HS256 service_role JWT (API key validation, RBAC) |
SUPABASE_PUBLISHABLE_KEY | Opaque sb_publishable_* key (translation source) |
SUPABASE_SECRET_KEY | Opaque sb_secret_* key (translation source) |
ANON_KEY_ASYMMETRIC | Pre-signed ES256 anon JWT (translation target; internal use only) |
SERVICE_ROLE_KEY_ASYMMETRIC | Pre-signed ES256 service_role JWT (translation target; internal use only) |
DASHBOARD_BASIC_AUTH | Dashboard basic auth credentials (computed in step 1) |
SUPABASE_SECRET_KEY, SUPABASE_PUBLISHABLE_KEY, ANON_KEY_ASYMMETRIC, and SERVICE_ROLE_KEY_ASYMMETRIC are set, opaque key translation is enabled. Otherwise, Envoy runs in legacy-only mode and only legacy HS256 keys are accepted. The entrypoint prints which mode is active to the container log at startup.Configuration changes require restarting the container so the entrypoint re-renders the template:
docker compose -f docker-compose.yml -f docker-compose.envoy.yml restart api-gw
Routes are matched in the order declared. The first matching prefix wins. Protected routes require a valid apikey header; open routes pass through without API key validation.
| Path prefix | Upstream | Path rewrite | Access control | Notes |
|---|---|---|---|---|
/auth/v1/verify | auth | /verify | Open | Email verification |
/auth/v1/callback | auth | /callback | Open | OAuth callback |
/auth/v1/authorize | auth | /authorize | Open | OAuth authorize |
/auth/v1/.well-known/jwks.json | auth | /.well-known/jwks.json | Open | JWKS for third-party verification |
/.well-known/oauth-authorization-server | auth | - | Open | OAuth 2.0 Authorization Server Metadata (RFC 8414) |
/sso/saml/acs | auth | - | Open | SAML assertion consumer |
/sso/saml/metadata | auth | - | Open | SAML metadata |
/functions/v1/ | functions | / | Bypass | Edge Functions runtime performs its own JWT verification; 150s timeout |
/storage/v1/ | storage | / | Bypass | Storage performs its own authorization |
/auth/v1/ | auth | / | API key | Protected Auth endpoints |
/rest/v1/ | rest | / | API key | PostgREST |
/graphql/v1 | rest | /rpc/graphql | API key | pg_graphql (adds Content-Profile: graphql_public) |
/realtime/v1/api | realtime | /api | API key | Realtime REST API |
/realtime/v1/ | realtime | /socket/ | API key | Realtime WebSocket |
/pg/ | meta | / | Service role only | postgres-meta - used by Studio for database access |
/api/mcp | studio | - | Denied | MCP endpoint (blocked by default via RBAC DENY) |
/mcp | studio | /api/mcp | Denied | MCP endpoint (blocked by default via RBAC DENY) |
/ (catch-all) | studio | - | Basic auth | Dashboard; strips inbound Authorization header |
MCP routes are denied at the gateway by default. Allowing local access requires editing the RBAC rule on the /mcp route. See the inline comments in lds.template.yaml for the allowed pattern, and Enabling MCP Server Access for the security model.
The gateway handles three authentication-related steps: dashboard basic auth on the catch-all route, API key enforcement on protected routes, and an opaque-to-internal key translation step that runs before enforcement.
The catch-all / route requires HTTP basic auth with credentials from DASHBOARD_USERNAME and DASHBOARD_PASSWORD. The Authorization header is stripped (regardless of scheme) before the request is forwarded to Studio, so the basic auth credentials never reach the upstream service.
The protected routes (/auth/v1/, /rest/v1/, /graphql/v1, /realtime/v1/api, /realtime/v1/, /pg/) require the apikey header to contain a valid configured key. Acceptable keys are the new opaque sb_publishable_* and sb_secret_* keys (translated to internal JWTs) or the legacy ANON_KEY and SERVICE_ROLE_KEY HS256 JWT API keys.
A Lua filter rejects missing or invalid keys with HTTP 401 Unauthorized. An RBAC filter then applies finer-grained rules:
/pg/ - only service_role keys (sb_secret_* or legacy SERVICE_ROLE_KEY) are allowed (HTTP 403 otherwise).When the new API keys (sb_publishable_*, sb_secret_*) are configured, a chain of Lua filters translates opaque keys into the corresponding pre-signed internal JWTs before the request reaches API key enforcement and upstream services. The entire chain is skipped on /functions/v1/: the Edge Runtime receives the original apikey and Authorization headers unchanged.
The chain operates in this order:
?apikey=... but no apikey header, the value is copied into the apikey header. This normalizes query-only clients (such as Realtime WebSocket connections from browsers, where custom headers are not available).apikey query parameter contains an opaque key, it is replaced - both in the URL and in the apikey header - with the corresponding pre-signed internal JWT.apikey header contains an opaque key, it is replaced with the corresponding pre-signed internal JWT.x-api-key mirror. On the Realtime WebSocket route, the apikey value is copied into the x-api-key header (which Realtime reads first for WebSocket authentication).Authorization synthesis. If the client did not send a real JWT in the Authorization header (or sent only a Bearer sb_* value, which is not a valid JWT), the gateway synthesizes Authorization: Bearer <apikey> from the (potentially translated) apikey header. This is skipped on the Realtime WebSocket route, which uses x-api-key instead.For background on opaque vs asymmetric keys, see New API Keys and Asymmetric Authentication.
Envoy attaches forwarded headers to every upstream request so downstream services can reconstruct the original client-facing URL.
| Header | Value |
|---|---|
X-Forwarded-Host | The client's Host header (added if not already present) |
X-Forwarded-Port | The listener port (8000) |
X-Forwarded-Proto | Set automatically by Envoy based on the connection (http or https) |
X-Forwarded-Prefix | Set per-route to the matched path prefix (for example, /storage/v1 for Storage) |
X-Forwarded-For | Set automatically because use_remote_address: true is enabled on the listener |
X-Forwarded-Prefix is required by Storage for S3 signature v4 verification and for constructing TUS upload Location URLs.
The gateway applies a permissive CORS policy at the virtual-host level:
GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD, CONNECT, TRACEThis matches both the current Supabase platform behavior and the previous Kong-based gateway. The auth boundary for Supabase APIs is the apikey header rather than the request origin.
If you customize the cors: block in lds.template.yaml to enable allow_credentials: true, you must restrict allow_origin_string_match to specific origins - browsers (and Envoy) reject the combination of credentials with a wildcard origin.
The gateway is configured with these production-oriented settings:
| Setting | Purpose |
|---|---|
normalize_path: true, merge_slashes: true | Prevents path-confusion bypass of RBAC prefix rules |
path_with_escaped_slashes_action: REJECT_REQUEST | Rejects requests that contain URL-encoded slashes in the path |
use_remote_address: true | Treats Envoy as an edge proxy: uses the peer connection IP as the trusted client address (rather than trusting client-supplied X-Forwarded-For) and strips untrusted x-envoy-* request headers |
headers_with_underscores_action: REJECT_REQUEST | Blocks header smuggling attacks that exploit underscore-vs-hyphen normalization |
per_connection_buffer_limit_bytes: 32768 | Caps per-connection buffer memory to 32 KiB |
max_active_downstream_connections: 30000 | Overload manager limit on total downstream connections |
Admin interface bound to 127.0.0.1:9901 | The admin API is reachable only from inside the container, not from other containers or the host |
Image pinned to envoyproxy/envoy:v1.37.2 (or newer) | Includes published security patches for Envoy 1.37.x |
The Envoy admin interface at 127.0.0.1:9901 exposes /config_dump, which contains the fully rendered configuration including all API keys, JWTs, and the basic auth hash in plaintext. Never expose port 9901 to other containers or to the host.
The gateway listens on plain HTTP only and does not terminate TLS. For public deployments, terminate TLS at an upstream reverse proxy - the Docker setup ships docker-compose.caddy.yml and docker-compose.nginx.yml for this purpose.
All routing, filter, and cluster changes are made in the YAML files under ./volumes/api/envoy/.
lds.template.yaml. Routes are ordered - place new routes before the catch-all / route to ensure they match. Keep per-route basic_auth: disabled for API routes and set an appropriate RBAC override if the route should bypass the global policy.cds.yaml with the service's DNS name and port, then reference it from a route's cluster: field.${VAR_NAME} form. If you add a placeholder, update both docker-compose.envoy.yml (to pass the variable into the container) and docker-entrypoint.sh (to substitute it with sed).lds.yaml from the filesystem. Configuration changes require restarting the container so the entrypoint re-renders the template:docker compose -f docker-compose.yml -f docker-compose.envoy.yml restart api-gw
This guide does not cover Envoy's route, filter, and cluster reference. See the Envoy documentation for the full configuration surface.
</Admonition>Envoy exposes an admin interface on 127.0.0.1:9901 inside the container, with endpoints like /ready, /clusters, /stats, and /config_dump. The standard envoyproxy/envoy image is minimal (no curl or wget), so the admin endpoints are not reachable via docker exec without adding tooling.
The simplest way to query the admin interface during debugging is to run a short-lived curl container that joins the same network namespace as supabase-envoy:
docker run --rm --network container:supabase-envoy curlimages/curl http://127.0.0.1:9901/clusters
docker run --rm --network container:supabase-envoy curlimages/curl http://127.0.0.1:9901/stats
docker run --rm --network container:supabase-envoy curlimages/curl http://127.0.0.1:9901/config_dump
/config_dump includes secrets in plaintext. Never expose port 9901 to a public network or untrusted host, and never share its output without first removing secrets.
Envoy writes access logs and filter output to stdout. View them with:
docker compose -f docker-compose.yml -f docker-compose.envoy.yml logs api-gw
The access log format is a standard combined log with the request method, original path, response code, and bytes sent.
401 Unauthorized on a protected route. The apikey header is missing or does not match any configured key. Verify that the header value exactly matches one of ANON_KEY, SERVICE_ROLE_KEY, SUPABASE_PUBLISHABLE_KEY, or SUPABASE_SECRET_KEY in your .env file. Note that SUPABASE_PUBLISHABLE_KEY and SUPABASE_SECRET_KEY are only accepted when the new key configuration is fully set up - see New API Keys and Asymmetric Authentication.403 Forbidden on /pg/. The /pg/ route requires a service_role key (SUPABASE_SECRET_KEY or legacy SERVICE_ROLE_KEY). Anon and publishable keys are rejected.403 Forbidden on /api/mcp or /mcp. These routes are blocked by default. See Enabling MCP Server Access.SignatureDoesNotMatch on S3 requests to Storage. Verify that the Storage service configuration in docker-compose.yml contains REQUEST_ALLOW_X_FORWARDED_PATH=true and STORAGE_PUBLIC_URL. Storage uses the X-Forwarded-Prefix header the gateway sends to reconstruct the original request path for SigV4 verification.400 Bad Request with underscore headers. headers_with_underscores_action: REJECT_REQUEST is enabled. Some clients send headers like X_Forwarded_For with underscores; these are rejected. Use hyphens in header names.