Deployment
Hostname scheme
Section titled “Hostname scheme”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.
| Env | Zone (web) | API | Assets (imgproxy) | Docs | COOKIE_DOMAIN |
|---|---|---|---|---|---|
| prd | kottia.com | api.kottia.com | assets.kottia.com | none | .kottia.com |
| stg | stg.useast.kottia.com | api.stg.useast.kottia.com | assets.stg.useast.kottia.com | none | .stg.useast.kottia.com |
| dev | dev.useast.kottia.com | api.dev.useast.kottia.com | assets.dev.useast.kottia.com | docs.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.
Development deployment (Railway)
Section titled “Development deployment (Railway)”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_dataPrivate DNS: <service>.railway.internalOff-platform: Cloudflare R2 uploads-devStaging 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.
Build & deploy
Section titled “Build & deploy”- 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): theadapter-nodeoutput runs underbun build/index.js, matching api/worker. (An earlier Bun-runtime attempt crashed SSR —QueryClientundefined — most plausibly collateral of the drift gotcha below, now exact-pinned; the Dockerfile’s production-stage comment records the fallback path back tonode:20.)ssr.noExternal: truebundles all JS deps; native modules stay external and are declared as proddependenciesso they’re present at runtime —sharp(libvips) viabuild.rollupOptions.external. sharp needs the strongerrollupOptions.externalbecause the prod image’s--ignore-scriptsinstall can’t resolve its prebuilt platform binary at build time, sossr.external’s resolve-then-externalize falls back to bundling and fails;rollupOptions.externalskips resolution entirely. Any native dep reached server-side via@repo/server-services(e.g.sharpthroughimage-sanitize) must follow this pattern. - Web SSR gotcha — the
bun installstage is non-frozen (turbo pruneemits 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-levelwindowreference (e.g.@googlemaps/js-api-loader2.1.0) crashes SSR. Pin such deps exactly (direct version + rootoverrides/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 toreal-estate-core/real-estate-core-mono, branchdevelopment. Each app watches its own path,packages/**, and the rootpackage.jsonandbun.lock. - Build variables — Railway passes service variables as Docker build arguments for matching declared
ARGs.PUBLIC_API_URL=https://api.dev.useast.kottia.comis required at build time and changing it requires asvelte-webrebuild. - Project config —
.railway/railway.tsis the project-wide Config as Code source, using therailwayTypeScript SDK. Railway deprecated the migration’s per-servicerailway.json; userailway config pull,railway config plan, and an explicitly reviewedrailway 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 / job | What it does |
|---|---|
ci.yml → quality | Change-selected Svelte/Paraglide checks, web lint, workspace type checks, and the full docs check. |
ci.yml → tests | App + package unit tests, Prisma/inquiry invariants, native model regeneration diff, and contract fixture validation. |
ci.yml → web-build | Vite 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-drift | Fails if shared native Core/ files diverge beyond known intentional deltas. Uses Bun without installing workspace dependencies. |
ci.yml → android / ios | Change-selected user/agent matrices calling _android-check.yml and _ios-check.yml. |
ci.yml → CI gate | Stable 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.
Networking
Section titled “Networking”| From → To | URL pattern |
|---|---|
| App → Postgres (PostGIS) | postgres.railway.internal:5432 (Railway service reference) |
| App → Valkey | valkey.railway.internal:6379 (Railway service reference) |
| App → Meilisearch | http://meilisearch.railway.internal:7700 |
| App → Cloudflare R2 | https://<ACCOUNT_ID>.r2.cloudflarestorage.com (S3 API) |
| imgproxy → Cloudflare R2 | https://<ACCOUNT_ID>.r2.cloudflarestorage.com (S3 API) |
| Browser → SvelteKit | https://dev.useast.kottia.com (Cloudflare DNS → Railway) |
| Browser → apps/api | https://api.dev.useast.kottia.com |
| Mobile → apps/api | https://api.dev.useast.kottia.com |
| Browser → imgproxy | https://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.
Infrastructure as code
Section titled “Infrastructure as code”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.
Mobile distribution
Section titled “Mobile distribution”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://.
Run on a physical device
Section titled “Run on a physical device”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:
cd apps/ios-userxcodegen generate # regenerate IosUser.xcodeproj from project.ymlopen IosUser.xcodeprojIn 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:
cd apps/android./gradlew :app-user:assembleDebugadb install -r app-user/build/outputs/apk/debug/app-user-debug.apkassembleDebug 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):
# from repo rootbun 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 thekeystore.propertiesupload 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-useruses MapKit — no Maps API key to restrict.apps/android/app-userandapps/android/app-agentboth use Google Maps (play-services-maps) with one shared key committed in each app’sAndroidManifest.xml. Its GCP application restriction must list both package names —com.realestatecore.userandcom.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.