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:
-
Start the stack with
docker compose up -d. -
Wait until every service but
restreports healthy indocker compose ps.reststays unhealthy until the migrations createappandpgmq_public, whichPGRST_DB_SCHEMASnames; it recovers by itself once they exist. -
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.
-
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:
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=disableis required. The self-hosted Postgres has no TLS, and without it the CLI fails withThe 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 keyssetup.shgenerates 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:
psql "postgresql://postgres@127.0.0.1:15432/postgres?sslmode=disable" \
-v ON_ERROR_STOP=1 -f supabase/db_seeds/permissions_and_roles.sqlThe 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:
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
donex-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 secret | Self-hosted value |
|---|---|
supabase_url | https://api.myapp.com, the same as SB_URL |
storage_url | https://api.myapp.com/storage/v1/object, the same as SB_STORAGE_URL |
database_webhook_secret | The same value as the functions' DATABASE_WEBHOOK_SECRET |
mfa_recovery_code_pepper | A fresh pepper, openssl rand -base64 48 |
account_deletion_email_pepper | A 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:
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');psql "postgresql://postgres@127.0.0.1:15432/postgres?sslmode=disable" -v ON_ERROR_STOP=1 -f vault.sql
rm vault.sqlpsql 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 deploytalks to Supabase's hosted API and has no self-hosted target. Thefunctionscontainer mountsvolumes/functions/and serves each<name>/index.tsin it through upstream's router,main/index.ts, which must stay in place._shared/works as it does locally, through relative imports. Leave outtests/and.env.test. deno.jsonbecomesdeno.jsonc. The router reads one import map for every function, at the fixed pathdeno.jsonc. Copy the kit'ssupabase/functions/deno.jsonto 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.lockis not used: thenpm:andjsr:imports resolve on first use into thedeno-cachevolume, 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):
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:
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:
- If migrations are pending, a
pg_dump -Fcof the database, kept on the server. - The stack's config installed and
docker compose up -d, which recreates the services whose config changed; then a check thatrealtime.messagesexists, refusing the release if it does not. supabase db push --skip-vault, from a one-shot container of a pinned CLI on the stack's network, againstdb:5432, carrying onlyPOSTGRES_PASSWORDandSB_SEND_EMAIL_HOOK_SECRET.- The functions tree swapped and
functionsrecreated. - The Caddy site installed, the whole Caddy config validated, then reloaded.
- The assets the bucket lacks uploaded, never overwriting one.
- Checks over loopback: Auth's health, PostgREST serving its schema,
Storage's status,
healthanswering 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/andhttps://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()listsstorage_cleanupandstripe_cleanup.
Step by step
From an empty Ubuntu server to a production Floot backend, one checked step at a time: SSH, firewall, Docker, Caddy, Supabase's stack with your app's files, the first deploy, a reboot, backups and the smoke check, then your web app as a second site.
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.