Deployment
Deployment
Section titled “Deployment”ClinicFlow runs on Railway (backend + frontend), with Supabase for the database, the Railway Redis plugin for queues and caching, and Cloudflare for DNS/SSL.
Architecture
Section titled “Architecture”clinicflow.lat (Cloudflare DNS) ├── app.clinicflow.lat → Railway (Frontend static service) ├── api.clinicflow.lat → Railway (Backend Node.js service) └── Email routing → Gmail (support@, sales@, info@, billing@)Railway Setup
Section titled “Railway Setup”Backend Service
Section titled “Backend Service”- Create a new Railway project
- Add a service → Deploy from GitHub repo → select
clinic-saas-production - Set Root Directory to
backend - Start command:
node --import tsx --import ./src/instrument.ts src/index.ts(matchespackage.json’sstartscript — Railway auto-detects this frompackage.json, no manual override needed) - Add all environment variables from Environment Variables
- Set a custom domain:
api.clinicflow.lat
Frontend Service
Section titled “Frontend Service”- Add another service in the same Railway project
- Deploy from same repo, Root Directory →
frontend - Build command:
npm run build - Start command:
caddy run --config Caddyfile(or Railway static site detection) - Environment variables:
VITE_API_URL=https://api.clinicflow.lat/apiVITE_LS_URL_BASIC=https://...VITE_LS_URL_PRO=https://...VITE_LS_URL_CLINICA=https://...
- Set custom domain:
app.clinicflow.lat
The Caddyfile handles SPA routing (all paths → index.html):
:80 { root * /app/dist try_files {path} {path}/ /index.html file_server}Supabase
Section titled “Supabase”Production Project Setup
Section titled “Production Project Setup”- Create a new Supabase project at supabase.com
- Go to SQL Editor → paste and run
backend/src/db/schema.sql - Enable Row Level Security on all tables (the schema file does this)
- Copy credentials: Settings → API → URL, anon key, service role key
Migrations
Section titled “Migrations”After the initial schema is applied, deploy migrations:
cd backendnpm run migrateBackups
Section titled “Backups”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:
- 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.
- Supabase restores into a new project/branch — it does not overwrite the live project in place.
- Verify the restored data (spot-check
clinics,patients,appointmentsrow counts and a few recent rows against what’s expected). - Cut over: update
SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY/SUPABASE_DB_URL(and any otherSUPABASE_*vars) in Railway to point at the restored project, redeploy. - Update DNS/Cloudflare only if the restored project’s connection details require it (normally just the Railway env vars above change, not DNS).
- Post-incident: run
npm run migrateagainst the restored project to confirmschema.sqlis 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:
- Open your project → New → Plugin → Redis
- Copy the connection string — it becomes
REDIS_URLautomatically in Railway environment variables
For local development, use any Redis instance (local Docker, Railway fork, or a hosted provider).
Cloudflare DNS
Section titled “Cloudflare DNS”Point app and api subdomains to Railway:
Type Name Content ProxyCNAME app your-frontend.up.railway.app ✓ (orange cloud)CNAME api your-backend.up.railway.app ✓ (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
LemonSqueezy
Section titled “LemonSqueezy”- Create a store at lemonsqueezy.com
- Create products for each plan (Basic, Pro, Clínica)
- For each product, create a variant and copy the Variant ID →
LS_VARIANT_*env vars - In Store → Webhooks: add a webhook pointing to
https://api.clinicflow.lat/webhooks/lemonsqueezy- Events:
order_created - Copy the Signing Secret →
LEMONSQUEEZY_SIGNING_SECRET
- Events:
Sentry
Section titled “Sentry”- Create a project at sentry.io (choose Node.js for backend, React for frontend)
- Copy the DSN values →
SENTRY_DSN(backend),VITE_SENTRY_DSN(frontend) - Sentry is initialized in
backend/src/instrument.jsandfrontend/src/main.jsx
Meta / WhatsApp Cloud API
Section titled “Meta / WhatsApp Cloud API”The webhook for all clinics’ WhatsApp messages routes to:
https://api.clinicflow.lat/webhooks/whatsappConfigure 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).
Environment Parity & Staging
Section titled “Environment Parity & Staging”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).
Deploy Checklist
Section titled “Deploy Checklist”Before going live with a new environment:
- Schema applied to Supabase (
schema.sqlrun in SQL Editor) - All required env vars set in Railway
-
ENCRYPTION_KEYis exactly 32 characters -
JWT_SECRETis 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
Rollback
Section titled “Rollback”Application rollback (Railway)
Section titled “Application rollback (Railway)”- Railway dashboard → the affected service → Deployments tab.
- Find the last known-good deployment, click ⋮ → Redeploy.
- Railway serves the previous build immediately — no rebuild needed, since it redeploys the already-built artifact.
- 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.
Database rollback
Section titled “Database rollback”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:
- Write the explicit reverse SQL by hand (e.g.
DROP COLUMN IF EXISTS ...,ALTER TABLE ... DROP CONSTRAINT ...) and run it viaSUPABASE_DB_URL— the same connectionnpm run migrateuses. - Revert the corresponding change in
schema.sqlitself in the same commit, soschema.sqland the live database stay in sync (permigrations/README.md). - If the change already shipped data in a new column/table that must be preserved, export
it first (
pg_dumpa targeted table, or aSELECTinto 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.