Configuration
Every setting of a self-hosted Floot backend and where it lives: Supabase's setup and your own compose file, the config.toml to environment mapping, function secrets, the JWT switch and the Send Email hook.
On your own server, nothing reads supabase/config.toml and there is no
dashboard: every setting is an environment variable on one of the stack's
services. This page covers where those settings live and what each must be:
the stack Supabase's setup installs and the compose file you layer over it, the
config.toml values mirrored as environment variables, and the functions'
secrets, JWT switch and Send Email hook. The examples use api.myapp.com for
your backend and app.myapp.com for your web app.
The stack
Supabase's Self-Hosting with Docker guide starts with a one-line setup for Linux:
curl -fsSL https://supabase.link/setup.sh | sh -s -- --ref self-hosted/v0.8.2
cd supabase-projectsetup.sh installs git, openssl, jq and Docker if they are missing,
copies Supabase's docker/ directory at a release tag into supabase-project/,
writes .env from .env.example (it prompts for the public URLs), generates
every key the stack needs, records the tag in .supabase-version and pulls the
images. It starts nothing. Pass --ref: without it, the script takes the
newest self-hosted/v* tag on the day you run it, and the supabase.link
address itself serves the script from Supabase's master branch.
It is a one-shot bootstrap, not a tool that keeps the server in shape: run again inside a project it exits at once, and run again beside one it refuses because the directory exists. Everything after day one is yours, and the rest of this group is that work. It leaves out TLS and a reverse proxy, DNS, the firewall, SMTP, the OAuth providers, your functions, your migrations, the Vault rows, backups and monitoring.
Three .env values must name your public hosts:
SUPABASE_PUBLIC_URL=https://api.myapp.com
API_EXTERNAL_URL=https://api.myapp.com/auth/v1
SITE_URL=https://app.myapp.comAPI_EXTERNAL_URL ends in /auth/v1 since self-hosted 0.7.0. SITE_URL is
your web app, not the API host.
The keys setup.sh generates include the new API keys, SUPABASE_PUBLISHABLE_KEY
and SUPABASE_SECRET_KEY in .env, with the asymmetric keys the gateway
swaps them for (ANON_KEY_ASYMMETRIC, SERVICE_ROLE_KEY_ASYMMETRIC). Keep
them all: the app connects with the publishable key, and the gateway turns it
into a token the services behind it accept.
Your changes, in one file of your own
Keep Supabase's files as they arrived, and put every change in a compose file
of your own, layered on top. Upstream's update.sh merges its own files on an
upgrade, so every edit you make to them becomes a merge conflict later.
The stock .env sets COMPOSE_FILE, so a plain docker-compose.override.yml
is not loaded. Name your file, for example docker-compose.myapp.yml, and
add it to that list:
COMPOSE_FILE=docker-compose.yml:docker-compose.myapp.ymlOverriding a list such as ports needs Compose's !override and !reset
tags, so use Docker Compose 2.24.4 or newer. Compose 2.24.2 reads
!override on a port list but keeps upstream's port as well, which publishes
the gateway on every interface.
Floot's setup. Floot keeps upstream's docker/ at a pinned tag in its own
repository, byte for byte, with one compose file of its own,
docker-compose.floot.yml, layered over it. That file holds every non-secret
setting; .env holds secrets alone, each read as ${NAME?} so Compose refuses
to start while one is missing. It runs nine services (db, auth, rest,
realtime, storage, functions, the Envoy gateway api-gw, studio,
meta) and leaves out four that upstream ships:
- Logflare and Vector, the logs stack, which upstream runs only after
sh run.sh config add logs: they mount the Docker socket and cost about 1 GiB. - Supavisor: nothing depends on it, and it published 5432 and 6543 on every interface.
- imgproxy: the app uses no image transforms, so
storage.depends_on.imgproxyandIMGPROXY_URLgo with it.
Every service carries a mem_limit at about twice its idle memory,
so the backend cannot starve other sites on the server:
name: floot-api
services:
storage:
depends_on:
imgproxy: !reset null
environment:
ENABLE_IMAGE_TRANSFORMATION: "false"
IMGPROXY_URL: !reset null
mem_limit: 256m
imgproxy: !reset
supavisor: !resetFrom config.toml to environment variables
The self-hosted stack never reads supabase/config.toml. Every setting the
kit's file makes locally becomes an environment variable on one service, and
the stock compose file passes only some of them; others are commented out or
absent, and then the service's own default applies. Treat config.toml as the
source of truth, and set each value in your compose file with a comment naming
the key it mirrors, so the two stay in step.
Auth defaults that differ
These values differ from the kit's when you set nothing, or the stock file
sets them differently. Set each on the auth service:
config.toml key | Variable | Without it | The kit's value |
|---|---|---|---|
minimum_password_length | GOTRUE_PASSWORD_MIN_LENGTH | 6 | 8 |
password_requirements | GOTRUE_PASSWORD_REQUIRED_CHARACTERS | No required characters | The four classes (below) |
refresh_token_reuse_interval | GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL | 0 | 10 |
[auth.rate_limit] email_sent | GOTRUE_RATE_LIMIT_EMAIL_SENT | 30 per hour | 60 |
[auth.email] max_frequency | GOTRUE_SMTP_MAX_FREQUENCY | 1m | 45s |
[auth.email] otp_expiry | GOTRUE_MAILER_OTP_EXP | 86400 | 3600 |
[auth.email.notification.password_changed] | GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED | false | true |
[auth.email.notification.mfa_factor_enrolled] | GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_ENROLLED_ENABLED | false | true |
[auth.email.notification.mfa_factor_unenrolled] | GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_UNENROLLED_ENABLED | false | true |
[auth.sms] enable_signup | GOTRUE_EXTERNAL_PHONE_ENABLED | true in the stock .env | false |
[auth.sms] enable_confirmations | GOTRUE_SMS_AUTOCONFIRM | true in the stock .env | false, on purpose: the CLI turns the kit's setting into true, but no unconfirmed phone should sign in |
additional_redirect_urls | GOTRUE_URI_ALLOW_LIST (comma-separated, globs allowed) | Empty in the stock .env | Your web app's paths, https://app.myapp.com/**, and <your scheme>://oauth-callback |
[auth.external.google] skip_nonce_check | GOTRUE_EXTERNAL_GOOGLE_SKIP_NONCE_CHECK | false | true |
[auth.hook.send_email] | GOTRUE_HOOK_SEND_EMAIL_ENABLED, _URI, _SECRETS | Off (commented out) | On, see the hook |
Also set the three rate limits per 5 minutes and IP to the kit's values,
GOTRUE_RATE_LIMIT_TOKEN_REFRESH=150, GOTRUE_RATE_LIMIT_OTP=30 and
GOTRUE_RATE_LIMIT_VERIFY=30, and read the rate-limit header:
until it is set, none of them applies.
Three traps in that list:
- The password classes are spelled out, colon-separated, and Compose reads
$as the start of a variable, so it is written$$. The value the Supabase CLI passes forlower_upper_letters_digits_symbolsis in the example below. - The stock compose comment names
GOTRUE_EXTERNAL_SKIP_NONCE_CHECK, which Auth does not read. The setting is per provider:GOTRUE_EXTERNAL_GOOGLE_SKIP_NONCE_CHECK. - Apple has no block at all in the stock file. Add
GOTRUE_EXTERNAL_APPLE_ENABLED,_CLIENT_ID(the Services ID first, then the bundle IDs),_SECRET(a JWT that expires after about six months; see Apple) and_REDIRECT_URIby hand. Google and GitHub are there, commented out.
Each provider's redirect URI is https://api.myapp.com/auth/v1/callback, the
same as the functions' SB_AUTH_EXTERNAL_REDIRECT_URI. MFA's defaults already
equal the kit's (TOTP on, phone off, 10 factors).
Auth sends no mail itself while the Send Email hook is on, so it needs no SMTP
credentials. The stock .env's SMTP_* values are Auth's, and they share
their names with the functions' own; set the functions' on the functions
service, as below.
Exposed schemas
[api] schemas and extra_search_path become two PostgREST variables, set on
rest (Studio reads them too):
PGRST_DB_SCHEMAS: app,graphql_public,pgmq_public,public
PGRST_DB_EXTRA_SEARCH_PATH: public,extensionsThe stock values are public,graphql_public and public. Without app and
pgmq_public, the functions that read them fail with a bare DATABASE_ERROR,
and their log shows PGRST106 (Invalid schema: pgmq_public).
The rate-limit header
Auth's per-IP limits key on a request header you name in
GOTRUE_RATE_LIMIT_HEADER. While it is unset, every per-IP limit is off,
whatever the limits say. Behind a proxy, the header must hold the real client
address, written by your proxy and never taken from the request: a client that
can set it can give itself a fresh address on every request.
Floot sets GOTRUE_RATE_LIMIT_HEADER=X-Real-IP, and its Caddy site overwrites
X-Real-IP with the client address Caddy resolved from CF-Connecting-IP
(header_up X-Real-IP {client_ip}, in the site shown on
Exposure). The gateway passes the header
through untouched.
Floot's auth settings
services:
auth:
environment:
API_EXTERNAL_URL: "https://api.floot.dev/auth/v1"
# [auth] site_url
GOTRUE_SITE_URL: "https://app.floot.dev"
# [auth] additional_redirect_urls: the web app, and the mobile apps' OAuth callback.
GOTRUE_URI_ALLOW_LIST: "https://app.floot.dev/**,dev.floot.app://oauth-callback"
# [auth] refresh_token_reuse_interval
GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: "10"
# [auth] minimum_password_length
GOTRUE_PASSWORD_MIN_LENGTH: "8"
# [auth] password_requirements = "lower_upper_letters_digits_symbols", as the CLI passes it:
# classes split on ':', '\:' a literal colon, and $$ a literal $ in Compose.
GOTRUE_PASSWORD_REQUIRED_CHARACTERS: 'abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$$%^&*()_+-=[]{};''\\:"|<>?,./`~'
# [auth.rate_limit] email_sent, token_refresh, sign_in_sign_ups, token_verifications
GOTRUE_RATE_LIMIT_EMAIL_SENT: "60"
GOTRUE_RATE_LIMIT_TOKEN_REFRESH: "150"
GOTRUE_RATE_LIMIT_OTP: "30"
GOTRUE_RATE_LIMIT_VERIFY: "30"
# No config.toml key: Caddy overwrites X-Real-IP with the client's address.
GOTRUE_RATE_LIMIT_HEADER: X-Real-IP
# [auth.email] max_frequency and otp_expiry
GOTRUE_SMTP_MAX_FREQUENCY: 45s
GOTRUE_MAILER_OTP_EXP: "3600"
# [auth.email.notification.*] enabled
GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED: "true"
GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_ENROLLED_ENABLED: "true"
GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_UNENROLLED_ENABLED: "true"
# [auth.hook.send_email] enabled, uri and secrets
GOTRUE_HOOK_SEND_EMAIL_ENABLED: "true"
GOTRUE_HOOK_SEND_EMAIL_URI: "https://api.floot.dev/functions/v1/send-email"
GOTRUE_HOOK_SEND_EMAIL_SECRETS: "${SB_SEND_EMAIL_HOOK_SECRET?}"
# [auth.sms] enable_signup, and no autoconfirm for a phone nobody confirmed
GOTRUE_EXTERNAL_PHONE_ENABLED: "false"
GOTRUE_SMS_AUTOCONFIRM: "false"
# [auth.external.google] skip_nonce_check, by its per-provider name
GOTRUE_EXTERNAL_GOOGLE_SKIP_NONCE_CHECK: "true"The full file sets every other [auth] value too, even those that already
match, so a later change of default upstream cannot drift it.
Function secrets
The kit's functions validate the set in supabase/functions/_shared/env.ts
when they boot. One missing or invalid secret stops every function from
booting:
- every function answers
500, with{"code":"WORKER_ERROR","message":"Function exited due to an error (please check logs)"}; - the
functionsservice's log (docker compose logs functions) showsENV VALIDATION FAILED, with thefieldErrorsnaming each bad variable (Every Edge Function fails to boot reads them); - every auth email fails, and Auth answers
Unexpected status code returned from hook: 500.
The set and its meaning are the same as on Cloud (What goes in the file); the values that differ on your own server:
| Variable | Self-hosted value |
|---|---|
SB_URL | https://api.myapp.com |
SB_INTERNAL_URL | http://api-gw:8000, the gateway on the stack's network (it also answers as kong) |
SB_STORAGE_URL | https://api.myapp.com/storage/v1/object |
SB_PUBLISHABLE_KEY | SUPABASE_PUBLISHABLE_KEY from .env |
SB_SECRET_KEY | SUPABASE_SECRET_KEY from .env |
SB_AUTH_EXTERNAL_REDIRECT_URI | https://api.myapp.com/auth/v1/callback |
SMTP_PORT | A port your host lets out; see below |
SB_INTERNAL_URL sends the functions' own API calls straight to the gateway,
so the secret key they carry never leaves the server or reaches your proxy's
log, while every URL they hand out stays on SB_URL. The stock compose already
injects SUPABASE_URL and the other SUPABASE_* names into the functions; the
kit reads none of them, and its SB_ names keep one set that means the same on
every host.
Set the secrets as environment on the functions service in your compose file,
each secret read from .env. Every function sees the whole set: there is no
per-function environment.
Mail ports. Supabase's port rules are Cloud's; on your own server, your host decides. Many block outgoing 25 and 465 by default. The kit's mailer switches to implicit TLS on 465 alone, so where 465 is blocked use 587, which it speaks with STARTTLS; never 2465, which it would speak in plaintext to a TLS port.
The JWT switch
On Cloud, each function's verify_jwt in config.toml decides whether the
gateway checks a token first. The self-hosted router has one switch for every
function, FUNCTIONS_VERIFY_JWT in .env (passed as VERIFY_JWT), and the
stock value is false.
Keep FUNCTIONS_VERIFY_JWT false
The database webhooks, the cron jobs, the Stripe and RevenueCat webhooks and
the Send Email hook carry no user token, so with the switch on, each of their
calls is refused with 401 UNAUTHORIZED_NO_AUTH_HEADER.
That is safe because every kit function checks its caller in code, whatever its
verify_jwt says: a webhook signature, a shared secret, or the user's token
resolved against Auth. Keep that rule for any function you add: on a
self-hosted stack, nothing in front of it checks anything. The gateway does
not ask for an API key on /functions/v1/ either.
The Send Email hook
Auth accepts an http:// hook URI only for localhost, 127.0.0.1, ::1 and
host.docker.internal; any other host needs https://. So the internal
http://api-gw:8000/functions/v1/send-email is refused, and the hook is your
public URL:
GOTRUE_HOOK_SEND_EMAIL_URI: "https://api.myapp.com/functions/v1/send-email"The call goes out from the server and back in through your proxy, so the server
must reach its own public hostname. GOTRUE_HOOK_SEND_EMAIL_SECRETS takes the
same v1,whsec_ value as the functions' SB_SEND_EMAIL_HOOK_SECRET.
Exposure
What a self-hosted Floot backend shows the internet: the API paths your proxy forwards, Studio and Postgres behind an SSH tunnel, Docker and ufw, Cloudflare, and Storage behind a CDN.
Backups
What holds state on a self-hosted Floot backend, Floot's backup recipe as the reference, restoring onto a new server, and the queue registry a restore must refill.