Skip to content

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.


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@)

  1. Create a new Railway project
  2. Add a service → Deploy from GitHub repo → select clinic-saas-production
  3. Set Root Directory to backend
  4. Start command: node --import tsx --import ./src/instrument.ts src/index.ts (matches package.json’s start script — Railway auto-detects this from package.json, no manual override needed)
  5. Add all environment variables from Environment Variables
  6. Set a custom domain: api.clinicflow.lat
  1. Add another service in the same Railway project
  2. Deploy from same repo, Root Directoryfrontend
  3. Build command: npm run build
  4. Start command: caddy run --config Caddyfile (or Railway static site detection)
  5. 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://...
  6. 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
}

  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 → NewPluginRedis
  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 app and api subdomains to Railway:

Type Name Content Proxy
CNAME 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

  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 IDLS_VARIANT_* env vars
  4. In Store → Webhooks: add a webhook pointing to https://api.clinicflow.lat/webhooks/lemonsqueezy
    • Events: order_created
    • Copy the Signing SecretLEMONSQUEEZY_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.