Skip to content

Deployment

The brand domain is kottia.com. Every environment is its own zone — production uses the apex, non-production environments use <env>.<region>.kottia.com — and resources hang off the zone as <resource>.<zone>. The web app lives at the zone itself.

EnvZone (web)APIAssets (imgproxy)DocsCOOKIE_DOMAIN
prdkottia.comapi.kottia.comassets.kottia.comnone.kottia.com
stgstg.useast.kottia.comapi.stg.useast.kottia.comassets.stg.useast.kottia.comnone.stg.useast.kottia.com
devdev.useast.kottia.comapi.dev.useast.kottia.comassets.dev.useast.kottia.comdocs.dev.useast.kottia.com (reserved, Access-gated).dev.useast.kottia.com

Resource-first naming is load-bearing: the browser calls apps/api cross-origin with cookies, so the Better Auth session cookie’s Domain= must be a parent of both the web and API hosts. Using the zone as that parent keeps DEV and STG sessions inside their own zone and out of PRD. The reverse does not hold — PRD’s apex-scoped .kottia.com cookie is also sent to DEV/STG hosts, and a non-prod host could set a .kottia.com cookie PRD would accept. A per-environment Better Auth cookie prefix and a decision on separating the non-prod registrable domain are pending follow-ups before STG/PRD go live (details in deploy/README.md § Hostname scheme). The docs site is DEV-only; Bull Board never gets a public hostname. ORIGIN, BETTER_AUTH_URL, CORS_ORIGINS, BETTER_AUTH_URL_API, PUBLIC_API_URL, IMGPROXY_URL and REPLICATE_WEBHOOK_BASE_URL are all derived from this table — see Dev environment.

DEV runs in Railway project kottia.com, environment development, fronted by Cloudflare DNS (see the TLS depth note on proxying multi-label hosts) and retaining Cloudflare R2 bucket uploads-dev. Full operations runbook: Dev environment (Railway) (mirrors deploy/README.md).

Railway kottia.com / development
├── svelte-web apps/svelte-web/Dockerfile :3000 → dev.useast.kottia.com
├── api apps/api/Dockerfile :3000 → api.dev.useast.kottia.com
├── worker apps/worker/Dockerfile :3000 — no public domain by default
├── docs apps/docs/Dockerfile :8080 — no public domain yet (docs.dev.useast.kottia.com reserved)
├── imgproxy ghcr.io/imgproxy/imgproxy:v3.30.1 :8080 → assets.dev.useast.kottia.com
├── postgres postgis/postgis:16-3.5 :5432 + volume /var/lib/postgresql/data
├── valkey valkey/valkey:8 :6379 + volume /data
└── meilisearch getmeili/meilisearch:v1.9.0 :7700 + volume /meili_data
Private DNS: <service>.railway.internal
Off-platform: Cloudflare R2 uploads-dev

