Deployment
Development deployment (Dokploy)
Section titled “Development deployment (Dokploy)”The development environment is self-hosted on a Hostinger VPS running Dokploy (Docker Swarm + Traefik + Let’s Encrypt), fronted by Cloudflare, with Cloudflare R2 for object storage. Full bring-up runbook: Dev environment (Dokploy) (mirrors deploy/README.md).
Dokploy project (Hostinger VPS)├── svelte-web (Docker multi-stage, Bun runtime, adapter-node) → dev.real-estate-core.com├── apps/api (Docker, Bun, Hono) → api-dev.real-estate-core.com├── worker (Docker single-stage, Bun runtime) — no public domain├── docs (Docker, Astro static via Caddy) → docs-dev.real-estate-core.com├── imgproxy (ghcr.io/imgproxy/imgproxy:v3.30.1) → dev-assets.real-estate-core.com└── data services (one private Compose on dokploy-network) ├── postgres (postgis/postgis:16-3.5) ├── valkey (valkey/valkey:8) └── meilisearch (getmeili/meilisearch:v1.9.0)Off-box: Cloudflare R2 (S3-compatible object storage, bucket 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.
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 — per-app Dokploy settings: Build Type = Dockerfile, Build Path (context) =
/(theturbo prunebuilds need the repo root), Dockerfile Path =apps/<app>/Dockerfile, with per-app trigger paths so a push rebuilds only what changed. svelte-web’s browser-facing values (PUBLIC_API_URL,VITE_SENTRY_DSN,SENTRY_ORG/PROJECT) are Build Arguments (DockerfileARGs) — not Dokploy Build-time Secrets, which are BuildKit--secretmounts the Dockerfile doesn’t read.
Two independent pieces — checks on GitHub Actions, deploys via Dokploy’s GitHub App. Neither needs a CI secret.
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 — the Dokploy GitHub App watches development and auto-builds each app from its Dockerfile on push; per-app trigger paths scope rebuilds. There is no deploy job and no deploy token in CI.
Networking
Section titled “Networking”| From → To | URL pattern |
|---|---|
| App → Postgres (PostGIS) | postgresql://...@postgres:5432/app (dokploy-network) |
| App → Valkey | redis://:<pw>@valkey:6379 (dokploy-network) |
| App → Meilisearch | http://meilisearch:7700 (dokploy-network) |
| 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.real-estate-core.com (Cloudflare → Traefik) |
| Browser → apps/api | https://api-dev.real-estate-core.com |
| Mobile → apps/api | https://api-dev.real-estate-core.com |
| Browser → imgproxy | https://dev-assets.real-estate-core.com |
Inter-service traffic stays on Dokploy’s internal Swarm overlay (dokploy-network); the data services publish no ports and have no domains. Web and api are sibling subdomains, so the Better Auth cookie is scoped to the parent via COOKIE_DOMAIN=.real-estate-core.com.
Infrastructure as code — none yet
Section titled “Infrastructure as code — none yet”There is no IaC in this repository. An Ansible + Terraform tree existed briefly (added Feb 2026, removed during the bun-workspaces migration); only its .gitignore entries survived, which is why references to infra/ outlived the directory itself.
What actually happens today: DEV is deployed by Dokploy’s GitHub App. Each app is a Dokploy Application pointed at branch development with auto-deploy on and per-app trigger paths, so a push rebuilds only what changed. Rollback is a Dokploy redeploy of a previous build, not a script — there is no deploy/deploy.sh.
STG and PRD on GCP are decided but unprovisioned, and Production landscape §1 puts writing Terraform explicitly out of its own scope. Whoever provisions GCP writes the first infrastructure-as-code this repository has had — §9.1 of that document says where it goes and in what order.
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.