Supabase Cloud
Take a Floot backend from nothing to a working Supabase Cloud project: link, secrets, functions, migrations, Vault, webhooks, auth and mail.
This page takes a generated project from nothing to a working Supabase Cloud deploy, in the order that works. The path was proven end to end on a new Free plan project, and the CLI messages quoted are the ones printed there.
Run every command from your project root, the directory that holds
supabase/, with the Supabase CLI 2.116.0 or newer. Read
Going to production first: its four rules apply to every step
below.
Create and link
Create the project in the Supabase dashboard: pick a region close to your users and set a strong database password (the form's Generate a password link makes one). Keep that password in your password manager; you need it again in a moment, and nothing on your machine stores it.
The form's Security options decide how the Data API treats your tables:
- Enable Data API: leave it checked. The app reads and writes through it.
- Automatically expose new tables: uncheck it. Checked, it grants
anon,authenticatedandservice_rolefull access to every new table inpublic. The kit's first migration revokes that default anyway, and every kit table carries its own explicit grants, so unchecking it starts the project in the state the migrations leave it in. - Enable automatic RLS: the kit does not need it. Its migrations enable
row level security on every table they create in
public.
Note the project ref, the subdomain in https://<ref>.supabase.co. Then log
the CLI in and link the project:
supabase login
supabase link --project-ref <ref>link prints Finished supabase link. without asking for a password. It
records the ref and the project's service versions under supabase/.temp/,
and never the database password.
Then put the database password in your shell, where db push looks for it:
read -rs SUPABASE_DB_PASSWORD && export SUPABASE_DB_PASSWORDread -s keeps the password out of your terminal and your shell history.
Why the password is not optional
Without SUPABASE_DB_PASSWORD, a linked db push logs in through a
temporary role the CLI creates (it prints Initialising login role...). That
role's session does not have the extensions schema on its search path, so
the kit's first migration stops with ERROR: type "citext" does not exist (SQLSTATE 42704). With the password, the push connects as postgres, whose
search path includes extensions, and applies cleanly.
Function secrets and deploy
The Edge Functions read their configuration from secrets set on the project,
the hosted counterpart of supabase/.env.local. Write them into a production
env file outside the repository (for example
~/secrets/myapp-production.env, readable only by you), then set them in one
command.
What goes in the file
The set is the schema in supabase/functions/_shared/env.ts. Every function
validates it when it boots, so one missing required value stops every function,
not just the one that needs it.
| Variable | Value on Cloud |
|---|---|
DENO_ENV | production |
ENABLE_MULTI_TENANCY, ENABLE_SEAT_BASED_BILLING, ENABLE_REVENUECAT | The same values as in the app's .env; the app and the functions must agree |
ENABLE_STRIPE | true if you take payments through Stripe; the functions alone read it |
SB_URL | https://<ref>.supabase.co |
SB_STORAGE_URL | https://<ref>.supabase.co/storage/v1/object |
SB_PUBLISHABLE_KEY | The project's publishable key (below) |
SB_SECRET_KEY | The project's secret key (below) |
SB_AUTH_EXTERNAL_REDIRECT_URI | https://<ref>.supabase.co/auth/v1/callback |
SB_SEND_EMAIL_HOOK_SECRET | A fresh v1,whsec_ secret; you paste the same value into the Send Email hook later |
DATABASE_WEBHOOK_SECRET | A fresh whsec_ secret; the same value goes into the Vault |
SMTP_FROM, SMTP_HOST, SMTP_PASSWORD, SMTP_PORT, SMTP_USER | Your mail provider's values; see Mail |
WEBSITE_URL | Your web app's public URL; invite links are built on it, so its host is your --web-domain |
Leave SB_INTERNAL_URL unset; it is for self-hosted stacks only. The feature
secrets are needed only when their flag is on: the STRIPE_* values for
Stripe, the REVENUECAT_* values for
RevenueCat. Each feature page lists its own.
Generate the two fresh secrets rather than reusing your local ones:
echo "v1,whsec_$(openssl rand -base64 32)"
echo "whsec_$(openssl rand -base64 32)"The first is SB_SEND_EMAIL_HOOK_SECRET, the second DATABASE_WEBHOOK_SECRET.
Both keys are on Project Settings → Configuration → API Keys, tab
Publishable and secret API keys, under Publishable key and Secret
keys (each has a default key). Copy the secret key from there. If you use
the CLI instead, pass --reveal:
supabase projects api-keys --project-ref <ref> --revealA masked key looks like a real one
Without --reveal, projects api-keys prints the secret key masked:
sb_secret_, four characters, then padding. Set that as SB_SECRET_KEY and
every admin call fails with Invalid API key, which the functions report as
a bare DATABASE_ERROR.
A filled-in file reads like this:
DENO_ENV=production
ENABLE_MULTI_TENANCY=true
ENABLE_SEAT_BASED_BILLING=false
ENABLE_STRIPE=false
ENABLE_REVENUECAT=false
SB_URL=https://<ref>.supabase.co
SB_STORAGE_URL=https://<ref>.supabase.co/storage/v1/object
SB_PUBLISHABLE_KEY=sb_publishable_...
SB_SECRET_KEY=sb_secret_...
SB_AUTH_EXTERNAL_REDIRECT_URI=https://<ref>.supabase.co/auth/v1/callback
SB_SEND_EMAIL_HOOK_SECRET=v1,whsec_...
DATABASE_WEBHOOK_SECRET=whsec_...
SMTP_FROM="My App <noreply@myapp.com>"
SMTP_HOST=smtp.example.com
SMTP_PASSWORD=...
SMTP_PORT=465
SMTP_USER=...
WEBSITE_URL=https://app.myapp.comWhy the names start with SB_
Supabase reserves the SUPABASE_ prefix for the variables it injects into
every deployed function (SUPABASE_URL, SUPABASE_SECRET_KEYS and others),
and secrets set refuses to set one:
Env name cannot start with SUPABASE_, skipping: <name>The kit's SB_ names keep one set of variables, under the kit's control, that
means the same thing locally, on Cloud and on a self-hosted stack.
Set the secrets, then deploy
supabase secrets set --env-file ~/secrets/myapp-production.env
supabase functions deployfunctions deploy with no names bundles and deploys every function under
supabase/functions/ (the _shared and tests folders are skipped), and
applies each function's verify_jwt setting from supabase/config.toml.
Bundling runs locally and needs Docker running (--use-api bundles on
Supabase's side instead). Each function also prints
Specifying import_map through flags is no longer supported. Please use deno.json instead.,
which is harmless.
Never deploy everything with --no-verify-jwt
--no-verify-jwt applies to every function in that command. Without a
function name, it turns the gateway's JWT check off on every function that
sets verify_jwt = true, account deletion and the billing endpoints among
them. Deploy with no flag and let config.toml decide.
To check the gateway is guarding what it should, call a protected function with no token:
curl -i https://<ref>.supabase.co/functions/v1/delete-accountIt answers 401 with
{"code":"UNAUTHORIZED_NO_AUTH_HEADER","message":"Missing authorization header"}.
A changed secret needs no redeploy: run secrets set again and the next request
reads the new value. Edge Functions → Manage → Secrets shows the same set,
under Custom secrets, by name and digest only.
Migrations
Preview first, then push:
supabase db push --skip-vault --dry-run
supabase db push --skip-vaultThe dry run lists the migrations it would apply and applies nothing, for example:
Would push these migrations:
• *_init.sql
• *_multi-tenancy-init.sql
• *_seat_based_billing.sqlThe real push asks for confirmation, applies each file in order, and records
it in the remote migration history. supabase migration list --linked
afterwards shows the same versions under Local and Remote. That history
is how the next push knows where to start, which is why an applied migration is
never edited: change it and production never sees the change.
On this first deploy, the functions went out before the schema existed, which is harmless because nothing calls them yet. From the second release on, follow the deploy order: migrations first.
Three rules for every push:
-
--skip-vault, always, or a push that applies a migration overwrites the remote Vault with your local values; see the rule. -
Never
--include-seed. The files insupabase/db_seeds/are for your local stack:seed.sqlholds placeholder plans and prices with demo Stripe and RevenueCat ids. Production gets its catalog from your payment provider. -
The send-email hook secret must resolve. Every CLI command that loads
supabase/config.toml(db push,functions deploy,db query --linked,migration list --linked) validates the[auth.hook.send_email]secret first. On your machine,supabase/.env.localsatisfies it. On a clean checkout, such as a CI job, exportSB_SEND_EMAIL_HOOK_SECRET(the production value is simplest), or every command stops before connecting:Invalid hook config: auth.hook.send_email.secrets must be formatted as "v1,whsec_<base64_encoded_secret>" with a minimum length of 32 characters.
Vault by SQL
Postgres cannot read your function secrets, so five values the database needs
live in the Vault, under lowercase names. Locally, [db.vault] seeds them from
supabase/.env.local; on Cloud nothing does, and --skip-vault makes sure the
CLI never will. You create them once, by SQL.
| Vault secret | Value |
|---|---|
supabase_url | https://<ref>.supabase.co, the same as SB_URL |
storage_url | https://<ref>.supabase.co/storage/v1/object, the same as SB_STORAGE_URL |
database_webhook_secret | The same value as the function secret DATABASE_WEBHOOK_SECRET |
mfa_recovery_code_pepper | A fresh pepper |
account_deletion_email_pepper | A fresh pepper |
These values are per host. Each project you run, staging and production alike,
gets its own, and none is ever the local one: your local supabase_url is
http://kong:8000, which only resolves inside Docker. Generate each pepper
with openssl rand -base64 48.
Write the five statements to a file only you can read, run it against the linked project, then delete it:
select vault.create_secret('https://<ref>.supabase.co', 'supabase_url');
select vault.create_secret('https://<ref>.supabase.co/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');supabase db query --linked -f vault.sql
rm vault.sqldb query --linked runs the SQL as postgres through Supabase's API, with no
database password. The rows then show on Integrations → Vault, tab
Secrets, where Add new secret is the dashboard route to the same
result.
To rotate a value later, update it in place rather than creating a second row:
select vault.update_secret(
(select id from vault.secrets where name = 'database_webhook_secret'),
'<new value>'
);database_webhook_secret, supabase_url and storage_url each have a twin
among the function secrets; change both sides together. A pepper rotation has
consequences for data already stored; read
the MFA pepper and
the account-deletion pepper first.
Reference data and storage
Two things the app needs at runtime come from files db push does not run.
Multi-tenancy only
This section applies only to projects generated with `--multi-tenancy`.
Roles and permissions. The owner, admin and member roles, their
permissions and the links between them live in
supabase/db_seeds/permissions_and_roles.sql. Run it once against the project:
supabase db query --linked -f supabase/db_seeds/permissions_and_roles.sqlThe full set is 3 roles, 25 permissions and 25 role and permission links. Run it only once: a second run fails on rows that already exist.
The assets bucket. The first migration creates the public assets
bucket, but not the files the emails link to: the logo and the default
avatars in supabase/storage_seeds/assets/. Upload them with:
supabase seed buckets --linkedBecause the bucket already exists, the CLI asks
Bucket assets already exists. Do you want to overwrite its properties? [Y/n].
Answer yes: it applies the bucket settings from config.toml (public, 2 MiB,
PNG, JPEG and SVG only) and uploads the objects.
Emails build their image links on SB_STORAGE_URL, so it must point at this
project's storage, https://<ref>.supabase.co/storage/v1/object, in the
function secrets and as storage_url in the Vault. A wrong value fails
quietly: the emails send, with broken images.
Database Webhooks
Open Integrations → Database Webhooks and click Enable webhooks. Once enabled, the page shows an INSTALLED badge and a Webhooks tab reading No hooks created yet; that is expected, because the kit creates no dashboard webhooks.
What the kit needs is the supabase_functions schema that enabling creates.
Its database triggers call Edge Functions through app.trigger_edge_webhook,
which records each call in supabase_functions.hooks. The local stack always
has that schema; a new Cloud project does not, and in a multi-tenancy project
every invitation and ownership transfer fails until it exists:
ERROR: 42P01: relation "supabase_functions.hooks" does not existExposed schemas
The functions read and write two schemas besides public over the API: app,
and pgmq_public for the queues the cleanup jobs drain. A new project exposes
only public and graphql_public.
Open Integrations → Data API → Settings. Under Exposed schemas, check
app and pgmq_public and keep public and graphql_public; leave every
other schema unchecked. Make sure Extra search path reads public and
extensions.
Until both are exposed, the API answers PGRST106 (Invalid schema: pgmq_public) and the functions that need them fail with a bare
DATABASE_ERROR.
Never use the Expose Queues toggle
The Queues settings offer Expose Queues via PostgREST. Do not touch it.
Turned on, it grants the pgmq_public functions, and the raw pgmq ones, to
anon, so anyone holding your publishable key can read and write your
queues. Turned off, it drops the pgmq_public schema the kit's migrations
created. Either way breaks the kit; add the schema by hand as above.
Auth settings
Auth settings on Cloud live in the dashboard, not in config.toml. The local
values in [auth] (a 127.0.0.1 Site URL, a host.docker.internal hook) are
right for your machine and wrong everywhere else.
URLs
On Authentication → Configuration → URL Configuration:
- Site URL: your web app's public URL. Auth builds its links on it, to the exact host and port.
- Redirect URLs: click Add URL for each place Auth may send a user
back to: your web app's paths,
https://app.myapp.com/**, since web sign-in returns to the page it started from (see Auth redirect URLs), and your app's callback,<your scheme>://oauth-callback(see Deep Links). A local preview on another origin needs its own entry too.
Providers
Enable each social provider you offer under Authentication in the
dashboard, with the client ids and secrets from its page:
Apple, Google,
GitHub. Their callback is
https://<ref>.supabase.co/auth/v1/callback, the same URL as
SB_AUTH_EXTERNAL_REDIRECT_URI.
Password and code rules
A new project starts with Supabase's own Auth defaults, not the values in the
kit's config.toml, and four of them differ. Open Authentication →
Configuration → Sign In / Providers, click Email under Auth
Providers, set the first three to the kit's values, then Save:
| Field | New project | Kit value | Why it matters |
|---|---|---|---|
| Minimum password length | 6 | 8 | The server floor under the app's own, stricter check |
| Password requirements | No required characters (default) | Lowercase, uppercase letters, digits and symbols (recommended) | Mirrors the app's password validator |
| Email OTP length | 8 | 6 | The app's code screen has six boxes; a longer code cannot be entered |
The fourth is on Authentication → Configuration → Rate Limits:
Minimum interval per user starts at 60 seconds, the same as the app's
resend cooldown. The kit uses 45, so a user who waits out the cooldown and
taps Resend never hits the server's limit. A new project shows this field
greyed out, noting that it only applies when custom SMTP is configured. Set it
after the Send Email hook below is on; if it stays greyed out, the
config.toml alternative sets it as
max_frequency.
See The settings behind these flows for what each one does.
The Send Email hook
The kit's email flows send through the send-email function, so Auth must call
it. On Authentication → Configuration → Auth Hooks, click Add hook,
then Send Email hook:
- Type: HTTPS.
- URL:
https://<ref>.supabase.co/functions/v1/send-email. - Secret: paste your
SB_SEND_EMAIL_HOOK_SECRETinstead of generating a new one. The value must match the function secret exactly.
Then Create hook. Cloud accepts only an https:// URL here; the form
refuses anything else with The URL must start with https://.
Leave Confirm email on, under User Signups on Sign In / Providers, as a new project ships it: the kit's sign-up is built on the 6-digit confirmation code, and a user is routed to enter it before they can sign in.
With the hook on, raise the email limit on Authentication → Configuration →
Rate Limits. Rate limit for sending emails starts at 2 emails per hour,
and failed sends count against it, so a couple of failed attempts while you set
up mail lock sign-ups out for the hour with email rate limit exceeded. The
kit's local stack allows 60 per hour; pick a ceiling that fits your traffic.
Security notifications
The kit sends a notice when a password changes or an MFA method is added or
removed. Locally these are on; a new Cloud project starts with them off. Once
the Send Email hook is set, open Authentication → Emails → Security, switch
on Password changed, MFA method added and MFA method removed, then
Save changes. The same page notes
Email templates are not used: A Send Email hook is active, which is expected:
the kit renders its own templates.
Every email the kit sends goes through your provider's SMTP, from the
send-email function, using the five SMTP_* function secrets. Two values
need care on Cloud.
SMTP_FROMisName <address>, on a domain your provider has verified. Anything else in that field can be refused (a bare domain gets501 Bad sender address syntax), and so is a sender on any domain the provider has not verified, including a subdomain you only added for bounce records.- Use port 465. Supabase documents outgoing connections to ports 25 and 587 as blocked from Edge Functions. Port 465 is not, and the kit switches to implicit TLS on 465 by itself (see Moving to a real provider).
A mail mistake never says so in the app. Auth sees the hook fail with
Unexpected status code returned from hook: 502, rolls the sign-up back and
answers 500 unexpected_failure. The cause is only in the function's logs:
Edge Functions → send-email → Logs.
For example, Floot sends its own mail through Resend, which verified the apex
domain floot.dev:
SMTP_FROM="Floot <noreply@floot.dev>"
SMTP_HOST=smtp.resend.com
SMTP_PORT=465
SMTP_USER=resend
SMTP_PASSWORD=<Resend API key>A sender on send.floot.dev, the subdomain holding Resend's bounce records,
is refused as unverified.
Billing before invites
Seat-based billing only
This section applies only to projects generated with `--seat-based-billing`.
With seat-based billing, an organization cannot invite anyone until it has an
active subscription, whatever the app's flags say: the database refuses the
invitation with NO_ACTIVE_SUBSCRIPTION. Set up Stripe
and your products before you invite the first member; see
Seat-Based Billing.
The Free plan
The Free plan runs the kit, with limits worth knowing before you launch on it:
- Uploads are capped at 50 MB. The kit's project-wide
file_size_limit = "50MiB"inconfig.tomlis slightly above that, so on Free the plan's cap is the real ceiling. The buckets the kit ships cap themselves far lower (2 MiB and 5 MiB); the cap matters for a bucket you add. - Built-in email is limited to 2 per hour, which is why the kit sends through the Send Email hook and your own provider.
- Idle projects pause. Supabase pauses a Free project after about a week without activity; see Free project pausing.
- No backups, and two active projects per organization.
Smoke check
With everything above in place, prove the live project end to end from a release build of the app pointed at it (see Building for release):
-
Sign up. The confirmation email arrives from your sender with a 6-digit code, and entering it signs you in. This proves the hook, the function and your SMTP settings. Right after a deploy or a restart, the first sign-up can outlast Auth's 5-second wait for the hook: the email still goes out, the app retries, and a second email follows. Only the later code works.
-
Call a protected function. Delete that account from the app's settings; the deletion succeeds and its email arrives. This proves a signed-in user's token passes the gateway's JWT check. In a multi-tenancy project, the app first asks you to delete the organization the sign-up created, and that takes an authenticator app: deleting an organization asks for a fresh code, even with
ENABLE_MFAoff. -
Round-trip a webhook. In a multi-tenancy project, sign up again and invite another address into your organization (with seat-based billing, the organization needs its subscription first); the invitation email arrives and accepting it works. This proves Database Webhooks, the Vault rows and the exposed schemas together.
-
Wait an hour. The kit's scheduled jobs run hourly or every few hours; then check that they succeeded:
select jobname, status, start_time from cron.job_run_details join cron.job using (jobid) order by start_time desc limit 10;
A timed-out invitation that still arrived
In net._http_response, the call to handle-org-invitation can show
timed_out even though the invitation email arrived. The function sends the
mail before it answers, and a cold start plus the SMTP exchange can outlast
the database's 5-second wait. The invitation itself went out.
Alternative: settings from config.toml
Pushing settings from config.toml instead of the dashboard
supabase config push can send the exposed schemas, the auth settings and
the Database Webhooks switch from supabase/config.toml instead of you
clicking them. Use it only with CLI 2.119 or newer and a
[remotes.<name>] block:
- Older CLIs leak. Before 2.119,
config pushsends every auth field on any change, resolvingenv()values fromsupabase/.env.local: your local Site URL, redirect URLs, thehost.docker.internalhook URI and your local hook secret. A Free project on the built-in mailer refuses that push outright. 2.119 sends only the values that differ and skips a secret it cannot resolve, but without the block it still pushes your local values. - The
[remotes.<name>]block overrides only what it declares. Itsproject_idmust be your ref, and it must redeclare every value that differs from your local one: Site URL, redirect URLs, the hook URI and its secret, and every[auth.external.<provider>]you enabled locally. Anything it leaves out is pushed from the base config.
[remotes.production]
project_id = "<ref>"
[remotes.production.api]
schemas = ["public", "graphql_public", "app", "pgmq_public"]
extra_search_path = ["public", "extensions"]
[remotes.production.auth]
site_url = "https://app.myapp.com"
additional_redirect_urls = ["https://app.myapp.com/**", "com.myapp://oauth-callback"]
[remotes.production.auth.hook.send_email]
enabled = true
uri = "https://<ref>.supabase.co/functions/v1/send-email"
secrets = "env(PROD_SEND_EMAIL_HOOK_SECRET)"
[experimental.webhooks]
enabled = truePreview with supabase config diff, then run supabase config push from the
project root and answer each prompt; decline any section you did not mean to
change. The output opens with Loading config override: [remotes.production]
when the block applies. [experimental.webhooks] enabled = true is the CLI's
route to Enable webhooks. The dashboard remains the proven route for the
Send Email hook; drop that block if you set the hook there.
Once the block's project_id matches the linked project, every linked
command (db push, functions deploy, db query) validates the block's hook
secret too, so PROD_SEND_EMAIL_HOOK_SECRET must be exported for all of them,
not just for config push.
In a pipeline
The kit ships no CI workflow; the commands above are the whole deploy. A pipeline that runs them must:
- Authenticate with a
SUPABASE_ACCESS_TOKENand link withsupabase link --project-ref <ref>. - Export
SUPABASE_DB_PASSWORDandSB_SEND_EMAIL_HOOK_SECRETfrom its secret store, never from a committed file. - Run
supabase db push --skip-vaultbeforesupabase functions deploy, and stop if the push fails. Never pass--include-seed. - Set function secrets and Vault rows in a separate, deliberate step, not on every deploy.
- Write the app's
.envfrom its secret store beforeflutter build: the file is git-ignored but listed inpubspec.yaml, so a clean checkout fails the build without it. - Release the clients only after the backend deploy succeeded.
Going to production
What goes live when you ship a Floot app, the order it ships in, the rules that hold on every deploy, and a launch checklist.
Overview
Run a Floot backend on your own server from Supabase's official Docker setup: what differs from Cloud, the request path, and where each topic lives.