Flootdocs
Self-hosted

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/v1 and /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.yml forwards 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 db service publishes no port: the stock 5432 is Supavisor's pooler, which wants the user postgres.<POOLER_TENANT_ID>. db push, psql and pg_dump connect as plain postgres, so they go to Postgres directly, never through the pooler. Keep Supavisor, with its 5432 and 6543 bound to 127.0.0.1, and publish db's own 5432 beside it on 127.0.0.1:5433. The commands in this group assume that layout.

docker-compose.myapp.yml (excerpt)
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:5432

Docker 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:

docker-compose.floot.yml (excerpt)
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: true

Its 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:

sites-available/floot-api
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:

Terminal
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-server

While 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 one trusted_proxies line:

    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-store and remove Expires on 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_storage block 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.

On this page