All development services run on a single Hostinger VPS under Dokploy (Docker Swarm + Traefik + Let’s Encrypt), fronted by Cloudflare. Only the public-facing hosts (web, apps/api, imgproxy, docs) have public domains; the data services live on the internal dokploy-network with no published ports. Object storage is Cloudflare R2 (off-box, S3-compatible). Staging + production target GCP us-central1 (Iowa) — decided, not yet provisioned; see Production landscape. This VPS is kept as the DEV tier under that plan.
flowchart TB
subgraph Public["Public internet"]
Browser["Browser"]
Mobile["Mobile (native)"]
R2[("Cloudflare R2 (S3)")]
end
subgraph Dokploy["Dokploy on Hostinger VPS (dokploy-network)"]
SvelteKit["apps/svelte-web (Node + adapter-node)"]
API["apps/api (Bun + Hono)"]
Worker["apps/worker (Bun + BullMQ)"]
DB[("PostgreSQL + PostGIS 16")]
VK[("Valkey")]
MS[("Meilisearch v1.9")]
IP["imgproxy v3.30"]
end
Browser -->|HTTPS| SvelteKit
Browser -->|HTTPS| API
Browser -->|HTTPS, signed| IP
Mobile -->|HTTPS| API
SvelteKit --> DB
SvelteKit --> VK
SvelteKit --> MS
SvelteKit --> R2
API --> DB
API --> VK
Worker --> DB
Worker --> VK
Worker --> MS
R2 --> IP
| Service | Internal URL pattern | Public URL |
|---|
| PostgreSQL | postgresql://...@postgres:5432/app (dokploy-network) | — |
| Valkey | redis://:<pw>@valkey:6379 (dokploy-network) | — |
| Meilisearch | http://meilisearch:7700 (dokploy-network) | — |
| Cloudflare R2 | https://<ACCOUNT_ID>.r2.cloudflarestorage.com (S3 API, off-box) | — |
| SvelteKit web | — | https://dev.real-estate-core.com |
| apps/api | — | https://api-dev.real-estate-core.com |
| imgproxy | — | https://dev-assets.real-estate-core.com |
| docs | — | https://docs-dev.real-estate-core.com |
| Bull Board | — | Internal only — Dokploy app terminal / SSH tunnel (optional bull-dev domain behind Basic auth). DEV/STG only — PRD has no Bull Board; use bun run queue:admin (see Incident guides) |
| Service | Why it exists |
|---|
| PostgreSQL + PostGIS | System of record. 45 models, 85+ RLS policies. PostGIS for property ST_MakePoint coordinates. |
| Valkey | (a) Property search via @repo/redis-search GEOSEARCH; (b) BullMQ queue backend (db1); (c) rate-limit buckets; (d) area price funnel cache. |
| Meilisearch | Locations (SEPOMEX-sourced municipalities, cities, localities), agents, agent service areas. Fuzzy + filterable text search. |
| Cloudflare R2 | All uploaded user content: property images, floor plans, avatars. S3-compatible, zero egress fees. |
| imgproxy | On-demand resize + WebP conversion. HMAC-signed URLs — attackers can’t manipulate dimensions or request arbitrary resources. |
| apps/svelte-web | Public site + dashboards. SSR + form actions. Hosts its own Better Auth handler for the web flow. |
| apps/api | Standalone tRPC + Better Auth host for mobile (and eventually browser). Same DB + auth tables, interchangeable sessions. |
| apps/worker | BullMQ consumer. Search indexing, lead ingestion, alerts, AI jobs (Gemini, Replicate). |
| Queue | Concurrency | Notes |
|---|
search-indexing | default | sync-property (2s debounce, 3 retries) → Valkey; sync-agent, sync-team → Meilisearch |
leads | default | lead-ingestion (reliable, DLQ to failed_lead_ingestions); sla-monitor (repeatable */15 * * * *) |
alerts | default | price-drop-notify, status-change-notify, saved-search-scan (repeatable */30 * * * *) |
ai | 1 | limiter: { max: 6/min } to stay under Replicate’s per-account cap at $0 credit |
Every queue name is suffixed with process.env.QUEUE_SUFFIX. Production leaves it empty; local dev sets -dev. The Bull Board registration is suffix-aware too.
| Tool | Coverage |
|---|
| Sentry | Error tracking + session replay, one project per deployable: apps/svelte-web (server + client), apps/api, apps/worker, and the native mobile apps (ios-user / android-user / ios-agent / android-agent, org na-v1f). The non-mobile inits run a beforeSend scrubber (redacts cookies, authorization, CSRF, `password |
| PostHog | Consent-gated product analytics. |
| Bull Board | BullMQ dashboard — reach it via the Dokploy worker terminal or an SSH tunnel (or an optional Basic-auth domain) to inspect queue state, retry / replay failed jobs. DEV/STG only; in PRD the same operations are scripted via bun run queue:admin (Incident guides). |
| Knock dashboard | Inspect in-app feed deliveries + email channel state per workflow. |
| Scenario | Mitigation |
|---|
| Lost Postgres password | Dokploy → data-services Compose → Environment (POSTGRES_PASSWORD) — reset requires re-initializing the volume or an ALTER USER via the container terminal. Update DATABASE_URL + DIRECT_URL in all apps. |
| Stale Valkey state | redis-cli -u "$VALKEY_URL" FLUSHDB then restart the worker so re-indexing fires. |
| Stale Meilisearch index | Re-run scripts/sync-locations.ts + scripts/enrich-viewports.ts + scripts/sync-service-areas.ts (see Dev environment); agents/teams re-index via the worker’s search-indexing queue. |
| imgproxy SSRF | Already mitigated: URLs are HMAC-signed server-side. IMGPROXY_KEY / IMGPROXY_SALT rotation invalidates all in-flight URLs. |
| Replicate traffic or spend spike | Set AI_ROOM_STAGING_ENABLED=false on web, API, and worker to stop new predictions; do not delete persisted jobs. Inspect the dedicated ai-staging queue and ai_provider_attempts, then adjust AI_STAGING_RATE_LIMIT_PER_MINUTE only against the current account limits. Existing prediction IDs remain resumable/cancelable. |
| DB schema drift | cd packages/database && bun run db:reset — drops the schema, then re-runs the Kysely migration chain to latest (0001 ID-generator functions → 0002 baseline schema → 0003 triggers, then the incremental migrations after it) so generated-ID columns and triggers come back intact. See Architecture → Database. |