Zero-Cost Production Deploy
The path from a working web codebase to a live free-tier stack. Every form, every secret, every weird network quirk, every gotcha that broke a real deploy — documented so the next person doesn't re-discover them.
The stack
| Service | Role | Free-tier caps |
|---|---|---|
| Vercel Hobby | Web frontend, short API routes, daily cron | 100 GB bandwidth · 60 s function timeout · daily-only crons · no commercial use |
| Render Free | Long-running services (WebSocket hubs, anything > 60 s), Docker, cron jobs | 512 MB RAM · sleeps after 15 min idle · 750 hr/month |
| Supabase Free | Postgres + Auth + RLS + Storage | 500 MB DB · 50 K MAU · 1 GB Storage · project pauses after 7 days inactivity |
| Upstash Redis Free | Rate limiting, concurrency locks, ephemeral state | 10 K commands/day · 256 MB |
| GitHub Actions | CI + scheduled jobs the free tier won't allow | 2 K min/month private, unlimited public |
| Sentry Free (optional) | Errors + sourcemaps | 5 K errors/month |
Monthly cost: $0. Optional custom domain: ~$10/year.
Skip individual layers you don't need. The patterns below apply whether you use 1, 2, or all 4 services.
When NOT to use this stack
- Sub-second cold start matters — Render Free sleeps after 15 min. First request after idle waits 30–60 s. A scheduled warmup helps but doesn't eliminate.
- Commercial monetization on Vercel — Hobby plan explicitly bans this. Either upgrade to Pro ($20/seat/mo) or move frontend to Cloudflare Pages (allows commercial use, also free).
- Postgres > 500 MB or > 50 K MAU — Supabase Free caps. Either pay or shard out.
- Heavy compute > 512 MB RAM — Render Free cap. Cloudflare Workers is a different free-tier option for stateless work.
- Multi-region writes — Everything in this stack is single-region on free tier. Pay for replicas.
- You need always-on with HIPAA/SOC2 — Free tiers don't sign BAAs or compliance agreements.
Pre-flight (5 min)
brew install bun supabase/tap/supabase gh jq curl
Generate random secrets up front. Save the output to a temp file — you'll paste these values into Vercel + Render in later steps.
~/.claude/skills/zero-cost-deploy/scripts/gen-secrets.sh
That prints lines like:
APP_ENCRYPTION_KEY=UAsi…sbM= # 32 random bytes, base64 — for AES-GCM at rest
INTERNAL_RPC_TOKEN=CnE5…m5k # for Vercel↔Render auth
CRON_SECRET=qDyY…+PlD # for cron route authorization
Important: any secret used by BOTH Vercel and Render must be the same value in both. The script generates each secret once. Don't re-roll them per service.
Order of operations — strict
1. Supabase → emits project_ref + URL + service_role key
2. Upstash → independent; can parallel with Render
3. Render → uses Supabase URL + service_role key + shared RPC token
4. Vercel → uses everything above
5. Smoke test → scripts/verify-deploy.sh
6. Rotate → any secret that touched terminal output or chat
GitHub Actions for CI runs implicitly on push; for scheduled jobs (warming Render, releases), add the workflow at any point.
① Supabase
Sign up + new project at https://supabase.com/dashboard. Region close to your users (us-east-2, ap-northeast-1, etc.). Free plan.
After project creates, Project Settings → API → Data API has the values you need:
| Field | Use as |
|---|---|
| Project URL | NEXT_PUBLIC_SUPABASE_URL (or whatever your framework names it) |
Publishable key (sb_publishable_…) | Browser-side anon key |
Secret key (sb_secret_…) | SUPABASE_SERVICE_ROLE_KEY — server-only, never exposed |
| Project ID / Ref (substring of URL) | <project-ref> for CLI / Management API |
Note: Supabase migrated to
sb_publishable_…/sb_secret_…keys in 2026. Older@supabase/supabase-jsversions only know the JWT-format keys; bump to ≥ 2.x.
Pushing migrations — two paths
Path A (default): CLI
supabase login # opens browser; requires CLI ≥ 1.219
supabase link --project-ref <project-ref>
supabase db push --linked
Path B: Management API (when behind Clash/Mihomo/corporate firewall)
If you see tls error (EOF) or dial timeout on db.<ref>.supabase.co:5432, your network's blocking direct postgres. (Clash fakeip mode uses 198.18.0.0/15; corporate firewalls often block raw TCP to non-standard ports.) HTTPS works.
- Generate a Personal Access Token: https://supabase.com/dashboard/account/tokens → Generate → copy
sbp_…. - Run:
SUPABASE_PAT="sbp_…" PROJECT_REF="<ref>" \
~/.claude/skills/zero-cost-deploy/scripts/supabase-push-migration.sh path/to/migration.sql
The script POSTs your full SQL to POST /v1/projects/<ref>/database/query. Multi-statement migrations work. Errors come back as JSON; the script exits non-zero on any error.
- Verify:
SUPABASE_PAT="…" PROJECT_REF="…" \
~/.claude/skills/zero-cost-deploy/scripts/supabase-verify-tables.sh
In Studio: Table Editor should list all your tables; each table's Policies tab should show your RLS policies.
See references/supabase.md for the full Management API surface.
② Upstash Redis
Console: https://console.upstash.com/. GitHub OAuth signup works.
Recommended: create the DB via Management API. Upstash's UI uses a React combobox for region selection that ignores synthetic JS events — you'll fight it if you try to script. The API is bulletproof.
- Generate a developer API key: https://console.upstash.com/account/api → Create API Key → name it, Read/Write, set expiry → copy the UUID key. Note your account email — Upstash uses HTTP Basic Auth with
email:apikey. - Create the database:
UPSTASH_EMAIL="you@example.com" \
UPSTASH_API_KEY="<uuid>" \
DB_NAME="myapp-rate-limit" \
PRIMARY_REGION="us-east-1" \
~/.claude/skills/zero-cost-deploy/scripts/upstash-create-redis.sh
The script prints UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN.
- Verify:
curl -H "Authorization: Bearer $UPSTASH_REDIS_REST_TOKEN" "$UPSTASH_REDIS_REST_URL/PING"
# → {"result":"PONG"}
See references/upstash.md for region options, global vs regional, eviction config.
③ Render
Use if your app has anything that can't fit in Vercel's 60 s function budget: WebSocket servers, long-running orchestration, background workers, scheduled jobs more frequent than daily.
Repo prep
You need three files in your repo root (templates included):
render.yaml— Blueprint defining the service(s)Dockerfile— multi-stage, fits 512 MB.env.example— all env vars (Render reads which ones to prompt fromrender.yaml)
Sign up + connect repo
- https://dashboard.render.com → Get Started → GitHub OAuth using your repo's org.
- Render sends an email verification — you have to click that link; OAuth alone doesn't unlock the dashboard.
- Dashboard → New → Blueprint → connect your repo.
- If the org isn't visible: click Configure account under GitHub → install Render's GitHub App for the right org → return to Render and refresh.
Apply the Blueprint
Render parses render.yaml, shows env vars marked sync: false for you to fill. Provide values from the secrets you generated + Supabase keys.
Click Apply. Build runs (3–5 min on first deploy of free tier).
GOTCHA: Verify env vars actually saved
When applying the Blueprint, the env values may silently fail to save if your React state is in a weird place (or if you scripted the form). The Service builds with EMPTY env vars and crashes at boot.
Always check after first deploy: Service → Environment → click Edit → confirm actual values. If empty, paste them in and click Save, rebuild, and deploy.
Verify
curl https://<your-service>.onrender.com/healthz
# → ok
④ Vercel
- https://vercel.com/new → Continue with GitHub.
- If "Install the G