Staging and production are a separate phase on GCP us-central1 (Iowa) — Cloud Run, Cloud SQL, Memorystore for Valkey, self-hosted Meilisearch, GCS, and a Google edge. Decisions are locked; nothing is provisioned yet. See Production landscape. Nothing on this page is production infrastructure.

  • Web — multi-stage Dockerfile at apps/svelte-web/Dockerfile. Stages: turbo prune → bun install → paraglide compile → svelte-kit build. Bun end-to-end (oven/bun:1.3.11-slim): the adapter-node output runs under bun build/index.js, matching api/worker. (An earlier Bun-runtime attempt crashed SSR — QueryClient undefined — most plausibly collateral of the drift gotcha below, now exact-pinned; the Dockerfile’s production-stage comment records the fallback path back to node:20.) ssr.noExternal: true bundles all JS deps; native modules stay external and are declared as prod dependencies so they’re present at runtime — sharp (libvips) via build.rollupOptions.external. sharp needs the stronger rollupOptions.external because the prod image’s --ignore-scripts install can’t resolve its prebuilt platform binary at build time, so ssr.external’s resolve-then-externalize falls back to bundling and fails; rollupOptions.external skips resolution entirely. Any native dep reached server-side via @repo/server-services (e.g. sharp through image-sanitize) must follow this pattern.
  • Web SSR gotcha — the bun install stage is non-frozen (turbo prune emits a lockfile Bun rejects as frozen), so the deploy build can resolve newer ^-ranged deps than the committed lockfile. A clean local build does not guarantee a clean deployed bundle: a browser-only dep that drifts to a version with a module-top-level window reference (e.g. @googlemaps/js-api-loader 2.1.0) crashes SSR. Pin such deps exactly (direct version + root overrides/resolutions) when they sit in the root-layout import graph.
  • API — Dockerfile at apps/api/Dockerfile. Bun + Hono. Mounts /api/auth/* and /api/trpc/*.
  • Worker — single-stage Dockerfile at apps/worker/Dockerfile. oven/bun:1.3-slim, runs TypeScript source directly.
  • .dockerignore — reduces build context from ~3.8 GB to ~50 MB.
  • Deploy config — Railway keeps the repository root as the Docker build context and uses each existing apps/<app>/Dockerfile. GitHub sources point to real-estate-core/real-estate-core-mono, branch development. Each app watches its own path, packages/**, and the root package.json and bun.lock.
  • Build variables — Railway passes service variables as Docker build arguments for matching declared ARGs. PUBLIC_API_URL=https://api.dev.useast.kottia.com is required at build time and changing it requires a svelte-web rebuild.
  • Project config — .railway/railway.ts is the project-wide Config as Code source, using the railway TypeScript SDK. Railway deprecated the migration’s per-service railway.json; use railway config pull, railway config plan, and an explicitly reviewed railway config apply.

Two independent pieces — checks in GitHub Actions and deploys through Railway’s GitHub integration. Neither requires a deployment job or deployment secret in CI.

GitHub Actions (.github/workflows/) — runs on PRs into development and development pushes:

Workflow / jobWhat it does
ci.yml → qualityChange-selected Svelte/Paraglide checks, web lint, workspace type checks, and the full docs check.
ci.yml → testsApp + package unit tests, Prisma/inquiry invariants, native model regeneration diff, and contract fixture validation.
ci.yml → web-buildVite build under Bun on linux-x64 — canary for the Docker build stage (heap-bounded via BUN_JSC_forceRAMSize) + sharp N-API roundtrip check.
ci.yml → native-driftFails if shared native Core/ files diverge beyond known intentional deltas. Uses Bun without installing workspace dependencies.
ci.yml → android / iosChange-selected user/agent matrices calling _android-check.yml and _ios-check.yml.
ci.yml → CI gateStable final status; selected jobs may pass or skip, but any failure blocks the gate.

Deploys — Railway watches development and auto-builds each app from its Dockerfile on push; app-path, packages/**, package.json, and bun.lock watch patterns scope rebuilds.

From → ToURL pattern
App → Postgres (PostGIS)postgres.railway.internal:5432 (Railway service reference)
App → Valkeyvalkey.railway.internal:6379 (Railway service reference)
App → Meilisearchhttp://meilisearch.railway.internal:7700
App → Cloudflare R2https://<ACCOUNT_ID>.r2.cloudflarestorage.com (S3 API)
imgproxy → Cloudflare R2https://<ACCOUNT_ID>.r2.cloudflarestorage.com (S3 API)
Browser → SvelteKithttps://dev.useast.kottia.com (Cloudflare DNS → Railway)
Browser → apps/apihttps://api.dev.useast.kottia.com
Mobile → apps/apihttps://api.dev.useast.kottia.com
Browser → imgproxyhttps://assets.dev.useast.kottia.com

Inter-service traffic stays on Railway private networking. Data services have no public domains. The docs service has no public domain yet; docs.dev.useast.kottia.com is reserved for DEV only and is added once Cloudflare Access/SSO is verified: an anonymous request returns 401/403 or a 30x whose Location points to the configured Cloudflare Access login host. A 200 response containing the docs body or any X-Railway-Edge header fails that access-boundary check. Web and API are sibling subdomains, so the Better Auth cookie is scoped to the parent with COOKIE_DOMAIN=.dev.useast.kottia.com; API CORS allows the web origin. QUEUE_SUFFIX=-dev isolates DEV jobs.

TRUSTED_PROXY_CIDRS=100.64.0.0/10 is currently configured for Railway’s internal proxy range. Treat it as provisional until observed socket peers and direct Railway-domain forwarding-header spoof tests confirm the assumption.

DEV has project-wide Railway Config as Code in .railway/railway.ts. Plan/apply changes through the Railway CLI and review the plan before applying it. Secret values remain manually managed in Railway and outside source; non-secret connections use service references.

STG and PRD on GCP remain decided but unprovisioned. Their future Terraform is separate from the Railway DEV configuration; Production landscape §9.1 describes the planned foundation.

The four mobile apps (apps/ios-user, apps/android/app-user, apps/ios-agent, apps/android/app-agent) are native — Swift/SwiftUI + Kotlin/Compose. There is no Expo/EAS path: each builds with its own platform toolchain and installs as a debug-signed local build for on-device dev. There are no TestFlight / Play tracks yet (the store cutover is a later phase); to share signed test builds with QA before then, use Firebase App Distribution — see Sending test builds below.

All apps target the deployed API (https://api.<domain>) by default; to point at a local apps/api, flip the debug base URL (see each app’s CLAUDE.md). Both customer apps keep bundle / application ID com.realestatecore.user and the expo-user:// deep-link scheme (retained for Better Auth trustedOrigins + Knock); the agent apps keep com.realestatecore.agent / expo-agent://.

Build/install commands per platform. The same shape applies to the agent apps (apps/ios-agent / apps/android/app-agent).

iOS — the project is generated by XcodeGen (IosUser.xcodeproj is gitignored), then built/run from Xcode:

Terminal window
cd apps/ios-user
xcodegen generate # regenerate IosUser.xcodeproj from project.yml
open IosUser.xcodeproj

In Xcode: plug in the iPhone and select it as the run destination; under Signing & Capabilities enable Automatically manage signing and set Team to your Apple Developer team (Xcode registers the device UDID for you). For a standalone build set Edit Scheme → Run → Build Configuration → Release, then Product → Run. Headless alternative: xcrun devicectl device install app --device <udid> <path-to-.app>.

Android — debug APK over adb:

Terminal window
cd apps/android
./gradlew :app-user:assembleDebug
adb install -r app-user/build/outputs/apk/debug/app-user-debug.apk

assembleDebug auto-signs with the debug keystore. A signed assembleRelease needs keystore.properties present (the EAS-exported upload key — see apps/android/app-user/keystore.properties.example); without it, release falls back to debug signing for local R8 testing. Both apps target the deployed API by default; for a local apps/api flip the debug base URL to http://localhost:4000 (iOS) / http://10.0.2.2:4000 (Android).

Sending test builds (Firebase App Distribution)

Section titled “Sending test builds (Firebase App Distribution)”

A local script builds a signed artifact for one of the four native apps and uploads it to that app’s Firebase App, so testers receive it via the Firebase App Tester app (one Firebase project, four registered apps: ios-user, ios-agent, android-user, android-agent):

Terminal window
# from repo root
bun scripts/firebase-distribute.ts <app> [--groups qa] [--notes "msg"] [--no-build]
# <app> ∈ ios-user | ios-agent | android-user | android-agent
  • Android runs assembleRelease (signed with the keystore.properties upload key) and uploads the APK — no per-device registration.
  • iOS runs xcodegen → xcodebuild archive → ad-hoc IPA export with automatic (Xcode-managed) signing (-allowProvisioningUpdates), so the Apple ID for the team must be signed into Xcode → Settings → Accounts. iOS is UDID-gated: each tester’s device must be registered to the team (Apple portal → Devices) or the install won’t launch; the next run re-archives and folds new devices in automatically.

Setup is the four FIREBASE_APP_ID_* IDs + APPLE_TEAM_ID in .env.local (see Environment variables) plus a one-time firebase login. The Firebase App ID is not the bundle id — it looks like 1:1234567890:ios:abcd…. Distribution needs no Firebase SDK in the app. CI automation (signing creds + a service account as GitHub secrets) is deferred — this is a local-only flow for now.

API key restrictions (mandatory before public ship)

Section titled “API key restrictions (mandatory before public ship)”

Any client-side key baked into a native binary ships to user devices — anyone with the IPA / APK can extract it. Without provider-side restrictions, an extracted Google Maps key can rack up billed calls.

  • apps/ios-user uses MapKit — no Maps API key to restrict.
  • apps/android/app-user and apps/android/app-agent both use Google Maps (play-services-maps) with one shared key committed in each app’s AndroidManifest.xml. Its GCP application restriction must list both package names — com.realestatecore.user and com.realestatecore.agent — each with the debug SHA-1s and the Play App Signing cert, and its API restriction must be Maps SDK for Android only. Geocoding / Places / Distance Matrix are Web Service APIs that ignore Android app restrictions, so adding one makes the extracted key replayable from anywhere; those go through the server key.
  • Public-by-design keys (GetStream, Knock) are gated by server-issued user tokens — verify chat.getToken / the signed Knock identification token is the only mint path. No chat / push provider keys are embedded in the customer apps yet (they arrive in a later phase).
  • The API base URL is not a secret — CORS + Better Auth + tRPC’s own auth handle access control.

Server-only secrets (GetStream API secret, Knock secret API key) live on apps/api / the worker and must never be embedded in a mobile binary.