Run a Production App for $0 on Supabase, Vercel, and Cloudflare
Wire Supabase Postgres and auth to a Vercel-hosted Next.js app behind Cloudflare DNS, entirely on free tiers.
What you'll build
A Next.js app with email/password auth and a per-user notes table, running at your own domain: Postgres and auth on Supabase, hosting on Vercel, DNS (and optionally CDN) on Cloudflare. Monthly bill: $0.
Prerequisites
- Node.js 20.9+ (Next.js 16's floor). Verified with Node 20.19.6, Next.js 16.3.3, Vercel CLI 59.5.0,
@supabase/ssr0.12.5,@supabase/supabase-js2.112.4. - Free accounts on Supabase, Vercel (Hobby), and Cloudflare (Free).
- A domain you control. Registration is the one thing that isn't free (roughly $10/year); everything else here is on a free tier.
- Free-tier fine print: Supabase Free gives you 500 MB Postgres, 50,000 MAUs, and 2 active projects, and pauses projects after a week without database activity (one click to resume, data intact). Vercel Hobby is non-commercial use only, with 1M edge requests/month and 100 deployments/day. Supabase custom domains are a $10/month add-on, so your API URL stays
*.supabase.co— that's fine, it's only called from your app. - Commands are macOS/Linux; on Windows use WSL.
1. Create the Supabase project and schema
Create a project at supabase.com/dashboard/new, pick the region closest to your users, and save the database password. When it's ready, open SQL Editor and run:
create table notes (
id bigint primary key generated always as identity,
user_id uuid not null default auth.uid() references auth.users (id) on delete cascade,
body text not null,
created_at timestamptz not null default now()
);
alter table notes enable row level security;
create policy "users read own notes"
on notes for select to authenticated
using ((select auth.uid()) = user_id);
create policy "users insert own notes"
on notes for insert to authenticated
with check ((select auth.uid()) = user_id);
RLS is on and the publishable key is going into the browser, so these two policies are the entire authorization layer — no server-side checks needed.
Now open the project's Connect panel (top bar) and copy the Project URL and publishable key (sb_publishable_...). The legacy anon JWT still works, but it's slated for deprecation by end of 2026.
2. Scaffold the app and run it locally
npx create-next-app@latest free-stack -e with-supabase
cd free-stack
cp .env.example .env.local
Edit .env.local:
NEXT_PUBLIC_SUPABASE_URL=https://<your-ref>.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_...
The template already ships lib/supabase/{client,server,proxy}.ts, sign-up/login pages under app/auth/, and a proxy.ts that bounces unauthenticated requests (anything outside /, /login, /auth/*) to /auth/login.
3. Add the notes page
Create app/protected/notes/page.tsx:
import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server";
async function addNote(formData: FormData) {
"use server";
const supabase = await createClient();
const body = String(formData.get("body") ?? "").trim();
if (body) await supabase.from("notes").insert({ body });
revalidatePath("/protected/notes");
}
export default async function NotesPage() {
const supabase = await createClient();
const { data: notes, error } = await supabase
.from("notes")
.select("id, body, created_at")
.order("created_at", { ascending: false });
if (error) return <p>Error: {error.message}</p>;
return (
<main className="mx-auto max-w-md space-y-4 p-6">
<form action={addNote} className="flex gap-2">
<input name="body" placeholder="New note" className="flex-1 border px-2" required />
<button type="submit" className="border px-3">Add</button>
</form>
<ul className="space-y-1">
{notes.map((n) => <li key={n.id}>{n.body}</li>)}
</ul>
</main>
);
}
user_id is filled by the column default from auth.uid(), so the insert never trusts the client for ownership. Run npm run dev, open http://localhost:3000/protected/notes, sign up, confirm the email, add a note.
4. Deploy to Vercel
npm i -g vercel
vercel login
vercel link # creates a new project when prompted
Add the two env vars before building — NEXT_PUBLIC_* values are inlined at build time, so a deploy without them ships a broken bundle:
vercel env add NEXT_PUBLIC_SUPABASE_URL production
vercel env add NEXT_PUBLIC_SUPABASE_URL preview
vercel env add NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY production
vercel env add NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY preview
vercel --prod
The last line prints a https://free-stack-xxx.vercel.app URL. Check it loads before touching DNS.
5. Move DNS to Cloudflare and point it at Vercel
In the Cloudflare dashboard go to Domains → Onboard a domain, enter your apex domain, choose the Free plan, and let it import existing records. Copy the two nameservers it assigns, then at your registrar disable DNSSEC (if on) and replace the nameservers with Cloudflare's exactly. Wait until the zone shows Active (dig ns example.com returning *.ns.cloudflare.com is the tell).
Now attach the domain to Vercel and ask what records it wants:
vercel domains add example.com
vercel domains add www.example.com
vercel domains inspect example.com
Vercel assigns per-project values (the classic 76.76.21.21 still works, but use what inspect prints). In Cloudflare, DNS → Records → Add record, create:
| Type | Name | Content | Proxy status |
|---|---|---|---|
| A | @ |
the A value from inspect |
DNS only |
| CNAME | www |
the CNAME target from inspect (xxxx.vercel-dns-0xx.com) |
DNS only |
Grey-cloud both records for now: Vercel can't verify ownership or issue a Let's Encrypt cert through the proxy. Re-run vercel domains inspect example.com until it reports the domain as configured, then vercel certs ls should list example.com.
6. Tell Supabase about the production URL
In Supabase, Authentication → URL Configuration: set Site URL to https://example.com and add these Redirect URLs:
https://example.com/**
https://*-<your-vercel-team-slug>.vercel.app/**
Without this, confirmation emails sent from production redirect to the default Site URL, http://localhost:3000. The wildcard entry keeps preview deployments working too.
7. (Optional) Put Cloudflare's CDN in front
Vercel already serves from its own edge, so this is optional. If you want Cloudflare's caching and WAF: first set SSL/TLS → Overview to Full (strict) — Vercel's origin has a valid cert, and Flexible mode causes a redirect loop — then flip both records to Proxied. Responses now carry server: cloudflare.
Verify it works
curl -sI https://example.com | grep -iE '^(server|x-vercel-id|cf-ray)'
DNS-only:
server: Vercel
x-vercel-id: iad1::abcde-1787744225089-0ec800a9a24b
Proxied:
server: cloudflare
cf-ray: a3128adf9ff02209-ATL
Then, in a browser, visit https://example.com/protected/notes. You should land on /auth/login; sign up, click the confirmation link (it should return you to https://example.com/protected, not localhost), and add a note. Confirm it hit Postgres from the SQL Editor:
select count(*) from notes;
Sign in as a second user: their list is empty. That's RLS doing its job.
Troubleshooting
ERR_TOO_MANY_REDIRECTS in the browser. Cloudflare is proxying with SSL mode Flexible, which sends plain HTTP to Vercel; Vercel redirects to HTTPS; loop. Set SSL/TLS → Overview to Full (strict).
Vercel dashboard shows the domain as "Invalid Configuration" and vercel domains inspect never verifies. The records are proxied (orange cloud), so Vercel sees Cloudflare's IPs instead of its own. Switch both records to DNS only, wait for verification and cert issuance, then re-enable the proxy.
Confirmation email links open http://localhost:3000/.... Supabase fell back to the default Site URL because your production origin isn't in the redirect allowlist. Fix step 6, then re-send the confirmation.
Dashboard says the project is paused; the app returns errors on every query. A week with no database activity triggers this on Free. Click Resume project. If you'd rather not get paused, anything that touches the database a few times a day — a Vercel Cron hitting a tiny route handler that runs select 1 via Supabase — keeps it active.
Next steps
- Add OAuth providers (GitHub, Google) under Authentication → Providers, plus a
/auth/callbackroute handler that exchanges the code for a session — the Supabase social-login guides show the exact handler. - Wire
vercel git connectso pushes tomaindeploy production and every PR gets a preview URL — the wildcard redirect URL from step 6 already covers them. - Move schema into version control with the Supabase CLI (
supabase db diff/supabase db push) instead of the SQL Editor. - Watch usage: the Supabase organization Usage page and Vercel's Usage page both show how close you are to free-tier ceilings before anything gets throttled.
Sources & further reading
- Use Supabase with Next.js — supabase.com
- Redirect URLs — supabase.com
- Project Pausing — supabase.com
- Setting up a custom domain — vercel.com
- Can I use my domain on Vercel with A records? — vercel.com
- Full setup - Cloudflare DNS — developers.cloudflare.com
Ji-ho covers the increasingly tangled overlap between cloud architecture and security, drawing on a background as a penetration tester to keep his reporting grounded in real-world attack paths. He never lets a vendor claim go unquestioned and insists that every buzzword come with a proof of concept.
Discussion 2
look, the $0 app works great until you hit 50k monthly active users or need to do literally anything with your database at scale. i've seen so many of these tutorials gloss over the "free tier limits" part—spent a week debugging why auth was randomly failing at 2am before realizing we'd hit supabase's connection pooler ceiling. that said, it's perfect for proof of concepts and side projects where you're not bleeding money on compute you don't need yet.
Nice walkthrough. Real question though: what's your cold-start experience been like on Vercel's free tier with Supabase auth calls? I'm guessing the 12-second function timeout has bitten you at least once by week two of production.