Skip to content

Deployment

ClinicFlow runs on Vercel (Frontend SPA) and Railway (Backend Node.js + Redis), with Supabase for PostgreSQL database and auth, and Cloudflare for DNS/SSL.


clinicflow.lat (Cloudflare DNS)
├── clinicflow.lat / www → Vercel (Frontend Vite SPA)
├── api.clinicflow.lat → Railway (Backend Node.js service)
└── Email routing → Gmail (support@, sales@, info@, billing@)

  1. Create a project in Vercel linking clinic-saas-production repo
  2. Framework Preset: Vite, Root Directory: frontend
  3. SPA Routing is handled by frontend/vercel.json
  4. Add environment variables:
    VITE_API_URL=https://api.clinicflow.lat/api
    VITE_LS_URL_BASIC=https://...
    VITE_LS_URL_PRO=https://...
    VITE_LS_URL_CLINICA=https://...
  5. Custom domains: clinicflow.lat and www.clinicflow.lat
  1. Create a new Railway project (clinicflow-production)
  2. Add Redis service with persistent /data volume
  3. Add backend service from GitHub repo:
    • Root Directory: backend
    • Start command: node --import tsx --import ./src/instrument.ts src/index.ts
    • Port: 3000
  4. Add environment variables (Supabase, Redis, JWT keys, Lemon Squeezy, Meta, Resend)
  5. Set custom domain: api.clinicflow.lat

  1. Create a new Supabase project at supabase.com
  2. Go to SQL Editor → paste and run backend/src/db/schema.sql
  3. Enable Row Level Security on all tables (the schema file does this)
  4. Copy credentials: Settings → API → URL, anon key, service role key

After the initial schema is applied, deploy migrations:

Terminal window
cd backend
npm run migrate

Current state (verified 2026-07-18 via supabase backups list --project-ref <ref>): Point-in-Time Recovery (PITR) is OFF on the production project. No physical backups are currently listed. This means a full data-loss event today has no continuous-recovery path — only whatever backup mechanism the current Supabase plan tier provides by default (daily snapshot, if the plan includes one) or manual pg_dump exports, neither of which this doc can currently confirm are actually running. Enabling PITR is an infrastructure change outside this doc’s scope — track it separately.

Target RTO/RPO (once PITR is enabled):

  • RPO (Recovery Point Objective): ≤ 5 minutes. Supabase PITR uses continuous WAL archiving; actual RPO is bounded by WAL shipping lag, not a fixed backup interval.
  • RTO (Recovery Time Objective): ≤ 4 hours for a full restore-and-cutover, given ClinicFlow’s current data volume. Re-measure after the first real restore drill (see below) — this is a target, not yet a measured number.

Restore runbook:

  1. In the Supabase Dashboard → Database → Backups, choose Point in Time Recovery (if enabled) and select the target timestamp, or choose the most recent daily backup.
  2. Supabase restores into a new project/branch — it does not overwrite the live project in place.
  3. Verify the restored data (spot-check clinics, patients, appointments row counts and a few recent rows against what’s expected).
  4. Cut over: update SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY/SUPABASE_DB_URL (and any other SUPABASE_* vars) in Railway to point at the restored project, redeploy.
  5. Update DNS/Cloudflare only if the restored project’s connection details require it (normally just the Railway env vars above change, not DNS).
  6. Post-incident: run npm run migrate against the restored project to confirm schema.sql is fully applied (a backup taken mid-migration could be schema-stale).

Quarterly restore drills: not yet scheduled — this is process work, not a code/docs change; see the master plan’s “Bigger initiatives” section.


Railway provides a managed Redis plugin. Add it from the Railway dashboard:

  1. Open your project → New → Plugin → Redis
  2. Copy the connection string — it becomes REDIS_URL automatically in Railway environment variables

For local development, use any Redis instance (local Docker, Railway fork, or a hosted provider).


Point domains to Vercel and Railway:

Type Name Content Proxy
CNAME api clinic-saas-backend-production.up.railway.app DNS only (grey cloud)
CNAME www cname.vercel-dns.com ✓ (orange cloud)
A @ 76.76.21.21 ✓ (orange cloud)

SSL is handled by Cloudflare’s edge. Railway also provides SSL termination. Both work.

