Flootdocs

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 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, authenticated and service_role full access to every new table in public. 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:

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

Terminal
read -rs SUPABASE_DB_PASSWORD && export SUPABASE_DB_PASSWORD

read -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.

VariableValue on Cloud
DENO_ENVproduction
ENABLE_MULTI_TENANCY, ENABLE_SEAT_BASED_BILLING, ENABLE_REVENUECATThe same values as in the app's .env; the app and the functions must agree
ENABLE_STRIPEtrue if you take payments through Stripe; the functions alone read it
SB_URLhttps://<ref>.supabase.co
SB_STORAGE_URLhttps://<ref>.supabase.co/storage/v1/object
SB_PUBLISHABLE_KEYThe project's publishable key (below)
SB_SECRET_KEYThe project's secret key (below)
SB_AUTH_EXTERNAL_REDIRECT_URIhttps://<ref>.supabase.co/auth/v1/callback
SB_SEND_EMAIL_HOOK_SECRETA fresh v1,whsec_ secret; you paste the same value into the Send Email hook later
DATABASE_WEBHOOK_SECRETA fresh whsec_ secret; the same value goes into the Vault
SMTP_FROM, SMTP_HOST, SMTP_PASSWORD, SMTP_PORT, SMTP_USERYour mail provider's values; see Mail
WEBSITE_URLYour 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:

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

Terminal
supabase projects api-keys --project-ref <ref> --reveal

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

~/secrets/myapp-production.env
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.com

Why 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

Terminal
supabase secrets set --env-file ~/secrets/myapp-production.env
supabase functions deploy

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

Terminal
curl -i https://<ref>.supabase.co/functions/v1/delete-account

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

Terminal
supabase db push --skip-vault --dry-run
supabase db push --skip-vault

The 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.sql

The 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 in supabase/db_seeds/ are for your local stack: seed.sql holds 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.local satisfies it. On a clean checkout, such as a CI job, export SB_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 secretValue
supabase_urlhttps://<ref>.supabase.co, the same as SB_URL
storage_urlhttps://<ref>.supabase.co/storage/v1/object, the same as SB_STORAGE_URL
database_webhook_secretThe same value as the function secret DATABASE_WEBHOOK_SECRET
mfa_recovery_code_pepperA fresh pepper
account_deletion_email_pepperA 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:

vault.sql
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');
Terminal
supabase db query --linked -f vault.sql
rm vault.sql

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

Terminal
supabase db query --linked -f supabase/db_seeds/permissions_and_roles.sql

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

Terminal
supabase seed buckets --linked

Because 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 exist

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

FieldNew projectKit valueWhy it matters
Minimum password length68The server floor under the app's own, stricter check
Password requirementsNo required characters (default)Lowercase, uppercase letters, digits and symbols (recommended)Mirrors the app's password validator
Email OTP length86The 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_SECRET instead 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.

Mail

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_FROM is Name <address>, on a domain your provider has verified. Anything else in that field can be refused (a bare domain gets 501 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:

~/secrets/floot-production.env
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" in config.toml is 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):

  1. 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.

  2. 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_MFA off.

  3. 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.

  4. 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 push sends every auth field on any change, resolving env() values from supabase/.env.local: your local Site URL, redirect URLs, the host.docker.internal hook 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. Its project_id must 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.
supabase/config.toml
[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 = true

Preview 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_TOKEN and link with supabase link --project-ref <ref>.
  • Export SUPABASE_DB_PASSWORD and SB_SEND_EMAIL_HOOK_SECRET from its secret store, never from a committed file.
  • Run supabase db push --skip-vault before supabase 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 .env from its secret store before flutter build: the file is git-ignored but listed in pubspec.yaml, so a clean checkout fails the build without it.
  • Release the clients only after the backend deploy succeeded.

On this page