Flootdocs
Self-hosted

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.

A release on your own server follows the same deploy order as on Cloud, migrations first, then functions, with the self-hosted commands below in place of the CLI's Cloud ones, on a stack installed as in Configuration and exposed as in Exposure. The first release adds three steps you take once: the first start, the reference data and the Vault rows; on a new server, Step by step takes them in order. Every later release is a push, the functions, and a Vault rotation when a value changes. The commands reach Postgres and the gateway through the SSH tunnel, and use api.myapp.com for your backend.

Migrations

Postgres has no public port, so migrations reach it through the tunnel, with the same db push you would run against Cloud.

The first start

The kit's first migration needs schemas the stack's own services create as they boot: auth from Auth, storage from Storage and realtime.messages from Realtime. So on a fresh database, the order is:

  1. Start the stack with docker compose up -d.

  2. Wait until every service but rest reports healthy in docker compose ps. rest stays unhealthy until the migrations create app and pgmq_public, which PGRST_DB_SCHEMAS names; it recovers by itself once they exist.

  3. Check that Realtime has created its table. Run this over the tunnel; it must print realtime.messages, not an empty line:

    select to_regclass('realtime.messages');

    The first migration has a guard on that table, but the guard cannot tell a missing table from a correct one; check it yourself.

  4. Push the migrations, below.

Do not use sh run.sh start for this. It runs docker compose up -d --wait, which waits for every service, storage's dependency rest included, and gives up at the first unhealthy report.

Push

With the tunnel open, put the database URL in your shell (the password is POSTGRES_PASSWORD from .env), preview, then push:

Terminal
read -rs DB_PASSWORD
export DB_URL="postgresql://postgres:${DB_PASSWORD}@127.0.0.1:15432/postgres?sslmode=disable"
supabase db push --skip-vault --dry-run --db-url "$DB_URL"
supabase db push --skip-vault --db-url "$DB_URL"
  • ?sslmode=disable is required. The self-hosted Postgres has no TLS, and without it the CLI fails with The server does not support SSL connections. The tunnel is the encryption.
  • The password goes into a URL, so a character such as @ or / must be percent-encoded. The keys setup.sh generates are hex.
  • --skip-vault, always. A push that applies a migration otherwise writes your laptop's Vault values into production first; see the rule.
  • Never --include-seed, and the send-email hook secret must resolve, as on Cloud: see Migrations.

After the first push, rest turns healthy. From the second release on, follow the deploy order: migrations first, then functions.

Reference data

Multi-tenancy only

This section applies only to projects generated with `--multi-tenancy`.

Roles and permissions. Run supabase/db_seeds/permissions_and_roles.sql once, over the tunnel. It fails on rows that already exist, so never run it twice:

Terminal
psql "postgresql://postgres@127.0.0.1:15432/postgres?sslmode=disable" \
  -v ON_ERROR_STOP=1 -f supabase/db_seeds/permissions_and_roles.sql

The assets bucket. The first migration creates the public assets bucket, and supabase seed buckets cannot reach a self-hosted stack, so upload the three files from supabase/storage_seeds/assets/ through the gateway on the tunnel, with the secret key. Sending the key to the tunnel's port keeps it out of your proxy's log:

Terminal
read -rs SECRET_KEY
for f in app/logo.svg defaults/avatars/user-avatar-1.png defaults/avatars/org-avatar-1.png; do
  case $f in *.svg) type=image/svg+xml ;; *) type=image/png ;; esac
  curl -sS -X POST -H "apikey: $SECRET_KEY" -H "Content-Type: $type" -H 'x-upsert: false' \
    --data-binary "@supabase/storage_seeds/assets/$f" \
    "http://127.0.0.1:18000/storage/v1/object/assets/$f"
  echo
done

x-upsert: false makes Storage refuse an object that is already there. Emails and every new organization's default logo link to these files, and the edge cache keeps a replaced one for up to an hour, so a changed asset ships under a new file name, with the code that references it updated.

Vault by psql

The five Vault rows are the same as on Cloud (Vault by SQL), with your server's URLs:

Vault secretSelf-hosted value
supabase_urlhttps://api.myapp.com, the same as SB_URL
storage_urlhttps://api.myapp.com/storage/v1/object, the same as SB_STORAGE_URL
database_webhook_secretThe same value as the functions' DATABASE_WEBHOOK_SECRET
mfa_recovery_code_pepperA fresh pepper, openssl rand -base64 48
account_deletion_email_pepperA fresh pepper

storage_url must be public: the database stores URLs built on it, and emails show them. Postgres calls the functions on supabase_url from its webhooks and scheduled jobs, out and back in through your proxy like the Send Email hook. The internal http://api-gw:8000 also works there, if you would rather keep those calls on the stack's network.

Write the statements to a file only you can read, run it over the tunnel, then delete it:

vault.sql
select vault.create_secret('https://api.myapp.com', 'supabase_url');
select vault.create_secret('https://api.myapp.com/storage/v1/object', 'storage_url');
select vault.create_secret('<DATABASE_WEBHOOK_SECRET>', 'database_webhook_secret');
select vault.create_secret('<fresh pepper>', 'mfa_recovery_code_pepper');
select vault.create_secret('<fresh pepper>', 'account_deletion_email_pepper');
Terminal
psql "postgresql://postgres@127.0.0.1:15432/postgres?sslmode=disable" -v ON_ERROR_STOP=1 -f vault.sql
rm vault.sql

