Skip to content

Deployment

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.

  • Web — multi-stage Dockerfile at apps/svelte-web/Dockerfile. Stages: turbo prunebun installparaglide compilesvelte-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 — per-app Dokploy settings: Build Type = Dockerfile, Build Path (context) = / (the turbo prune builds 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 (Dockerfile ARGs) — not Dokploy Build-time Secrets, which are BuildKit --secret mounts 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 / jobWhat it does
ci.ymlqualityChange-selected Svelte/Paraglide checks, web lint, workspace type checks, and the full docs check.
ci.ymltestsApp + package unit tests, Prisma/inquiry invariants, native model regeneration diff, and contract fixture validation.
ci.ymlweb-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.ymlnative-driftFails if shared native Core/ files diverge beyond known intentional deltas. Uses Bun without installing workspace dependencies.
ci.ymlandroid / iosChange-selected user/agent matrices calling _android-check.yml and _ios-check.yml.
ci.ymlCI gateStable 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.

From → ToURL pattern
App → Postgres (PostGIS)postgresql://...@postgres:5432/app (dokploy-network)
App → Valkeyredis://:<pw>@valkey:6379 (dokploy-network)
App → Meilisearchhttp://meilisearch:7700 (dokploy-network)
App → Cloudflare R2https://<ACCOUNT_ID>.r2.cloudflarestorage.com (S3 API)
imgproxy → Cloudflare R2https://<ACCOUNT_ID>.r2.cloudflarestorage.com (S3 API)
Browser → SvelteKithttps://dev.real-estate-core.com (Cloudflare → Traefik)
Browser → apps/apihttps://api-dev.real-estate-core.com
Mobile → apps/apihttps://api-dev.real-estate-core.com
Browser → imgproxyhttps://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.

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.

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 xcodegenxcodebuild 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.