Email routing (for support@, sales@, info@, billing@):

In Cloudflare → Email → Email Routing:

  • Create catch-all rule forwarding to your Gmail address
  • Or add individual rules per address

  1. Create a store at lemonsqueezy.com
  2. Create products for each plan (Basic, Pro, Clínica)
  3. For each product, create a variant and copy the Variant ID → LS_VARIANT_* env vars
  4. In Store → Webhooks: add a webhook pointing to https://api.clinicflow.lat/webhooks/lemonsqueezy
    • Events: order_created
    • Copy the Signing Secret → LEMONSQUEEZY_SIGNING_SECRET

  1. Create a project at sentry.io (choose Node.js for backend, React for frontend)
  2. Copy the DSN values → SENTRY_DSN (backend), VITE_SENTRY_DSN (frontend)
  3. Sentry is initialized in backend/src/instrument.js and frontend/src/main.jsx

The webhook for all clinics’ WhatsApp messages routes to:

https://api.clinicflow.lat/webhooks/whatsapp

Configure in your Meta App → WhatsApp → Configuration:

  • Webhook URL: https://api.clinicflow.lat/webhooks/whatsapp
  • Verify Token: value of WHATSAPP_WEBHOOK_VERIFY_TOKEN
  • Subscribed fields: messages

Each clinic registers their own phone number through the Settings tab in the dashboard (see WhatsApp Setup Guide).


Staging should mirror production’s structure exactly — same Railway service shape, same env var names — but with entirely separate credentials: a separate Supabase project, separate Redis instance, separate WhatsApp test number/WABA, separate ENCRYPTION_KEY, separate LemonSqueezy test-mode store. Never point a staging service at the production Supabase project or share ENCRYPTION_KEY/JWT_* values between the two — a compromised staging environment must not be able to decrypt or forge tokens for production.

Copy backend/.env.staging.example / frontend/.env.staging.example as a starting point when standing up a staging Railway service; fill in the staging project’s real values there (or set them directly as Railway environment variables — do not commit a real .env.staging, it’s gitignored for exactly this reason).


Before going live with a new environment:

  • Schema applied to Supabase (schema.sql run in SQL Editor)
  • All required env vars set in Railway
  • ENCRYPTION_KEY is exactly 32 characters
  • JWT_SECRET is at least 32 characters
  • Meta webhook URL configured and verified
  • LemonSqueezy webhook URL configured
  • Sentry DSN set (or left blank to disable)
  • Custom domains configured in Railway + Cloudflare
  • First superadmin invite code created via POST /api/superadmin/invite-codes
  • Health check passes: curl https://api.clinicflow.lat/health

  1. Railway dashboard → the affected service → Deployments tab.
  2. Find the last known-good deployment, click ⋮ → Redeploy.
  3. Railway serves the previous build immediately — no rebuild needed, since it redeploys the already-built artifact.
  4. Watch /health (backend) and the frontend’s root URL for a 200 before considering the rollback complete.

There is no automated canary or traffic-split step today — deploys are all-or-nothing per service. If a change needs a safer rollout in the future, that’s new infrastructure (see the master plan’s “Bigger initiatives — Canary deployments + automated rollback”), not something the current setup supports.

schema.sql is additive-only by convention (CREATE ... IF NOT EXISTS, no destructive DROP COLUMN/DROP TABLE without an explicit, reviewed exception) — there is no automated “down migration.” If a schema change needs to be reverted:

  1. Write the explicit reverse SQL by hand (e.g. DROP COLUMN IF EXISTS ..., ALTER TABLE ... DROP CONSTRAINT ...) and run it via SUPABASE_DB_URL — the same connection npm run migrate uses.
  2. Revert the corresponding change in schema.sql itself in the same commit, so schema.sql and the live database stay in sync (per migrations/README.md).
  3. If the change already shipped data in a new column/table that must be preserved, export it first (pg_dump a targeted table, or a SELECT into a CSV) before dropping.

When the database is beyond a simple reverse-SQL fix

Section titled “When the database is beyond a simple reverse-SQL fix”

Fall back to the restore runbook in this doc’s “Backups” section — restore the most recent clean backup into a new project and cut over.