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.
This page sets out what on your server is reachable, and from where: your proxy
in front, the tunnel for Studio and Postgres, a CDN if you add one, and the
cache rules private Storage needs behind it.
The request path draws the layout.
The examples use api.myapp.com for your backend.
Only the API is public. The stock setup is not built that way, so these are the requirements to meet before the server takes traffic:
-
A TLS proxy in front, forwarding the API paths only. The gateway speaks plain HTTP on port 8000. Your proxy terminates TLS and forwards exactly these paths, with WebSocket support for Realtime:
/auth/v1/*/rest/v1/*/realtime/v1/*/storage/v1/*, except/storage/v1/s3*(Storage's S3 protocol)/functions/v1/*/.well-known/oauth-authorization-server
Everything else answers 404. Behind the same gateway sit Studio at
/, postgres-meta at/pg/,/graphql/v1and/mcp; none of them belongs on the internet. Match paths the way the gateway reads them, unescaped and with dot segments and repeated slashes cleaned, so no other spelling of a refused path slips through. -
Not the shipped Caddy override. Upstream's
docker-compose.caddy.ymlforwards the API paths and serves Studio on the rest of the same host, behind one shared basic-auth password. Copy its path list, not its layout. -
Studio and Postgres on loopback only, reached through an SSH tunnel. The stock
dbservice publishes no port: the stock5432is Supavisor's pooler, which wants the userpostgres.<POOLER_TENANT_ID>.db push,psqlandpg_dumpconnect as plainpostgres, so they go to Postgres directly, never through the pooler. Keep Supavisor, with its5432and6543bound to127.0.0.1, and publishdb's own5432beside it on127.0.0.1:5433. The commands in this group assume that layout.
services:
api-gw:
ports: !override
- 127.0.0.1:8000:8000
supavisor:
ports: !override
- 127.0.0.1:${POSTGRES_PORT}:5432
- 127.0.0.1:${POOLER_PROXY_PORT_TRANSACTION}:6543
db:
ports:
- 127.0.0.1:5433:5432Docker bypasses ufw
A port published without an address listens on every interface, and
Docker's own firewall rules run before ufw's, so a ufw deny does not
close it. The stock compose publishes the gateway's 8000 and Supavisor's 5432
and 6543 that way. Bind each port you keep to 127.0.0.1, or drop it.
Floot's setup. The gateway joins the proxy's Docker network under an alias
and publishes no public port; the gateway and Postgres are published on
loopback for the tunnel, as is its watcher's
UI, and nothing else is published. Floot runs no
Supavisor (Floot's setup),
so its db takes 127.0.0.1:5432 itself, and its tunnel maps 15432 to
5432:
services:
api-gw:
ports: !override
- 127.0.0.1:8000:8000
networks:
caddy_public:
aliases:
- floot-api-gw
db:
ports:
- 127.0.0.1:5432:5432
networks:
caddy_public:
external: trueIts Caddy site forwards the API paths to that alias and answers 404 to the
rest. The @private_storage and X-Real-IP lines are explained under
Storage behind a CDN and
the rate-limit header:
api.floot.dev {
# Caddy redacts Authorization and Cookie in its log, not the apikey header,
# which carries the secret key for any caller holding it.
log {
format filter {
wrap json
request>headers>Apikey replace REDACTED
}
}
@api {
path /auth/v1/* /rest/v1/* /realtime/v1/* /storage/v1/* /functions/v1/*
path /.well-known/oauth-authorization-server
not path /storage/v1/s3*
# The gateway refuses a backslash, raw or encoded, whose canonical reading
# is a slash that Caddy's path cleaning would not see.
not path_regexp `\\`
}
handle @api {
@private_storage {
path /storage/v1/*
not path /storage/v1/object/public/*
}
header @private_storage {
Cache-Control "private, no-store"
-Expires
defer
}
reverse_proxy floot-api-gw:8000 {
header_up X-Real-IP {client_ip}
}
}
handle {
respond 404
}
}Once it is live, curl -s -o /dev/null -w '%{http_code}\n' https://api.myapp.com/
prints 404, and https://api.myapp.com/auth/v1/.well-known/jwks.json
answers 200. Not /auth/v1/health: the gateway asks it for an API key, so
it answers 401 without one.
Studio and Postgres over a tunnel
With the gateway and Postgres on the server's loopback, one SSH command
forwards both to your laptop. Pick local ports clear of your local Supabase
stack, which uses 54320 to 54329:
ssh -N -L 127.0.0.1:18000:127.0.0.1:8000 -L 127.0.0.1:15432:127.0.0.1:5433 you@your-serverWhile it runs, Studio is at http://localhost:18000, behind the basic auth of
DASHBOARD_USERNAME and DASHBOARD_PASSWORD from .env, and Postgres answers
on 127.0.0.1:15432. The migrations in
Shipping a release use
that port. If you run the optional watcher, the tunnel gains a third forward,
for its UI: see Watching.
Behind Cloudflare
A CDN proxy in front of the API hides your server's address, which only holds if every host on that server is proxied. Floot proxies all of its hosts through Cloudflare. What that takes:
-
The real client address. Behind the proxy, every request comes from Cloudflare. Trust
CF-Connecting-IP, and only from Cloudflare's published ranges (cloudflare.com/ips), in Caddy's global options. Write the ranges on the onetrusted_proxiesline:Caddyfile (global options) { servers { trusted_proxies static <every Cloudflare range, on this line> client_ip_headers CF-Connecting-IP } } -
SSL/TLS Full (strict), with Caddy's own certificates at the origin.
-
Always Use HTTPS off. Caddy redirects to HTTPS itself, and a Cloudflare redirect makes a new host's first certificate fail with a 525.
-
WebSockets on, for Realtime.
-
Bot Fight Mode off. On the Free plan it cannot exempt a path, and it challenges Stripe's webhook calls.
-
The Free plan's limits: a 100 MB request body (the kit's Storage limit is 50 MiB) and a 100-second wait for your server's answer.
-
A Cache Rule for private Storage, below.
Storage behind a CDN
Storage does not mark private responses as private. On every download, public
or not, it sends back the Cache-Control the object was stored with, and the
kit's uploads store max-age=3600 unless the caller says otherwise. Signed
URLs answer with an Expires header alone. A CDN that caches by file
extension, as Cloudflare does, may then keep a private .png and hand it to
the next person who asks for that URL: its cache key does not include the
Authorization header.
Only /storage/v1/object/public/* is safe to cache. Private files are read
both through /object/authenticated/ and through the plain
/object/<bucket>/<path> that the client's download() uses, so a rule that
lists private routes will miss one. Turn it around:
-
Force
Cache-Control: private, no-storeand removeExpireson every/storage/v1/*response except/storage/v1/object/public/*. A Storage route added in a later release is then private by default. Floot's Caddy site does this in its@private_storageblock above. -
Bypass the CDN's cache for the same set, as a second layer that holds even for a response missing that header. On Cloudflare: Caching → Cache Rules, Create rule, a Custom filter expression, the cache eligibility Bypass cache, then Deploy. The expression:
(http.host eq "api.myapp.com" and starts_with(http.request.uri.path, "/storage/v1/") and not starts_with(http.request.uri.path, "/storage/v1/object/public/"))
To check, fetch a private image twice with a user's token: neither answer may
show cf-cache-status: HIT, and both carry Cache-Control: private, no-store.
A deleted public object stays cached. The edge keeps a public object for up
to its max-age, 3600 seconds by default, after you delete or replace it.
Avatars are stored under a new path on every upload, so a replaced avatar
never shows stale; a deleted one can stay visible for that hour. Ship any file
you replace under a new name, as for
the assets bucket.
Shipping a release
A release on a self-hosted Floot backend: the first start, db push over an SSH tunnel, reference data, the Vault rows by psql, deploying the functions, Floot's release order and the smoke check.
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.