Supabase Deploy: Three Variables Decide Whether Any Page Loads
Eight migrations, seven dashboard settings a migration can't carry, and why a missing Supabase env var takes down 111 product pages instead of one.
Deploying a Supabase-backed app is two deploys that must happen in a fixed order: the database (migrations, policies, buckets, auth settings) and then the app (environment variables on the host). Getting the order right is easy. What bites is that on a Next.js app with session refresh, three missing environment variables do not break one page — they break every page.
This storefront is mid-deploy right now in exactly that sense: its commerce schema is live on a hosted Supabase project, and its production build is deliberately still the pre-commerce one, because the Supabase variables are not yet set on the host. That is a real go-live checklist with a real blocker on it, so it is the one this post walks.
The database half: eight migration files, in filename order
$ ls supabase/migrations/
0001_commerce_init.sql
0002_display_name_allowlist.sql
0003_commerce_rpcs.sql
0004_lock_down_rate_limit_fn.sql
0005_retain_anonymized_download_audit.sql
0006_pricing_v2_slot_kinds.sql
0007_atomic_download_rate_limit.sql
0008_refund_requests.sql
supabase db push applies those in lexical order against the linked project and records each in the supabase_migrations.schema_migrations table, so re-running it is a no-op for anything already applied. Almost everything that defines the database's behaviour is inside those eight files: tables, RLS policies, and the SECURITY DEFINER RPCs that do the atomic writes. Almost — and the exception is instructive, so it gets its own section below.
That last point is the deploy-relevant one. Policies live in migrations and only there — never clicked into the dashboard — which is what makes a deploy reproducible at all. Two of those files carry SECURITY DEFINER functions (0003_commerce_rpcs.sql and 0007_atomic_download_rate_limit.sql), which is the other reason the order matters: the app calls those RPCs by name, so the app build is useless until they exist. A policy created through the UI exists in production and in nobody's checkout; the next db push from a teammate's machine will not create it, and no code review ever saw it. This repo's migration post goes through the file-by-file contract; for deployment the rule is just: if it is not in supabase/migrations/, it is not deployed.
What a migration cannot carry, and therefore what stays manual
A migration is SQL against your database. Several things a working Supabase deployment needs are not SQL, which means they are dashboard settings you have to reproduce per project:
| Setting | Where it lives | Why a migration can't do it |
|---|---|---|
| Site URL and redirect allowlist | Auth → URL Configuration | Auth service config, not database state |
| Google (or other) OAuth client id/secret | Auth → Providers | Credentials, and provider config is not in the DB |
| Email templates and SMTP sender | Auth → Emails | Same |
| Email confirmation on/off | Auth → Providers → Email | Same |
| Rate limits for auth endpoints | Auth → Rate Limits | Same |
| The storage bucket itself | Storage → New bucket | storage.buckets is a platform-owned table; this repo's 0001_commerce_init.sql says so in a comment rather than inserting into it |
| The objects inside the bucket | Storage → upload, or a script | Files are not schema |
The storage bucket is the sharpest example. This project's downloads come out of a private releases bucket, and you might reasonably expect the migration that builds the commerce schema to create it. It does not — it leaves a comment where the creation would be:
-- supabase/migrations/0001_commerce_init.sql
-- Storage: private bucket 'releases' created separately (storage.buckets insert, public=false).
So a fresh project that has had every migration applied still has no bucket, and the release zips inside it — at {slug}/{framework}/{slug}-{framework}-v{version}.zip — are uploaded by a separate script on top of that. Schema-complete is not the same as deploy-complete, and the gap is exactly the set of things in the table above.
The app half: the three variables that decide whether the site loads
Here is the full production environment surface of this app, pulled from the source rather than from a README:
$ grep -rhoE "process\.env\.[A-Z0-9_]+" \
src/app src/lib src/components src/data src/proxy.ts | sort -u
process.env.LEMONSQUEEZY_STORE_ID
process.env.LEMONSQUEEZY_STORE_SUBDOMAIN
process.env.LEMONSQUEEZY_WEBHOOK_SECRET
process.env.LS_VARIANT_ID_T1
process.env.LS_VARIANT_ID_T2
process.env.LS_VARIANT_ID_T3
process.env.LS_VARIANT_UUID_T1
process.env.LS_VARIANT_UUID_T2
process.env.LS_VARIANT_UUID_T3
process.env.NEXT_PUBLIC_SITE_URL
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
process.env.NEXT_PUBLIC_SUPABASE_URL
process.env.NODE_ENV
process.env.RESEND_API_KEY
process.env.RESEND_AUDIENCE_ID
process.env.SUPABASE_SERVICE_ROLE_KEY
Sixteen names, and most of them fail locally in the ordinary sense: miss RESEND_API_KEY and the newsletter form errors, miss a LemonSqueezy variant id and one buy button breaks. Three do not fail locally. They fail globally.
Why missing Supabase variables take down every route, not one
The reason is session refresh. In Next.js 16 it runs in src/proxy.ts (the file formerly called middleware.ts), and its matcher covers nearly every request:
// src/proxy.ts
export async function proxy(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: { /* getAll / setAll */ } },
);
await supabase.auth.getClaims();
return response;
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|.*\\.(?:svg|png|jpg|jpeg|webp|gif|ico|webmanifest|html)$).*)",
],
};
Read the two non-null assertions. createServerClient receives undefined when the variables are absent, throws, and the throw happens in the proxy — before routing. Not on /dashboard. Not on /login. On /, on /pricing, on every one of the 111 product pages, on the blog. A site with no account UI on 90% of its pages goes fully dark because of a variable only 10% of it needs.
That asymmetry is why this repo's own CLAUDE.md carries a standing instruction not to promote the commerce build until the variables are set on the host, and why production currently serves the earlier storefront-only build. The failure is not subtle in a preview deploy — it is total — but it is invisible in local development, where .env.local has always had the values.
So the deploy order is: push the database, set the variables, then promote the build. Reversing the last two is the one sequence that produces a site-wide outage rather than a broken feature.
The third variable is the one that must not be public
// src/lib/supabase/admin.ts
import "server-only";
import { createClient as createSbClient } from "@supabase/supabase-js";
export function createAdminClient() {
return createSbClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false, autoRefreshToken: false } },
);
}
Two deployment-relevant details. SUPABASE_SERVICE_ROLE_KEY has no NEXT_PUBLIC_ prefix, so Next.js never inlines it into a client bundle — and the import "server-only" line at the top makes an accidental import from a Client Component fail the build rather than ship the key. When you paste variables into a host's dashboard, the prefix is the entire security boundary: rename that variable to NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY "for consistency" and you have published an RLS-bypassing credential to every visitor.
The service-role key bypasses RLS entirely, so the explicit user_id filter in the code that uses it is the security check rather than a second layer on top of one. That is covered in the service-role key post; at deploy time the only rule is: three variables, one of them unprefixed, and never in a client bundle.
Environments: branches, or a second project
Supabase's own answer is database branching, where a Git branch gets an ephemeral database seeded from your migrations. It is the right default for teams, and it costs money per branch.
The cheaper shape, and the one here, is: one hosted project, migrations as the only path into it, and a test suite that does not need a database to run.
$ npm test
Test Files 31 passed (31)
Tests 386 passed (386)
Duration 4.40s
That works because the logic worth testing is not written against a database client — src/lib/entitlements.ts is a pure function over plain objects, and the webhook takes a narrow two-method interface with an in-memory fake in tests. The trade-off is explicit: this setup cannot catch a policy that is wrong in SQL, only logic that is wrong in TypeScript. If most of your behaviour lives in the database, branching or a second project stops being optional. The local-development post has the measurement that decides it.
Mistakes and how they show up
| Symptom | Cause | Fix |
|---|---|---|
| Every route 500s after deploy, including static marketing pages | Supabase variables missing where the proxy runs | Set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY on the host, then redeploy |
| Works in preview, fails in production | Variables scoped to preview only | Most hosts scope per environment — set them for production explicitly |
new row violates row-level security policy after deploy | Policies were clicked into the dashboard on one project, not written as SQL | Move them into a migration and db push |
| Signed-in user sees nothing; no error | A policy exists but grants nothing; the server-side read returns zero rows | Test the policy as an authenticated role, not as service_role |
OAuth redirects to localhost in production | Auth → URL Configuration still holds the dev Site URL | Set Site URL and the redirect allowlist per project — not a migration |
| Password reset emails never arrive | Default SMTP sender is rate-limited | Configure a real sender before launch |
| Entitled buyer's download 404s | Bucket exists, object does not | Upload the release artefacts; the bucket is SQL, its contents are not |
| Service-role key visible in the browser | Variable renamed with a NEXT_PUBLIC_ prefix | Remove the prefix, rotate the key immediately |
Frequently asked questions
What is the correct order to deploy a Supabase app?
Database first (supabase db push against the linked project), then the dashboard settings a migration cannot express (auth URLs, providers, SMTP), then the host's environment variables, then promote the app build. The variables must be in place before the build serves traffic, because a Next.js app that refreshes sessions in middleware needs them on every request.
Do I need the Supabase CLI to deploy?
For schema, effectively yes — supabase link plus supabase db push is the reproducible path, and it is the same command in CI. The alternative, applying SQL by hand in the dashboard, produces a database no checkout can recreate.
Why does a missing environment variable break pages that have nothing to do with auth?
Because session refresh runs before routing. In this codebase src/proxy.ts constructs a Supabase client on every non-asset request; with undefined credentials that construction throws, and the throw precedes any page's own code.
Can I run migrations from GitHub Actions?
Yes, and it is the usual production setup: supabase db push with the project ref and an access token in CI secrets. Nothing about the migration files changes; only who runs them.
Do I have to deploy Supabase itself? No, unless you want to. The hosted platform is the default; self-hosting via Docker Compose is a separate decision about who operates Postgres, GoTrue, Storage and the API gateway, and it does not change any of the application-side steps above.
Templates in this post
ASoc Coin is an online-banking marketing site with a cash-overview dashboard mock and a 3-tier pricing page. ASoc Compound is an automated-investing marketing site with goal-based portfolios and a 3-tier pricing page. ASoc Cortex is an AI-agency marketing site with services, industries, case studies and a lead funnel. Each is the marketing half of a product whose signed-in half would sit on exactly the deploy sequence above — schema first, variables second, build last.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