psql asks for the password, so it stays out of your shell history. To rotate a value, update the row in place rather than creating a second one:

select vault.update_secret(
  (select id from vault.secrets where name = 'database_webhook_secret'),
  '<new value>'
);

The SQL reads the Vault on every call, so a new value applies at once, with no restart. Change a value's twin among the function secrets in the same step, then recreate functions. Before rotating a pepper, read the MFA pepper and the account-deletion pepper.

Database Webhooks need nothing more: the stack's init script creates the supabase_functions schema that Cloud makes you enable.

Edge Functions

What the self-hosted runtime expects:

  • Deploying is a file copy. supabase functions deploy talks to Supabase's hosted API and has no self-hosted target. The functions container mounts volumes/functions/ and serves each <name>/index.ts in it through upstream's router, main/index.ts, which must stay in place. _shared/ works as it does locally, through relative imports. Leave out tests/ and .env.test.
  • deno.json becomes deno.jsonc. The router reads one import map for every function, at the fixed path deno.jsonc. Copy the kit's supabase/functions/deno.json to that name; it loads unchanged.
  • Recreate the container. Code is read from disk per request, but environment changes reach the functions only when the container is recreated; a restart keeps the old values. Recreate on every deploy and every secret change.
  • Outbound internet at cold start. deno.lock is not used: the npm: and jsr: imports resolve on first use into the deno-cache volume, which survives recreates.
  • 150 MB per function worker, fixed in the router.

Deploy

From your project root, with the stock layout (supabase-project/ in your home directory on the server):

Terminal
rsync -a --delete --exclude main/ --exclude deno.jsonc --exclude tests/ --exclude .env.test \
  supabase/functions/ you@your-server:supabase-project/volumes/functions/
ssh you@your-server 'cd supabase-project &&
  cp volumes/functions/deno.json volumes/functions/deno.jsonc &&
  docker compose up -d --force-recreate functions'

The excludes keep upstream's router and protect it from --delete. Your deno.jsonc replaces upstream's, which update.sh then reports as a local change on the next upgrade.

Floot's setup. Floot never writes into volumes/functions/. Its release assembles a separate tree, its functions, upstream's main/index.ts from the pinned tag, and deno.json copied as deno.jsonc, and its compose file mounts that tree instead, for Studio too:

docker-compose.floot.yml (excerpt)
services:
  studio:
    volumes:
      - ./functions:/app/edge-functions:ro,z

  functions:
    volumes:
      - ./functions:/home/deno/functions:z
    environment:
      # One switch for every function; each verifies its caller in code.
      VERIFY_JWT: "false"
      SB_URL: "https://api.floot.dev"
      SB_STORAGE_URL: "https://api.floot.dev/storage/v1/object"
      SB_INTERNAL_URL: http://api-gw:8000
      SB_PUBLISHABLE_KEY: "${SUPABASE_PUBLISHABLE_KEY?}"
      SB_SECRET_KEY: "${SUPABASE_SECRET_KEY?}"
      SB_SEND_EMAIL_HOOK_SECRET: "${SB_SEND_EMAIL_HOOK_SECRET?}"
      DATABASE_WEBHOOK_SECRET: "${DATABASE_WEBHOOK_SECRET?}"
      DENO_ENV: production
      SMTP_HOST: smtp.resend.com
      SMTP_PORT: "587"
      SMTP_USER: resend
      SMTP_PASSWORD: "${SMTP_PASSWORD?}"
      SMTP_FROM: Floot <noreply@floot.dev>

A deploy swaps the whole tree, recreates functions and studio, and only then calls a health function that answers with the release's version, which proves the new code runs, not just some code. Floot's server cannot reach port 465, so its mail goes out on 587.

Floot's release order

Floot's setup. Floot runs nothing from a laptop. Its secrets tool is the only writer of the Vault: it upserts the five rows through psql in the db container (vault.update_secret when a row exists, vault.create_secret otherwise). A release runs on the server, in this order:

  1. If migrations are pending, a pg_dump -Fc of the database, kept on the server.
  2. The stack's config installed and docker compose up -d, which recreates the services whose config changed; then a check that realtime.messages exists, refusing the release if it does not.
  3. supabase db push --skip-vault, from a one-shot container of a pinned CLI on the stack's network, against db:5432, carrying only POSTGRES_PASSWORD and SB_SEND_EMAIL_HOOK_SECRET.
  4. The functions tree swapped and functions recreated.
  5. The Caddy site installed, the whole Caddy config validated, then reloaded.
  6. The assets the bucket lacks uploaded, never overwriting one.
  7. Checks over loopback: Auth's health, PostgREST serving its schema, Storage's status, health answering this release's version, and / answering 404.

A failed migration is fixed forward with a new release. The database is never rolled back automatically, because a restore would discard what users wrote since; restoring the dump is a human decision.

Smoke check

Run the Cloud smoke check against your server: sign up, delete the account, round-trip an invitation, and check the cron jobs an hour later. On a self-hosted stack, it also proves the hook URI, the exposed schemas, the Vault rows and the server's route out and back in to its own hostname together. Then add the checks Cloud does not need:

  • https://api.myapp.com/ and https://api.myapp.com/pg/ answer 404.
  • From another machine, nothing answers on your server's ports 8000, 5432, 5433 and 6543.
  • A private image fetched twice is never cf-cache-status: HIT.
  • The newest backup restores, on another machine, with the pgsodium key from your password manager, and after the queue registry refill pgmq.list_queues() lists storage_cleanup and stripe_cleanup.

On this page