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.
This guide takes one empty server to a working production backend for your
Floot app, in the order the work is done. Every step ends with a check: run
it, and go on only once it passes. The guide prescribes one path, the one
Floot runs api.floot.dev on: Caddy in Docker in front of Supabase's own
self-hosted stack, with your app's settings in files of your own. On another
stack, adapt it; each step links the page of this group that holds the rules
behind it.
Read Going to production first: its four rules apply here
unchanged. The examples use api.myapp.com for your backend, app.myapp.com
for your web app, you for your user on the server and your-server for its
address.
On your laptop you need your Floot project, the
Supabase CLI 2.116.0 or newer, psql, rsync and
age. Commands titled Terminal run there, from
your project root; those titled Terminal (on the server) run in an SSH
session.
The files. Each file you create appears whole, titled with the path it will have in your project once the Floot CLI ships these files. The step says where it goes on the server.
1. The server
Rent a server with:
- Ubuntu 24.04 LTS, x86_64 or arm64.
- At least 4 GB of memory, 2 vCPU and 40 GB of disk.
- A public IPv4 address, and SSH key login from your provider's panel.
Other distributions are out of scope: every command below is Ubuntu's. Before you choose a host, check which outgoing mail ports it allows; many block 25 and 465 (see Mail ports).
Check. Log in with the user your provider gave you, root here:
ssh root@your-server 'lsb_release -ds; uname -m; nproc; free -h | grep Mem; df -h /'It prints Ubuntu 24.04 and a point release, x86_64 or aarch64, at least
2 processors, about 4 GB of total memory or more, and at least 40 GB on /.
2. DNS
At your DNS provider, add an A record for api.myapp.com pointing at the
server's IPv4 address. Add an AAAA record only if the server has a public IPv6
address too.
Put no CDN in front for now. If your DNS is on Cloudflare, set the record to DNS only, the grey cloud: this guide's Caddy takes the client's address straight from the connection, and a CDN changes that. Behind Cloudflare adds one later.
Check. It prints the server's address. Caddy asks for a certificate at its first start in step 8, so wait for this before then:
dig +short api.myapp.com A3. SSH with keys only
Work as a user of your own with sudo, never as root. If your provider gave
you root, make that user, with the same SSH key. A minimal image may lack
rsync, which this step and step 15 use, so install it first:
apt-get update && apt-get install -y rsync
adduser you
usermod -aG sudo you
rsync --archive --chown=you:you ~/.ssh /home/youLog in as you from a new terminal, and keep the first session open until this
step's check passes: a mistake here locks you out, and only your provider's
console gets you back in.
Then turn password and root login off, in a file of its own:
# Only SSH keys log in, and never as root. sshd keeps the first value it reads, and this file
# sorts before any a provider adds (such as 50-cloud-init.conf), so its values win.
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication noSave it on the server as /etc/ssh/sshd_config.d/00-keys-only.conf, then check
the whole configuration and reload:
sudo nano /etc/ssh/sshd_config.d/00-keys-only.conf
sudo sshd -t && sudo systemctl try-reload-or-restart ssh.serviceCheck. On the server, the settings in force are all no:
sudo sshd -T | grep -E '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication) 'From your laptop, the first command prints key-ok, and the other two end
with Permission denied (publickey):
ssh you@your-server true && echo key-ok
ssh -o PubkeyAuthentication=no you@your-server
ssh root@your-server4. The firewall
Allow SSH, HTTP and HTTPS in, and nothing else:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443
sudo ufw enableufw enable warns that it may disrupt SSH connections; answer y, since
OpenSSH is already allowed. 443 takes both TCP and UDP, for HTTP/3.
Docker bypasses ufw
A port Docker publishes skips ufw's rules entirely, so ufw cannot close
it. Every port this guide publishes, Caddy's 80 and 443 aside, is bound to
127.0.0.1; keep it that way for anything you add. See
Exposure.
Check. It shows Status: active, Default: deny (incoming), and rules
allowing OpenSSH, 80/tcp and 443, for IPv4 and IPv6:
sudo ufw status verbose5. Updates, and swap if you want it
Ubuntu installs its security updates every day by itself, through
unattended-upgrades, but never reboots for them: a kernel or library update
waits until you reboot, and /var/run/reboot-required exists meanwhile. When
it does, reboot in a quiet hour; step 16 proves the backend comes back by
itself.
Check. The service is enabled, and the configuration prints
APT::Periodic::Unattended-Upgrade "1"; and either no reboot line or
Unattended-Upgrade::Automatic-Reboot "false";:
systemctl is-enabled unattended-upgrades
apt-config dump | grep -E '^(APT::Periodic::Unattended-Upgrade|Unattended-Upgrade::Automatic-Reboot) 'If the first line is missing, run sudo dpkg-reconfigure -plow unattended-upgrades
and answer Yes.
Optional: a swap file. On a 4 GB server, one memory spike can make the
kernel kill a process to free memory, and Postgres is the largest one there. A
small swap file absorbs the spike. If swapon --show prints nothing, add one:
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstabswapon --show then lists /swapfile at 2G. Full hardening goes beyond this
guide; Ubuntu's
Security suggestions
cover it.
6. Supabase's setup
Supabase's setup.sh installs Docker, copies the stack's files at a release
tag into ~/supabase-project, writes its .env and generates every key the
stack needs. It starts nothing. Pin the tag:
curl -fsSL https://supabase.link/setup.sh | sh -s -- --ref self-hosted/v0.8.2It asks for four values:
SUPABASE_PUBLIC_URL:https://api.myapp.comAPI_EXTERNAL_URL: accept its default,https://api.myapp.com/auth/v1SITE_URL: your web app,https://app.myapp.comPROXY_DOMAIN: accept its default; nothing in this guide reads it.
The stack explains what the
script does and why the tag is pinned. It adds you to the docker group, which
takes effect at your next login, so log out and back in.
Check. In the new session:
cd ~/supabase-project
cat .supabase-version
grep -E '^(SUPABASE_PUBLIC_URL|API_EXTERNAL_URL|SITE_URL)=' .env
sh run.sh secrets
grep -cE '^ *(GOTRUE_JWT_KEYS|API_JWT_JWKS|JWT_JWKS|SUPABASE_JWKS):' docker-compose.yml
docker ps.supabase-version ends with ref=self-hosted/v0.8.2, the three URLs are
yours, run.sh secrets prints six values none of which is a placeholder such
as your-super-secret-and-long-postgres-password, the grep prints 4 (the
script turns on the asymmetric keys the new API keys need, in Auth, Realtime,
Storage and the functions), and docker ps prints its header, with no sudo
and no containers.
7. Docker, before anything starts
Two settings must be in place before the first container is created.
Docker Compose 2.24.4 or newer. Your compose file overrides upstream's port
lists with !override, which older versions read wrongly: Compose 2.24.2
keeps upstream's port as well, and publishes the gateway on every interface.
setup.sh installs a current version from Docker's repository; check it all
the same, in case Docker was there before:
docker compose version --shortLog rotation. Docker keeps every container's log in one file that grows
forever, and a log setting reaches only containers created after it. Write
Docker's daemon configuration now, while none exist. live-restore keeps the
containers running while Docker itself restarts or upgrades:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"live-restore": true
}Save it as /etc/docker/daemon.json, then restart Docker, which is harmless
with nothing running:
sudo nano /etc/docker/daemon.json
sudo systemctl restart dockerCheck. docker compose version --short printed 2.24.4 or a later
version, and this prints json-file true; step 10 checks that a container got
the rotation:
docker info --format '{{.LoggingDriver}} {{.LiveRestoreEnabled}}'8. Caddy
Caddy is the server's front door: it holds the certificates, terminates TLS
and forwards the API paths to the stack. It runs as a compose project of its
own in ~/caddy, so your web app or anything
else you serve later is one more site file, not a change to the stack. First
the Docker network it shares with the stack, and its directories:
docker network create caddy_public
mkdir -p ~/caddy/config/snippets ~/caddy/config/sites ~/caddy/srvThe compose file, saved as ~/caddy/compose.yml:
# The front door every site on the server shares: Caddy, its own compose project.
#
# The project name is pinned, so the volumes keep their names (caddy_caddy_data holds the
# certificates) wherever this directory moves. The image is an exact version: an upgrade is a
# change you make on purpose. The config directory is mounted whole, never a single file: a file
# mount keeps the old inode, and hides every edit that replaces the file. srv holds the static
# files a site serves, such as your web app's build, mounted whole for the same reason.
name: caddy
services:
caddy:
image: caddy:2.11.7
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./config:/etc/caddy:ro
- ./srv:/srv:ro
- caddy_data:/data
- caddy_config:/config
networks:
- caddy_public
volumes:
caddy_data:
caddy_config:
networks:
caddy_public:
external: trueThe Caddyfile, saved as ~/caddy/config/Caddyfile, with your own address for
Let's Encrypt's expiry notices:
# Global options, then every snippet and every site. A site is one file in sites/; nothing else
# changes here when you add one.
#
# No trusted_proxies: with no CDN in front, the peer is the client, and Caddy's {client_ip} is
# its address.
{
email you@myapp.com
}
import snippets/*
import sites/*The response headers every site imports, saved as
~/caddy/config/snippets/security:
# Response headers for every site that imports them.
(security_headers) {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
X-Frame-Options "SAMEORIGIN"
}
}The API's site, saved as ~/caddy/config/sites/api.caddy. It forwards the API
paths to the gateway and answers 404 to everything else, Studio included
(Exposure sets out each rule), and forces
private, no-store on private Storage, so no cache on the way, a CDN you add
later or a shared proxy, ever keeps a user's file
(Storage behind a CDN):
# The API: Auth, PostgREST, Realtime, Storage, the Edge Functions and the OAuth server metadata,
# proxied to the stack's gateway on caddy_public. Studio, postgres-meta (/pg/), GraphQL and
# Storage's S3 protocol answer 404 here, whatever the gateway would route. Caddy matches the path
# unescaped and cleaned, the way the gateway reads it, so no other spelling of a refused path
# gets through.
api.myapp.com {
import security_headers
# 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 {
# Storage's private answers (signed URLs, authenticated objects, bucket listings) must
# never be cached on the way, whatever Storage says. Public objects keep their headers.
@private_storage {
path /storage/v1/*
not path /storage/v1/object/public/*
}
header @private_storage {
Cache-Control "private, no-store"
-Expires
defer
}
# Auth's per-IP rate limits key on X-Real-IP: always the client's address, overwriting
# any value the request sent.
reverse_proxy myapp-api-gw:8000 {
header_up X-Real-IP {client_ip}
}
}
handle {
respond 404
}
}Start Caddy:
cd ~/caddy
docker compose up -dTo change a site or add one later, edit its file, then validate the whole configuration before Caddy loads it; a reload never restarts the container:
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfileCheck. On the server, docker compose logs caddy | grep 'certificate obtained successfully'
prints a line for api.myapp.com. From your laptop, over a certificate curl
accepts, / answers 404, and the API answers 502 until the stack starts in
step 10:
curl -s -o /dev/null -w '%{http_code}\n' https://api.myapp.com/
curl -s -o /dev/null -w '%{http_code}\n' https://api.myapp.com/auth/v1/.well-known/jwks.json9. Your app's files
Supabase's files stay as setup.sh left them, so that an
upgrade merges cleanly. Everything your
app changes goes into compose files of your own, layered on top, and the values
that differ per server go into a block of your own at the end of .env.
The compose file
Saved as ~/supabase-project/docker-compose.myapp.yml. It works for every
project as it stands, reading your URLs from the three values setup.sh asked
for and everything else from .env:
- Exposure: the gateway joins
caddy_publicasmyapp-api-gw, the name your API site proxies to, and every published port, the gateway's, the pooler's and Postgres's own, is bound to127.0.0.1, for the SSH tunnel. - Exposed schemas:
appandpgmq_public, which the functions need (Exposed schemas). - Auth: the kit's values where Auth's defaults differ, the rate-limit
header and the Send Email hook
(From
config.tomlto environment variables). - The functions' secrets (Function secrets).
# Your app's settings for Supabase's self-hosted stack, layered over upstream's docker-compose.yml
# by COMPOSE_FILE in .env. Upstream's files stay as they arrived, so an upgrade never conflicts
# with a change of yours. Every value that differs per server comes from .env: upstream's
# SUPABASE_PUBLIC_URL, API_EXTERNAL_URL and SITE_URL, and the "# Floot app" block. Needs Docker
# Compose 2.24.4 or newer, for !override.
services:
# Loopback only: Caddy reaches the gateway on caddy_public, and you reach it through the tunnel.
api-gw:
ports: !override
- 127.0.0.1:8000:8000
networks:
caddy_public:
aliases:
- myapp-api-gw
# The pooler stays, on loopback only.
supavisor:
ports: !override
- 127.0.0.1:${POSTGRES_PORT}:5432
- 127.0.0.1:${POOLER_PROXY_PORT_TRANSACTION}:6543
# Postgres itself, for db push, psql and pg_dump through the tunnel.
db:
ports:
- 127.0.0.1:5433:5432
rest:
environment:
# [api] schemas and extra_search_path: app and pgmq_public too, or the functions fail.
PGRST_DB_SCHEMAS: app,graphql_public,pgmq_public,public
PGRST_DB_EXTRA_SEARCH_PATH: public,extensions
studio:
environment:
PGRST_DB_SCHEMAS: app,graphql_public,pgmq_public,public
PGRST_DB_EXTRA_SEARCH_PATH: public,extensions
auth:
environment:
# [auth] additional_redirect_urls: the web app, and the mobile apps' OAuth callback.
GOTRUE_URI_ALLOW_LIST: "${SITE_URL}/**,${APP_URL_SCHEME:?}://oauth-callback"
# [auth] refresh_token_reuse_interval
GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: "10"
# [auth] minimum_password_length
GOTRUE_PASSWORD_MIN_LENGTH: "8"
# [auth] password_requirements = "lower_upper_letters_digits_symbols", as the CLI passes it:
# classes split on ':', '\:' a literal colon, and $$ a literal $ in Compose.
GOTRUE_PASSWORD_REQUIRED_CHARACTERS: 'abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$$%^&*()_+-=[]{};''\\:"|<>?,./`~'
# [auth.rate_limit] email_sent, per hour, then token_refresh, sign_in_sign_ups and
# token_verifications, per 5 minutes and IP.
GOTRUE_RATE_LIMIT_EMAIL_SENT: "60"
GOTRUE_RATE_LIMIT_TOKEN_REFRESH: "150"
GOTRUE_RATE_LIMIT_OTP: "30"
GOTRUE_RATE_LIMIT_VERIFY: "30"
# No config.toml key: Caddy overwrites X-Real-IP with the client's address. Without it,
# every per-IP limit above is off.
GOTRUE_RATE_LIMIT_HEADER: X-Real-IP
# [auth.email] max_frequency and otp_expiry
GOTRUE_SMTP_MAX_FREQUENCY: 45s
GOTRUE_MAILER_OTP_EXP: "3600"
# [auth.email.notification.*] enabled
GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED: "true"
GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_ENROLLED_ENABLED: "true"
GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_UNENROLLED_ENABLED: "true"
# [auth.hook.send_email]: an https URI, out through Caddy and back.
GOTRUE_HOOK_SEND_EMAIL_ENABLED: "true"
GOTRUE_HOOK_SEND_EMAIL_URI: "${SUPABASE_PUBLIC_URL}/functions/v1/send-email"
GOTRUE_HOOK_SEND_EMAIL_SECRETS: "${SB_SEND_EMAIL_HOOK_SECRET:?}"
# [auth.sms] enable_signup, and no autoconfirm for a phone nobody confirmed.
GOTRUE_EXTERNAL_PHONE_ENABLED: "false"
GOTRUE_SMS_AUTOCONFIRM: "false"
functions:
environment:
# One switch for every function; each kit function checks its caller in code.
VERIFY_JWT: "false"
# What the kit's functions read (supabase/functions/_shared/env.ts).
DENO_ENV: production
SB_URL: "${SUPABASE_PUBLIC_URL}"
SB_STORAGE_URL: "${SUPABASE_PUBLIC_URL}/storage/v1/object"
# The functions' own API calls stay on the stack's network, with the secret key.
SB_INTERNAL_URL: http://api-gw:8000
SB_PUBLISHABLE_KEY: "${SUPABASE_PUBLISHABLE_KEY:?}"
SB_SECRET_KEY: "${SUPABASE_SECRET_KEY:?}"
SB_AUTH_EXTERNAL_REDIRECT_URI: "${API_EXTERNAL_URL}/callback"
SB_SEND_EMAIL_HOOK_SECRET: "${SB_SEND_EMAIL_HOOK_SECRET:?}"
DATABASE_WEBHOOK_SECRET: "${DATABASE_WEBHOOK_SECRET:?}"
WEBSITE_URL: "${SITE_URL}"
ENABLE_MULTI_TENANCY: "${ENABLE_MULTI_TENANCY:?}"
SMTP_HOST: "${APP_SMTP_HOST:?}"
SMTP_PORT: "${APP_SMTP_PORT:?}"
SMTP_USER: "${APP_SMTP_USER:?}"
SMTP_PASSWORD: "${APP_SMTP_PASSWORD:?}"
SMTP_FROM: "${APP_SMTP_FROM:?}"
networks:
caddy_public:
external: trueA value written ${NAME:?} must be in .env: Compose refuses to start while
one is missing, and names it.
The # Floot app block of .env
Append your app's block to the end of .env. This command generates the two
secrets fresh and leaves CHANGE_ME where a value is yours to enter:
cat >> ~/supabase-project/.env <<EOF
############
# Floot app
############
# The same value as ENABLE_MULTI_TENANCY in the app's .env.
ENABLE_MULTI_TENANCY=true
# Your app's URL scheme, for its OAuth callback.
APP_URL_SCHEME=com.myapp
# The Send Email hook's secret, shared by Auth and the functions.
SB_SEND_EMAIL_HOOK_SECRET=v1,whsec_$(openssl rand -base64 32)
# Signs the database's calls to the functions; the Vault holds the same value.
DATABASE_WEBHOOK_SECRET=whsec_$(openssl rand -base64 32)
# The functions' mail. The SMTP_ values above are Auth's, unused while the hook is on.
APP_SMTP_HOST=CHANGE_ME
APP_SMTP_PORT=587
APP_SMTP_USER=CHANGE_ME
APP_SMTP_PASSWORD=CHANGE_ME
APP_SMTP_FROM="My App <noreply@myapp.com>"
EOFThen open .env (nano ~/supabase-project/.env) and fill the block in:
ENABLE_MULTI_TENANCY: the value your app's.envholds; the app and the functions must agree.APP_URL_SCHEME: your app's scheme, the part before://in its OAuth callback (The scheme).APP_SMTP_*: your mail provider's values, with a sender on a domain it has verified (Mail). Use port465if your host lets it out and587otherwise, never2465(Mail ports).
The block stays yours: Supabase's update.sh adds new keys to .env on an
upgrade without touching existing ones.
Feature files
Each feature you ship adds one short compose file of its own, and its values to the same block. Skip the ones your app does not use; the compose file above works alone.
Requires ENABLE_STRIPE
Stripe is off in a new project. Set `ENABLE_STRIPE=true` in `supabase/.env.local` to use this section.
Stripe. Saved as ~/supabase-project/docker-compose.myapp-stripe.yml:
# Stripe, for the functions.
services:
functions:
environment:
ENABLE_STRIPE: "true"
STRIPE_SECRET_KEY: "${STRIPE_SECRET_KEY:?}"
STRIPE_WEBHOOK_SECRET: "${STRIPE_WEBHOOK_SECRET:?}"
STRIPE_PORTAL_CONFIGURATION_ID: "${STRIPE_PORTAL_CONFIGURATION_ID:?}"cat >> ~/supabase-project/.env <<'EOF'
# Stripe, live mode
STRIPE_SECRET_KEY=CHANGE_ME
STRIPE_WEBHOOK_SECRET=CHANGE_ME
STRIPE_PORTAL_CONFIGURATION_ID=CHANGE_ME
EOFThe live secret key, the signing secret of a live webhook endpoint at
https://api.myapp.com/functions/v1/stripe-webhook, and the restricted portal
configuration's id: see In production.
Seat-based billing only
This section applies only to projects generated with `--seat-based-billing`.
Requires ENABLE_SEAT_BASED_BILLING
Seat-based billing is off in a new project. Set `ENABLE_SEAT_BASED_BILLING=true` in both `.env.local` and `supabase/.env.local` to use this section.
Seat-based billing, on top of Stripe. Saved as
~/supabase-project/docker-compose.myapp-seats.yml:
# Seat-based billing, for the functions. Needs multi-tenancy and Stripe.
services:
functions:
environment:
ENABLE_SEAT_BASED_BILLING: "true"
STRIPE_PORTAL_CONFIRM_CONFIGURATION_ID: "${STRIPE_PORTAL_CONFIRM_CONFIGURATION_ID:?}"cat >> ~/supabase-project/.env <<'EOF'
# Seat-based billing, live mode
STRIPE_PORTAL_CONFIRM_CONFIGURATION_ID=CHANGE_ME
EOFThe confirm configuration's id, from the script run in live mode: Billing portal configurations.
Requires ENABLE_REVENUECAT
RevenueCat is off in a new project. Set `ENABLE_REVENUECAT=true` in both `.env.local` and `supabase/.env.local` to use this section.
RevenueCat. Saved as ~/supabase-project/docker-compose.myapp-revenuecat.yml:
# RevenueCat, for the functions.
services:
functions:
environment:
ENABLE_REVENUECAT: "true"
REVENUECAT_SECRET_API_KEY: "${REVENUECAT_SECRET_API_KEY:?}"
REVENUECAT_WEBHOOK_SECRET: "${REVENUECAT_WEBHOOK_SECRET:?}"cat >> ~/supabase-project/.env <<'EOF'
# RevenueCat
REVENUECAT_SECRET_API_KEY=CHANGE_ME
REVENUECAT_WEBHOOK_SECRET=CHANGE_ME
EOFThe secret API key, and the bare token of a webhook pointing at
https://api.myapp.com/functions/v1/revenuecat-webhook: see
In production.
Requires ENABLE_GOOGLE_SIGN_IN
Google Sign-In is off in a new project. Set `ENABLE_GOOGLE_SIGN_IN=true` in `.env.local` to use this section.
Google sign-in. Saved as ~/supabase-project/docker-compose.myapp-google.yml:
# [auth.external.google], authenticating as the Web client.
services:
auth:
environment:
GOTRUE_EXTERNAL_GOOGLE_ENABLED: "true"
GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID: "${OAUTH_GOOGLE_WEB_CLIENT_ID:?}"
GOTRUE_EXTERNAL_GOOGLE_SECRET: "${OAUTH_GOOGLE_WEB_SECRET:?}"
GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI: "${API_EXTERNAL_URL}/callback"
# skip_nonce_check, by its per-provider name.
GOTRUE_EXTERNAL_GOOGLE_SKIP_NONCE_CHECK: "true"cat >> ~/supabase-project/.env <<'EOF'
# Google sign-in
OAUTH_GOOGLE_WEB_CLIENT_ID=CHANGE_ME
OAUTH_GOOGLE_WEB_SECRET=CHANGE_ME
EOFThe Web client's ID and secret, with https://api.myapp.com/auth/v1/callback
among its redirect URIs: see In production.
Sign in with Apple and GitHub have no flag: add each file only if your app offers it.
# [auth.external.apple], a block upstream's file lacks.
services:
auth:
environment:
GOTRUE_EXTERNAL_APPLE_ENABLED: "true"
# The Services ID first, then the bundle IDs.
GOTRUE_EXTERNAL_APPLE_CLIENT_ID: "${OAUTH_APPLE_CLIENT_ID:?}"
# A JWT that expires after about six months.
GOTRUE_EXTERNAL_APPLE_SECRET: "${OAUTH_APPLE_SECRET:?}"
GOTRUE_EXTERNAL_APPLE_REDIRECT_URI: "${API_EXTERNAL_URL}/callback"cat >> ~/supabase-project/.env <<'EOF'
# Sign in with Apple
OAUTH_APPLE_CLIENT_ID=CHANGE_ME
OAUTH_APPLE_SECRET=CHANGE_ME
EOFThe client IDs and the generated secret, with a reminder to regenerate it before it expires: see In production.
# [auth.external.github]
services:
auth:
environment:
GOTRUE_EXTERNAL_GITHUB_ENABLED: "true"
GOTRUE_EXTERNAL_GITHUB_CLIENT_ID: "${OAUTH_GITHUB_CLIENT_ID:?}"
GOTRUE_EXTERNAL_GITHUB_SECRET: "${OAUTH_GITHUB_SECRET:?}"
GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI: "${API_EXTERNAL_URL}/callback"cat >> ~/supabase-project/.env <<'EOF'
# GitHub sign-in
OAUTH_GITHUB_CLIENT_ID=CHANGE_ME
OAUTH_GITHUB_SECRET=CHANGE_ME
EOFA production OAuth app of its own: see In production.
Turn the files on
Add your compose file to COMPOSE_FILE in .env, then each feature file you
created, by the part of its name after docker-compose.; here, for example,
Stripe and Google:
cd ~/supabase-project
sh run.sh config add myapp
sh run.sh config add myapp-stripe myapp-googleCheck. sh run.sh config lists docker-compose.yml, then your files.
Then, with no output from either:
grep -n CHANGE_ME .env
docker compose config --quietA value still missing makes the second print
required variable STRIPE_SECRET_KEY is missing a value, with that value's
name.
10. The first start
The kit's first migration needs schemas that Auth, Storage and Realtime create
as they boot, so the stack starts before the migrations run, and one service,
rest, stays unhealthy until they have
(The first start
explains why, and why not sh run.sh start):
docker compose up -dCheck. Run these until every service but rest shows (healthy), which
takes a few minutes the first time:
docker compose ps --format '{{.Service}}: {{.Status}}'
sudo ss -tlnp | grep -E ':(8000|5432|5433|6543) '
docker inspect supabase-db --format '{{json .HostConfig.LogConfig}}'The four ports listen on 127.0.0.1 alone, and the log configuration names
"max-file":"3" and "max-size":"10m". From your laptop,
curl -s -o /dev/null -w '%{http_code}\n' https://api.myapp.com/auth/v1/.well-known/jwks.json
now prints 200: Auth's public keys, which it serves to anyone, so the answer
proves Caddy, the gateway and Auth with no key. Auth's /auth/v1/health
answers 401 here: the gateway asks every other Auth path for an API key.
11. Save the keys off the server
Do this now, before anything else. At its first start, Postgres made a random
pgsodium root key, which encrypts every Vault row, and .env holds every key
your clients and functions use. Until backups run, the server holds the only
copy of both: lose it, or its db-config volume, and every Vault row is gone.
From your laptop, print the key, and copy .env into a directory only you can
read:
ssh you@your-server 'docker exec supabase-db cat /etc/postgresql-custom/pgsodium_root.key'
mkdir -p ~/secrets && chmod 700 ~/secrets
scp you@your-server:supabase-project/.env ~/secrets/myapp-server.env
chmod 600 ~/secrets/myapp-server.envThe key is one line of 64 hexadecimal characters, printed without a newline,
so your prompt follows it on the same line. Save it in your password manager,
and attach the copy of .env to the same entry, or keep both in your secret
store. The steps below read values from the copy; copy .env again whenever you
change it on the server.
Never remove the db-config volume
docker compose down -v, or a cleanup that prunes volumes, deletes the
pgsodium key. See Backups.
Check. Both commands print the same hash, and your password manager holds the 64 characters and the file:
ssh you@your-server 'sha256sum supabase-project/.env'
shasum -a 256 ~/secrets/myapp-server.env12. Migrations, over the tunnel
Postgres listens only on the server's loopback, so your laptop reaches it, and the gateway, through an SSH tunnel. Open it in a terminal of its own and keep it open until step 15:
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-serverCheck that Realtime has created its table, then push, from your project root.
The password is POSTGRES_PASSWORD from your copy of .env:
psql "postgresql://postgres@127.0.0.1:15432/postgres?sslmode=disable" -tAc "select to_regclass('realtime.messages')"
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"The first command must print realtime.messages; on an empty line, wait for
Realtime and run it again. The dry run lists your migrations; the push applies
them. Every push carries --skip-vault, and none ever carries
--include-seed: Push gives
the reasons. Then unset DB_PASSWORD DB_URL.
Check. rest turns healthy by itself once its schemas exist, within a
minute of the push, since it retries every 30 seconds or so; run this again
until it shows (healthy):
ssh you@your-server 'cd supabase-project && docker compose ps rest'13. The Vault rows
Five values your SQL reads live in the Vault, with your server's URLs
(Vault by psql
lists them). This writes the statements to a file only you can read, with the
webhook secret from your copy of .env and two fresh peppers, runs it over the
tunnel, then deletes it. psql asks for the database password:
WEBHOOK_SECRET=$(sed -n 's/^DATABASE_WEBHOOK_SECRET=//p' ~/secrets/myapp-server.env)
(umask 077 && cat > vault.sql <<EOF
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('$WEBHOOK_SECRET', 'database_webhook_secret');
select vault.create_secret('$(openssl rand -base64 48)', 'mfa_recovery_code_pepper');
select vault.create_secret('$(openssl rand -base64 48)', 'account_deletion_email_pepper');
EOF
)
unset WEBHOOK_SECRET
psql "postgresql://postgres@127.0.0.1:15432/postgres?sslmode=disable" -v ON_ERROR_STOP=1 -f vault.sql
rm vault.sqlThe Vault is the peppers' only copy, and your backups carry it. Before you ever rotate one, read the MFA pepper and the account-deletion pepper.
Check. It lists the five names:
psql "postgresql://postgres@127.0.0.1:15432/postgres?sslmode=disable" \
-c "select name from vault.decrypted_secrets where decrypted_secret <> '' order by name"14. Reference data
Multi-tenancy only
This section applies only to projects generated with `--multi-tenancy`.
Roles and permissions, once. A second run fails on rows that already exist:
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's files, the logo and default avatars your emails link
to, uploaded through the tunnel with the secret key, which keeps the key out of
Caddy's log:
SECRET_KEY=$(sed -n 's/^SUPABASE_SECRET_KEY=//p' ~/secrets/myapp-server.env)
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
unset SECRET_KEYReference data explains both, and why a changed asset ships under a new name.
Check. The public logo answers 200:
curl -s -o /dev/null -w '%{http_code}\n' https://api.myapp.com/storage/v1/object/public/assets/app/logo.svg15. The functions
Deploying is a file copy into the stack's volumes/functions/, beside
upstream's router, then a recreate of the functions container, which is how
it reads new environment too
(Edge Functions):
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'You can close the tunnel now.
Check. A function called with no token answers 401, with the code
NOT_AUTHENTICATED:
curl -s -w '\n%{http_code}\n' -X POST https://api.myapp.com/functions/v1/delete-accountA 503 in the first seconds after the recreate means the container is still
starting: run it again. A 500 means a secret is missing or malformed, which
stops every function from booting. The functions' log names it, after
ENV VALIDATION FAILED
(Every Edge Function fails to boot):
docker compose logs functions | grep -A 20 'ENV VALIDATION FAILED'Fix the value in .env, run docker compose up -d --force-recreate functions
again, and copy .env to your laptop again.
16. A reboot
Reboot the server once now, on purpose, so that the first unplanned one holds no
surprise. Every container runs with restart: unless-stopped, and Docker starts
at boot, so all of it should come back without you:
ssh -t you@your-server 'sudo reboot'Check. A minute later, log in again, touching nothing else:
docker compose -f ~/caddy/compose.yml ps --format '{{.Service}}: {{.Status}}'
cd ~/supabase-project && docker compose ps --format '{{.Service}}: {{.Status}}'
docker info --format '{{.LoggingDriver}} {{.LiveRestoreEnabled}}'Caddy is Up, every service of the stack is (healthy), rest included, and
Docker still prints json-file true. From your laptop,
/auth/v1/.well-known/jwks.json still answers 200.
17. Backups
A backup is not optional: the guide is finished once one has run, been checked and been restored. Each night, the server dumps the database and its roles, archives the Storage files, encrypts each file to an age public key on its way out of the pipe, and uploads them, with a manifest, to a new dated folder in a bucket off the server. The server holds only the public key, so it cannot read its own backups. Backups names what holds state and why.
The key pair and the bucket
On your laptop, make the age key pair. The command prints the public key,
age1...; the file holds the private one:
age-keygen -o ~/secrets/myapp-backup-age-key.txtSave the private key file in your password manager too: without it, no backup can be read.
At a storage provider, create a private bucket, myapp-backups here, and an
access key that can read and write that one bucket and nothing else. The
example is Cloudflare R2; any provider rclone supports works. Add one lifecycle
rule that deletes objects after the number of days you keep backups, 30 for
example, so that data your users erase leaves the backups too.
On the server
Install the tools from Ubuntu's archive:
sudo apt-get install -y age rclone zstdThe bucket, as rclone's remote offsite, saved as
/root/.config/rclone/rclone.conf, with your key and account id:
sudo install -d -m 700 /root/.config/rclone
sudo nano /root/.config/rclone/rclone.conf# The off-site bucket, as rclone's remote "offsite". The example is Cloudflare R2; any provider
# rclone supports works, with its own type and settings.
[offsite]
type = s3
provider = Cloudflare
access_key_id = <access key id>
secret_access_key = <secret access key>
endpoint = https://<account id>.r2.cloudflarestorage.com
region = auto
acl = private
# The key reaches one bucket's objects only, so rclone must not try to create the bucket.
no_check_bucket = true
# R2 answers each upload with a version id, which Ubuntu's rclone then fails to read back.
no_head = trueThe backup itself, saved as /usr/local/sbin/myapp-backup
(sudo nano /usr/local/sbin/myapp-backup), with your user's home directory,
your public key and your bucket in its first three settings:
#!/usr/bin/env bash
#
# The nightly backup of the self-hosted backend, run as root by myapp-backup.service. Each run
# writes one new folder off the server, named after its UTC time, holding:
#
# postgres.dump.age pg_dump -Fc of the database
# globals.sql.age pg_dumpall --globals-only: the roles and their passwords
# storage.tar.zst.age the Storage files, with the extended attributes Storage keeps
# manifest.txt the stack's release tag, the Postgres image and each file's sha256
#
# Each file is encrypted to the age recipient on its way out of the pipe, so no plaintext touches
# the disk, and the server, holding only the recipient, cannot read its own backups. The pgsodium
# root key and .env are not in it: they live in your password manager.
set -euo pipefail
# Where setup.sh installed the stack, in your user's home directory.
STACK=/home/you/supabase-project
# The public half of your age key pair (age-keygen prints it); the private half stays off the server.
RECIPIENT=age1replacewithyourrecipient
# The rclone remote and bucket, from rclone's config, named here since systemd sets no HOME.
DESTINATION=offsite:myapp-backups
export RCLONE_CONFIG=/root/.config/rclone/rclone.conf
RUN=$(date -u +%Y-%m-%dT%H%M%SZ)
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
docker exec supabase-db pg_dump -U supabase_admin -d postgres -Fc |
age -r "$RECIPIENT" -o "$WORK/postgres.dump.age"
docker exec supabase-db pg_dumpall -U supabase_admin --globals-only |
age -r "$RECIPIENT" -o "$WORK/globals.sql.age"
# After the dump, so a file uploaded in between is a harmless orphan, not a row with no file.
tar -C "$STACK/volumes" --numeric-owner --xattrs --xattrs-include='user.*' -cf - storage |
zstd -q |
age -r "$RECIPIENT" -o "$WORK/storage.tar.zst.age"
# The checksum lines hold no '=', so `grep -v = manifest.txt | shasum -a 256 -c` checks a download.
{
echo "created=$RUN"
echo "stack=$(sed -n 's/^ref=//p' "$STACK/.supabase-version")"
echo "postgres_image=$(docker inspect -f '{{.Config.Image}}' supabase-db)"
(cd "$WORK" && sha256sum postgres.dump.age globals.sql.age storage.tar.zst.age)
} >"$WORK/manifest.txt"
# The files first, then the manifest, so a folder with a manifest is complete. --immutable
# refuses to replace an object that already exists.
rclone copy --immutable --exclude /manifest.txt "$WORK" "$DESTINATION/$RUN"
rclone copy --immutable --include /manifest.txt "$WORK" "$DESTINATION/$RUN"
echo "backup $RUN uploaded to $DESTINATION/$RUN"The service that runs it, saved as /etc/systemd/system/myapp-backup.service
(sudo nano, as above):
[Unit]
Description=Back up the self-hosted backend: database and Storage files, encrypted, off the server
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/myapp-backup
Nice=10And the timer that starts it every night, saved as
/etc/systemd/system/myapp-backup.timer:
[Unit]
Description=Run myapp-backup every night
[Timer]
OnCalendar=*-*-* 03:30:00 UTC
# A night missed while the server was off runs once it is back.
Persistent=true
[Install]
WantedBy=timers.targetMake the script executable, run one backup now, then turn the timer on:
sudo chmod 755 /usr/local/sbin/myapp-backup
sudo systemctl daemon-reload
sudo systemctl start myapp-backup.service
sudo systemctl enable --now myapp-backup.timerCheck. The run's journal ends with backup <time> uploaded to ..., the
bucket holds that folder's four files, and the timer shows its next run:
sudo journalctl -u myapp-backup.service -n 5 --no-pager
sudo rclone ls offsite:myapp-backups
systemctl list-timers myapp-backup.timerRestore it once
A backup counts once it has been restored. Do it now, while the data is small, on a scratch machine: a second small server for an hour, or a local Ubuntu virtual machine with Docker, never the production server.
-
Download the run's folder from your provider's dashboard, or with rclone, to your laptop, where the age private key is. In that folder,
grep -v = manifest.txt | shasum -a 256 -cprintsOKfor each file. -
Follow Restore, steps 1 to 6, on the scratch machine: the stack at the manifest's
stacktag, your compose files and.envfrom your password manager, the pgsodium key, the globals and the dump, the Storage archive. Steps 7 and 8 move production to a new server: skip them. -
In step 5, after the queue registry refill and before you remove
restore-db, check the restored database:Terminal (on the scratch machine) docker exec restore-db psql -U supabase_admin -d postgres -X -tA \ -c "select count(*) from auth.users" docker exec restore-db psql -U supabase_admin -d postgres -X -tA \ -c "select count(*) from vault.decrypted_secrets where decrypted_secret <> ''" docker exec restore-db psql -U supabase_admin -d postgres -X -tA \ -c "select queue_name from pgmq.list_queues() order by 1"
Check. The user count is production's at the backup's time, the Vault
count is 5 (an invalid ciphertext error instead means the pgsodium key is
not this server's), and the queues listed are storage_cleanup and
stripe_cleanup. After step 6, sudo find volumes/storage -type f | wc -l, run
in supabase-project, prints the same number on the scratch machine as on the
server. Then delete the
scratch machine and every plaintext file you decrypted.
Floot's setup. Floot's backup does more, each for a reason: a run every 4 hours, so a lost server costs at most 4 hours of writes; a bucket lock, so a stolen server key cannot erase history; lifecycle rules keeping 7, 30 and 90 days; a Restore check every night in a scratch Postgres container, so a broken dump fails the same night; and a Restore drill every quarter. See Floot's recipe.
18. The smoke check
Run the Cloud smoke check against your
server, from a release build of your app pointed at https://api.myapp.com
with its publishable key, SUPABASE_PUBLISHABLE_KEY from your copy of .env
(see Building for release): sign up,
delete the account, round-trip an invitation, and an hour later check the cron
jobs. On your own server, it proves the hook URI, the exposed schemas, the
Vault rows and the server's route out to its own hostname and back, together.
Check. Every step of it passes, and from your laptop:
curl -s -o /dev/null -w '%{http_code}\n' https://api.myapp.com/
curl -s -o /dev/null -w '%{http_code}\n' https://api.myapp.com/pg/
for port in 8000 5432 5433 6543; do nc -z -w 3 your-server $port && echo "$port open"; doneBoth curl lines print 404, and the loop prints nothing: nc reaches none of
the four ports. One nc per port, because macOS's stops at the first port it
cannot reach; on macOS, add -G 3 too, or each port waits more than a minute
before it gives up.
An hour after the sign-up, with the tunnel of step 12 open again, the kit's
scheduled jobs show succeeded:
psql "postgresql://postgres@127.0.0.1:15432/postgres?sslmode=disable" -c "
select jobname, status, start_time
from cron.job_run_details join cron.job using (jobid)
order by start_time desc limit 10"Your backend is in production.
Your web app on this server
If you ship the web app, Caddy can serve its release build from this server as a second site beside the API. Web owns the build and its hosting requirements; this section serves them here. Floot serves its own web app from a container image behind its main Caddy. A static build that Caddy serves itself is simpler: there is no image to build or run.
DNS. Add an A record for app.myapp.com pointing at the server, as in
step 2, with no CDN in front.
The site file. Saved as ~/caddy/config/sites/app.caddy, with your
backend's host in the report-only policy
(A report-only CSP first). It is
the Web page's Caddy site with two changes: the shared
snippet sends nosniff, the referrer policy and HSTS, and the build is in
~/caddy/srv/app, which Caddy reads as /srv/app through step 8's mount. If
your Caddy started before its compose file had that mount, add it and run
docker compose up -d in ~/caddy once: a reload does not add a mount.
# The web app: the release web build in ~/caddy/srv/app, served with the hosting requirements of
# the docs' Web page. Caddy terminates TLS here, so the shared snippet's HSTS applies.
app.myapp.com {
# HSTS, nosniff and the referrer policy. The snippet's X-Frame-Options yields to the
# frame-ancestors 'none' below in every current browser.
import security_headers
root * /srv/app
encode zstd gzip
header {
Cache-Control "no-cache"
Content-Security-Policy "frame-ancestors 'none'"
Permissions-Policy "camera=(), geolocation=(), microphone=(), payment=(), usb=()"
Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' https://accounts.google.com/gsi/client; style-src 'self' 'unsafe-inline' https://accounts.google.com/gsi/style; img-src 'self' data: blob: https://api.myapp.com; font-src 'self' data:; connect-src 'self' https://api.myapp.com wss://api.myapp.com https://accounts.google.com/gsi/; frame-src https://accounts.google.com/gsi/; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"
}
# The verified-link association files, typed as JSON: the Apple file has no extension.
@association path /.well-known/apple-app-site-association /.well-known/assetlinks.json
handle @association {
header Content-Type "application/json"
file_server
}
# Build output the app loads by name: a missing file is a 404, never index.html.
@build_output {
path /assets/* /canvaskit/* /icons/* /main.dart.* /version.json
path /flutter.js /flutter_bootstrap.js /flutter_service_worker.js /manifest.json /favicon.png
}
handle @build_output {
file_server
}
# Every other path is an app route.
handle {
try_files {path} /index.html
file_server
}
}The two association files, /.well-known/apple-app-site-association and
/.well-known/assetlinks.json, ship in the build;
Verified links sets out what they hold. Load the
site the way step 8 loads a change, then Caddy obtains its certificate:
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfileThe build. Build the release as
The release build shows, from a checkout
whose .env.local holds nothing private
(The env file is public), then copy
it to the server:
rsync -a --delete-after --delay-updates build/web/ you@your-server:caddy/srv/app/rsync copies dot files, so assets/.env and .well-known/ arrive with the
rest. --delay-updates moves every new file into place together at the end of
the copy, and --delete-after removes old files only after that, so a visitor
never pairs a new index.html with last release's main.dart.js for longer
than that moment. Each later release is the same build and the same command;
Caddy serves the new files at once, with no reload.
Auth's redirect URLs. Auth must accept your web app's paths
(Auth redirect URLs). With the web app on
SITE_URL, they are in already: step 6 set SITE_URL to
https://app.myapp.com, and your compose file's GOTRUE_URI_ALLOW_LIST lists
${SITE_URL}/**. If your web app is on another origin, set SITE_URL in
.env to it, recreate the two services that read it, and save .env off the
server again, as in step 11:
docker compose up -d auth functionsCheck. In ~/caddy,
docker compose logs caddy | grep 'certificate obtained successfully' prints a
line for app.myapp.com. This prints https://app.myapp.com/**, then your
app's OAuth callback:
docker compose exec auth printenv GOTRUE_URI_ALLOW_LISTFrom your laptop, every line of Check the host prints what that section expects.
What next
Every release from now on follows Shipping a release: the push, the functions, and a Vault rotation when a value changes. Beyond that, each of these is a choice:
- Alerts, optional: nothing in this guide tells you when the API stops
answering or a backup run fails. If you want a Telegram message when it
happens, Watching adds Uptime Kuma beside
Caddy; otherwise, look at
systemctl list-timers myapp-backup.timerand the backup's journal now and then. - Studio's Logs screen, optional:
sh run.sh config add logsadds Supabase's logs stack, Logflare and Vector. It costs about 1 GiB of memory, and Vector mounts the Docker socket, which gives it control of every container on the server. - Cloudflare in front, optional: Behind Cloudflare lists what it takes, the Cache Rule for private Storage included.
- The web app: Your web app on this server serves it beside the API; Web builds it and sets out its hosting rules.
- Upgrades, on purpose: Upgrades.
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.
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.