Skip to content

Infrastructure

Development runs in Railway project kottia.com, environment development, fronted by Cloudflare DNS (proxied only where the zone certificate covers the multi-label DEV hosts — see the TLS note in Deployment). Only web, apps/api, and imgproxy have public domains; docs and data services use Railway private networking at <service>.railway.internal. Object storage remains Cloudflare R2 bucket uploads-dev. Staging and production target GCP us-central1 (Iowa) — decided, not provisioned; see Production landscape.

flowchart TB
    subgraph Public["Public internet"]
        Browser["Browser"]
        Mobile["Mobile (native)"]
        R2[("Cloudflare R2 (S3)")]
    end

    subgraph Railway["Railway: kottia.com / development"]
        SvelteKit["apps/svelte-web (Bun + 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
ServiceInternal URL patternPublic URL
PostgreSQLpostgres.railway.internal:5432 (Railway service reference)—
Valkeyvalkey.railway.internal:6379 (Railway service reference)—
Meilisearchhttp://meilisearch.railway.internal:7700—
Cloudflare R2https://<ACCOUNT_ID>.r2.cloudflarestorage.com (S3 API, off-box)—
SvelteKit web—https://dev.useast.kottia.com
apps/api—https://api.dev.useast.kottia.com
imgproxy—https://assets.dev.useast.kottia.com → container port 8080
docshttp://docs.railway.internal:8080— (docs.dev.useast.kottia.com reserved for DEV, Access-gated)
Bull Board—Internal by default; optional bull.dev.useast.kottia.com only behind Basic auth. DEV/STG only — PRD uses bun run queue:admin (see Incident guides)

PostgreSQL, Valkey, and Meilisearch have Railway volumes mounted at /var/lib/postgresql/data, /data, and /meili_data, respectively. Their services have no public domains. The docs service has no public domain yet; docs.dev.useast.kottia.com is reserved for DEV and is added only once Cloudflare Access/SSO is verified: an anonymous request must return 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 the access-boundary check.

ServiceWhy it exists
PostgreSQL + PostGISSystem 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.
MeilisearchLocations (SEPOMEX-sourced municipalities, cities, localities), agents, agent service areas. Fuzzy + filterable text search.
Cloudflare R2All uploaded user content: property images, floor plans, avatars. S3-compatible, zero egress fees.
imgproxyOn-demand resize + WebP conversion. HMAC-signed URLs — attackers can’t manipulate dimensions or request arbitrary resources.
apps/svelte-webPublic site + dashboards. SSR + form actions. Hosts its own Better Auth handler for the web flow.
apps/apiStandalone tRPC + Better Auth host for mobile (and eventually browser). Same DB + auth tables, interchangeable sessions.
apps/workerBullMQ consumer. Search indexing, lead ingestion, alerts, AI jobs (Gemini, Replicate).
QueueConcurrencyNotes
search-indexingdefaultsync-property (2s debounce, 3 retries) → Valkey; sync-agent, sync-team → Meilisearch
leadsdefaultlead-ingestion (reliable, DLQ to failed_lead_ingestions); sla-monitor (repeatable */15 * * * *)
alertsdefaultprice-drop-notify, status-change-notify, saved-search-scan (repeatable */30 * * * *)
ai1limiter: { max: 6/min } to stay under Replicate’s per-account cap at $0 credit

Every queue name is suffixed with process.env.QUEUE_SUFFIX. Railway DEV and local dev use -dev; production will leave it empty. Producers and workers in a tier must use the same suffix. Bull Board registration is suffix-aware too.

ToolCoverage
SentryError 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
PostHogConsent-gated product analytics.
Bull BoardBullMQ dashboard — internal by default, or exposed through the optional Basic-auth-protected DEV domain. It grants queue control and exposes lead PII. DEV/STG only; PRD uses bun run queue:admin (Incident guides).
Knock dashboardInspect in-app feed deliveries + email channel state per workflow.
ScenarioMitigation
Lost Postgres passwordUpdate the Railway postgres service credential and its service references. Reset requires re-initializing the volume or ALTER USER from a deployed container.
Stale Valkey stateredis-cli -u "$VALKEY_URL" FLUSHDB then restart the worker so re-indexing fires.
Stale Meilisearch indexRestart the worker to restore locations / service_areas from R2 search-seeds/*.ndjson.gz; agents/teams re-index through the worker’s search-indexing queue.
imgproxy SSRFAlready mitigated: URLs are HMAC-signed server-side. IMGPROXY_KEY / IMGPROXY_SALT rotation invalidates all in-flight URLs.
Replicate traffic or spend spikeSet 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 driftcd 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.