Deployment
Production deployment for a self-hosted licensee (own server, DB, domain, branded apps — not SaaS, per spec Section 1).
Architecture
┌────────────┐ ┌──────────────────┐
clients → │ reverse │ ───→ │ backend (NestJS) │ ──→ Postgres+PostGIS
(apps, │ proxy/SSL │ │ (1..N instances) │ ──→ Redis (cache,
web) └────────────┘ └──────────────────┘ queues, sockets)
Multiple backend instances scale horizontally — the Socket.IO Redis adapter fans realtime events across them, and BullMQ workers share the same Redis. This is what the spec's 50k-driver / 100k-rider concurrency targets rely on.
Build & run with Docker
# Build the backend image (multi-stage; ships a slim non-root runtime)
docker build -t taxi-backend ./backend
# Or run the whole stack (db + redis + backend)
docker compose --profile app up -d
The image:
- runs as the non-root
nodeuser, - contains only production dependencies + compiled
dist, - is ~480 MB.
Production checklist
-
NODE_ENV=production. - Strong, unique
JWT_ACCESS_SECRET/JWT_REFRESH_SECRET(32+ chars). Boot fails otherwise. -
CORS_ORIGINSset to your real front-end origins. Boot fails in prod if empty (no reflect-any-origin). -
STORAGE_DRIVER=s3with bucket + keys configured. -
SENTRY_DSNset so 5xx errors are reported. - A real SMS provider (Twilio / MSG91) configured in Admin → Settings.
The default
logprovider only writes to the server log — with it, an SOS records the incident and mints the live-trip link but no trusted contact is ever actually texted. The admin safety queue labels such incidents, but the alert did not leave the server. - TLS terminated at the reverse proxy (Nginx/Caddy/ALB); HTTPS only.
- Database backups + PITR configured.
- Run migrations against the production DB on each release — see below.
- Restrict the DB role: it should not have
TRUNCATE/DROPon ledger tables (immutability is also enforced at the role level).
Running migrations in production
npm run migration:run uses typeorm-ts-node-commonjs, which needs ts-node —
a devDependency. The production image ships only production dependencies and the
compiled dist, so that script fails there with MODULE_NOT_FOUND. Use the
compiled data source instead:
docker compose -f docker-compose.prod.yml exec backend \
node node_modules/typeorm/cli.js migration:run -d dist/database/data-source.js
npm run migration:run remains correct for local development and CI, where dev
dependencies are installed.
CI/CD
/.github/workflows/backend-ci.yml
runs on every push/PR touching backend/:
- lint (zero warnings) → build → run migrations against a real Postgres+PostGIS service → test,
- build the Docker image.
Mobile store builds (rider + driver apps)
Both apps use Expo SDK 54 with dynamic app.config.ts (env-driven) and EAS
build profiles in each app's eas.json (development / preview /
production). White-label vars (APP_NAME, APP_ICON, bundle IDs, API URLs)
are documented in each app's ENV.md and in Installation docs.
Per app (mobile/rider, mobile/driver), from that directory:
# one-time: authenticate + create the EAS project
npx eas login
npx eas init # sets EAS_PROJECT_ID
# config-level validation (no native toolchain needed)
npx expo config --type public # env resolves; prod build throws on localhost URLs
npx expo prebuild --clean # regenerate native projects (picks up new icons)
# Android — produces an .aab for the Play Store
npx eas build -p android --profile production
# iOS — produces an .ipa for App Store Connect
npx eas build -p ios --profile production
# Submit (or upload the artifact manually)
npx eas submit -p android --profile production
npx eas submit -p ios --profile production
Codemagic (configured — codemagic.yaml at the repo root)
Four workflows ship ready: rider-android, rider-ios, driver-android,
driver-ios. Point Codemagic at this repo and they appear automatically.
Create these variable groups in the Codemagic UI, every value marked Secure:
| Group | Variables |
|---|---|
kashvi_api |
EXPO_PUBLIC_API_URL, EXPO_PUBLIC_SOCKET_URL |
maps |
GOOGLE_MAPS_API_KEY |
branding |
APP_NAME, APP_SCHEME, IOS_BUNDLE_ID, ANDROID_PACKAGE (optional — defaults ship) |
google_play |
GCLOUD_SERVICE_ACCOUNT_CREDENTIALS (Play Console → API access → JSON) |
appstore |
App Store Connect API key — attach it as the kashvi_app_store_key integration |
Then upload your Android keystore under Code signing identities and name it
kashvi_keystore.
Two things the workflows do that are easy to get wrong by hand:
expo prebuild --cleanruns on every build. Theios/andandroid/folders are committed, and any build system uses an existing native project as-is — so without regeneration the build keeps whatever was baked in at commit time, including the placeholder Maps key.- They assert the result. After prebuild, the Android workflows grep the
generated manifest for the real Maps key (and, for the driver app, for
ACCESS_BACKGROUND_LOCATION/FOREGROUND_SERVICE_LOCATION) and fail the build if it is absent. A missing Maps key otherwise produces a perfectly successful build that renders a blank grey map on every device.
Both store publish steps are intentionally conservative: Play uploads to the
internal track as a draft, and iOS goes to TestFlight but not straight to the
App Store. Promote from each console once you have looked at the build.
Never commit the real Maps key. This product ships as a source zip. A key baked into
android/app/src/main/AndroidManifest.xmlwould reach every buyer and bill you. The committed manifest carries a placeholder on purpose; the real key is injected by CI at prebuild time and never lands in git.
Supplying the build secrets (do this BEFORE your first production build)
A production build fails fast on purpose if the API/socket URLs are missing,
point at localhost or a LAN address, or the Maps key is empty
(app.config.ts). That guard is what stops a store binary shipping pointed at a
dev machine — so the build cannot succeed until you provide real values.
Do NOT put production values in a .env file: every .env* is git-ignored, and
a file on disk is easy to leak. Use EAS secrets, which are injected as env vars
at build time. Run these once per app (mobile/rider, then mobile/driver):
npx eas secret:create --scope project --name EXPO_PUBLIC_API_URL --value "https://api.yourdomain.com/api"
npx eas secret:create --scope project --name EXPO_PUBLIC_SOCKET_URL --value "https://api.yourdomain.com"
npx eas secret:create --scope project --name GOOGLE_MAPS_API_KEY --value "AIza..."
# White-label identity — only if you are rebranding from the defaults
npx eas secret:create --scope project --name APP_NAME --value "Your Brand"
npx eas secret:create --scope project --name IOS_BUNDLE_ID --value "com.yourbrand.rider"
npx eas secret:create --scope project --name ANDROID_PACKAGE --value "com.yourbrand.rider"
npx eas secret:list # verify before building
If you change any identity value you must re-run npx expo prebuild --clean.
The ios/ and android/ folders are committed, and EAS uses an existing native
project as-is rather than regenerating it — so without that step the build keeps
the old app name and bundle id. The same applies to GOOGLE_MAPS_API_KEY: it is
written into android/app/src/main/AndroidManifest.xml at prebuild time, so
setting the secret alone leaves Android maps blank.
What you must obtain yourself
These cannot be generated from this repo — they are accounts and keys tied to you as the publisher:
| What | Where | Needed for |
|---|---|---|
| Expo account | expo.dev | eas login, running any cloud build |
| Apple Developer Program ($99/yr) | developer.apple.com | iOS builds + App Store submission |
| Google Play Developer ($25 one-time) | play.google.com/console | Play Store submission |
| Android upload keystore | eas credentials can generate and store it |
signing the .aab |
| Google Maps API key | console.cloud.google.com | maps in both apps |
| Play service-account JSON | Play Console → API access | eas submit -p android |
Verified locally so far
The following were built and inspected on a developer machine, so the native projects are known-good before you spend a cloud build:
- Driver Android debug APK — builds; the generated manifest carries
ACCESS_BACKGROUND_LOCATION,FOREGROUND_SERVICE_LOCATIONand the Maps key. - Rider iOS simulator build — builds as a universal (arm64 + x86_64) binary.
No signed release artifact has been produced, because that needs the credentials above. The remaining risk is therefore credential/store configuration, not code.
Pre-submission checklist:
- Real, distinct per-app icons/splash (see
BRANDING-ASSETS.md) — the placeholder is shared across both apps and stores reject duplicates. - Production
EXPO_PUBLIC_API_URL/EXPO_PUBLIC_SOCKET_URL(HTTPS, not localhost/LAN — the prod build fails fast otherwise). - Google Maps key restricted to the app's Android package + SHA-1 and iOS bundle id.
- Store listings, privacy policy, and (driver app) background-location justification prepared.
Health & observability
GET /api/health— readiness (DB + Redis). Returns 503 if either is down — wire this to your load-balancer / k8s readiness probe.- Structured JSON logs (pino) with request-id correlation.
- Sentry captures 5xx + unhandled errors (and they're always logged locally too, even without a DSN).
Scaling notes
- Run N stateless backend replicas behind the proxy.
- Redis and Postgres are the shared state — size them first.
- BullMQ workers can run in the same process (dev) or as separate worker deployments (prod) consuming the same queues.