Skip to main content
ASoc
Tutorial

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.

The ASoc Team10 min read

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:

SettingWhere it livesWhy a migration can't do it
Site URL and redirect allowlistAuth → URL ConfigurationAuth service config, not database state
Google (or other) OAuth client id/secretAuth → ProvidersCredentials, and provider config is not in the DB
Email templates and SMTP senderAuth → EmailsSame
Email confirmation on/offAuth → Providers → EmailSame
Rate limits for auth endpointsAuth → Rate LimitsSame
The storage bucket itselfStorage → New bucketstorage.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 bucketStorage → upload, or a scriptFiles 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

SymptomCauseFix
Every route 500s after deploy, including static marketing pagesSupabase variables missing where the proxy runsSet NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY on the host, then redeploy
Works in preview, fails in productionVariables scoped to preview onlyMost hosts scope per environment — set them for production explicitly
new row violates row-level security policy after deployPolicies were clicked into the dashboard on one project, not written as SQLMove them into a migration and db push
Signed-in user sees nothing; no errorA policy exists but grants nothing; the server-side read returns zero rowsTest the policy as an authenticated role, not as service_role
OAuth redirects to localhost in productionAuth → URL Configuration still holds the dev Site URLSet Site URL and the redirect allowlist per project — not a migration
Password reset emails never arriveDefault SMTP sender is rate-limitedConfigure a real sender before launch
Entitled buyer's download 404sBucket exists, object does notUpload the release artefacts; the bucket is SQL, its contents are not
Service-role key visible in the browserVariable renamed with a NEXT_PUBLIC_ prefixRemove 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.

Keep reading

Tutorial9 min read

Supabase Email Rate Limit Exceeded: Why This Codebase Doesn't Fight It

Supabase's built-in email provider caps auth emails project-wide. This codebase relies on that limit by design (R-13) instead of building a bespoke one — and builds one anyway, elsewhere.

Read more
Tutorial8 min read

Supabase Local Dev: The Codebase That Never Runs supabase start

Eight migrations, six RLS policies and 357 tests in 3.3 seconds — with no Docker and no local stack. What that buys, and exactly where it stops working.

Read more
Tutorial11 min read

Supabase Migrations: Eight Files, and Four of Them Are Fixes

An audit of this storefront's eight commerce migrations — three create, five change or fix — plus the three habits that make one safe to re-run.

Read more