Three pieces, three hosts, all on free tiers:
| Piece | Host | Why this one |
|---|---|---|
| API + Redis | Render (Docker, Frankfurt) | Runs the Dockerfile as is, has managed Redis, health-checks before routing traffic |
| PostgreSQL | Supabase (or Neon) | Render's free database expires after 90 days; Supabase's free compute has no hourly quota |
| Frontend | Vercel | Static files with a CDN; one rewrite rule for the SPA |
The demo deployment runs with DEMO_MODE=true, which publishes the demo
logins at GET /demo and rebuilds the dataset every night at 03:00. A real
deployment leaves it off — the route then does not exist at all.
Moved from Neon on 2026-09-23 (ADR 51, 53): Neon's free plan meters compute hours, Supabase's free compute is always on with no hourly quota.
-
supabase.com → New project. Pick the European region closest to Render (Frankfurt if offered; the live demo runs on
eu-west-1, Ireland — about 20 ms further away, irrelevant at demo scale). -
Connect → Session pooler. Copy that string — port 5432:
postgresql://postgres.<ref>:<password>@aws-1-<region>.pooler.supabase.com:5432/postgresNot this Why Direct connection ( db.<ref>.supabase.co)IPv6 only on the free plan; Render has no outbound IPv6 Transaction pooler (port 6543) No session semantics: prisma migrate deploy's advisory lock fails -
Paste it into Render as
DATABASE_URLexactly as copied — no?sslmode=.... TLS is configured byDATABASE_CA_CERT(already inrender.yaml), and ansslmodein the URL would override the CA; the API refuses to start in that case rather than connect unverified.
Why a CA file at all: Supabase's certificates chain to its own root, which no
system trusts, so a plain sslmode=require either fails (the app) or
connects without checking the certificate (the migration CLI). The root is
committed at backend/certs/supabase-prod-ca-2021.crt, SHA-256
80:70:25:AD:50:D4:ED:21:9D:2C:9C:7D:29:9C:00:4F:82:4E:B0:0C:F7:F6:5A:FE:F6:07:D0:7B:72:E6:CA:FA.
Free-tier caveat: Supabase pauses a free project after about a week with no database activity. The daily cron jobs (overdue check, demo reset) were supposed to be that activity, but they run inside the API process and fire only if Render is awake at 02:00 — and on 2026-10-05 the project paused anyway. The API then crash-loops, because the entrypoint's
migrate deploycannot reach the database. The guarantee is now.github/workflows/keep-database-awake.yml: one/health/readyrequest a day from GitHub's scheduler, independent of Render and the uptime pinger. If it is ever paused regardless: dashboard → project → Restore. Data is kept, and Render recovers on its own once the database answers.Neon still works — set
DATABASE_URLwith its?sslmode=requireand removeDATABASE_CA_CERT; Neon's certificate is publicly trusted.
-
render.com → New → Blueprint → connect the GitHub repo. Render reads
render.yamlat the root and proposes two services:fakturly-apiandfakturly-redis. -
Fill in the values marked
sync: false:Variable Value DATABASE_URLfrom step 1 FRONTEND_URLyour Vercel URL, e.g. https://fakturly.vercel.app— no trailing slash, it is compared to the browser'sOriginheader for CORSSTRIPE_SECRET_KEYsk_test_...— see integrations.mdSTRIPE_WEBHOOK_SECRETfrom the Stripe dashboard webhook you create in step 4 RESEND_API_KEYre_...JWT_SECRETis generated by Render (generateValue: true) andREDIS_URLis wired from the Redis service.NODE_ENV=productionmeansenv.tsrefuses to boot if any of the Stripe or Resend keys are missing — deliberately, so a demo cannot go live with payments silently disabled. -
Deploy. The first start runs
prisma migrate deployagainst the empty database, then listens. Watch the logs for[scheduler] DEMO MODE — nightly reset scheduled. -
Seed the demo data once. Render → the service → Shell:
bun run seed:demo
From then on the nightly job keeps it fresh.
-
Check:
https://<api>.onrender.com/health/readyshould answer{"status":"ready","database":"up","redis":"up"}.
Free tier caveat: Render spins a free web service down after 15 minutes without traffic, and the next request takes 30–60 seconds while it wakes. The landing page will feel slow the first time someone opens it. If that matters for a demo, the Starter plan removes it.
The nightly reset needs the process alive at 03:00. A spun-down service runs no cron. Either keep it awake with an external ping every 10 minutes (cron-job.org, UptimeRobot), or run
bun run seed:demofrom the Render shell before showing the demo. For a free tier the ping is the honest option.Ping
/health, never/health/ready./health/readyruns a database query. Neon's free plan has a monthly compute quota and only stops using it when the database has been idle for 5 minutes. A pinger on/health/ready(or Render's health check pointed there, which fires every few seconds) keeps the compute awake 24/7. That is exactly how the live demo went down on 2026-09-23: Neon paused the project for the rest of the month./healthtouches nothing but the process, so Render stays awake and Neon still sleeps. The keep-awake workflow is the one exception: it calls/health/readyonce a day, because there a database query is the whole point.
-
vercel.com → Add New → Project → import the repo.
-
Settings:
Setting Value Root Directory frontendFramework Vite Build command bun run buildOutput directory dist -
Environment variable:
VITE_API_URL= the Render URL from step 2, no trailing slash.VITE_*variables are baked into the bundle at build time. Changing one means redeploying — it is not read at runtime. -
Deploy.
frontend/vercel.jsonrewrites every path toindex.html, so a deep link like/invoices/abcloads the app rather than a 404 from the CDN. -
Go back to Render and set
FRONTEND_URLto the Vercel URL, if you did not know it in step 2. The API restarts and CORS now allows the real origin.
The local setup uses stripe listen; production needs a real endpoint.
- Stripe dashboard (test mode) → Developers → Webhooks → Add endpoint.
- URL:
https://<api>.onrender.com/webhooks/stripe. - Event:
checkout.session.completed. - Copy the signing secret (
whsec_...) intoSTRIPE_WEBHOOK_SECRETon Render.
Test it end to end: log in as the demo admin, open an unpaid invoice,
Betallänk, pay with 4242 4242 4242 4242. The invoice turns PAID
within a few seconds and the ledger shows PAYMENT_RECEIVED.
The full list, with defaults, is validated in backend/src/lib/env.ts — that
file is the reference. The ones that matter for a deploy:
| Variable | Required in production | Notes |
|---|---|---|
DATABASE_URL |
yes | Supabase session pooler (port 5432), no ssl* parameters — or Neon with sslmode=require |
DATABASE_CA_CERT |
Supabase | Path to the CA the database certificate must chain to; certs/supabase-prod-ca-2021.crt (set in render.yaml) |
REDIS_URL |
yes | Render wires it |
JWT_SECRET |
yes, ≥ 32 chars | Render generates it |
FRONTEND_URL |
yes | CORS allowlist and links in emails |
STRIPE_SECRET_KEY |
yes | test key is fine for the demo |
STRIPE_WEBHOOK_SECRET |
yes | from the dashboard endpoint, not stripe listen |
RESEND_API_KEY |
yes | onboarding@resend.dev only delivers to the account owner |
DEMO_MODE |
no | true for the showcase, absent otherwise |
CLIENT_IP_HEADER |
yes, on Render | cf-connecting-ip. Render's X-Forwarded-For begins with whatever the client sent; only Cloudflare's own header is trustworthy. Check /health/whoami on any other host |
CROSS_SITE_COOKIES |
yes, when frontend and API are on different domains | Makes the refresh cookie SameSite=None; Secure. Without it every page reload logs the user out (ADR 47) |
COMPANY_* |
no | seller details on the PDF; defaults are the fictional demo company |
cd backend
docker build -t fakturly-api .
docker run --rm -p 3000:3000 \
-e DATABASE_URL=postgresql://devinder:secret@host.docker.internal:5432/fakturly \
-e REDIS_URL=redis://host.docker.internal:6379 \
-e JWT_SECRET=$(openssl rand -base64 48) \
-e NODE_ENV=development \
fakturly-apihost.docker.internal is how a container reaches services on the host
machine — localhost inside the container is the container itself.
NODE_ENV=development here so the Stripe and Resend stubs are allowed.