Skip to content

Latest commit

 

History

History
187 lines (148 loc) · 8.94 KB

deployment.md

File metadata and controls

187 lines (148 loc) · 8.94 KB

Deploying Fakturly

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.


1. Database — Supabase

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.

  1. 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).

  2. Connect → Session pooler. Copy that string — port 5432: postgresql://postgres.<ref>:<password>@aws-1-<region>.pooler.supabase.com:5432/postgres

    Not 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
  3. Paste it into Render as DATABASE_URL exactly as copied — no ?sslmode=.... TLS is configured by DATABASE_CA_CERT (already in render.yaml), and an sslmode in 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 deploy cannot reach the database. The guarantee is now .github/workflows/keep-database-awake.yml: one /health/ready request 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_URL with its ?sslmode=require and remove DATABASE_CA_CERT; Neon's certificate is publicly trusted.

2. API — Render

  1. render.com → New → Blueprint → connect the GitHub repo. Render reads render.yaml at the root and proposes two services: fakturly-api and fakturly-redis.

  2. Fill in the values marked sync: false:

    Variable Value
    DATABASE_URL from step 1
    FRONTEND_URL your Vercel URL, e.g. https://fakturly.vercel.app — no trailing slash, it is compared to the browser's Origin header for CORS
    STRIPE_SECRET_KEY sk_test_... — see integrations.md
    STRIPE_WEBHOOK_SECRET from the Stripe dashboard webhook you create in step 4
    RESEND_API_KEY re_...

    JWT_SECRET is generated by Render (generateValue: true) and REDIS_URL is wired from the Redis service. NODE_ENV=production means env.ts refuses to boot if any of the Stripe or Resend keys are missing — deliberately, so a demo cannot go live with payments silently disabled.

  3. Deploy. The first start runs prisma migrate deploy against the empty database, then listens. Watch the logs for [scheduler] DEMO MODE — nightly reset scheduled.

  4. Seed the demo data once. Render → the service → Shell:

    bun run seed:demo

    From then on the nightly job keeps it fresh.

  5. Check: https://<api>.onrender.com/health/ready should 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:demo from the Render shell before showing the demo. For a free tier the ping is the honest option.

Ping /health, never /health/ready. /health/ready runs 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. /health touches nothing but the process, so Render stays awake and Neon still sleeps. The keep-awake workflow is the one exception: it calls /health/ready once a day, because there a database query is the whole point.

3. Frontend — Vercel

  1. vercel.com → Add New → Project → import the repo.

  2. Settings:

    Setting Value
    Root Directory frontend
    Framework Vite
    Build command bun run build
    Output directory dist
  3. 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.

  4. Deploy. frontend/vercel.json rewrites every path to index.html, so a deep link like /invoices/abc loads the app rather than a 404 from the CDN.

  5. Go back to Render and set FRONTEND_URL to the Vercel URL, if you did not know it in step 2. The API restarts and CORS now allows the real origin.

4. Stripe webhook

The local setup uses stripe listen; production needs a real endpoint.

  1. Stripe dashboard (test mode) → Developers → Webhooks → Add endpoint.
  2. URL: https://<api>.onrender.com/webhooks/stripe.
  3. Event: checkout.session.completed.
  4. Copy the signing secret (whsec_...) into STRIPE_WEBHOOK_SECRET on 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.


What each environment variable does

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

Running the image locally

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-api

host.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.