Dev environment (Dokploy)
The development environment is self-hosted on a Hostinger VPS (4 vCPU / 16 GB / 200 GB NVMe) running Dokploy (Docker Swarm + Traefik + Let’s Encrypt), fronted by Cloudflare (DNS + proxy), with Cloudflare R2 as the S3-compatible object store. It replaced the previous Railway setup. Staging + production target GCP us-central1 (Iowa) — decided, not yet provisioned; see Production landscape. This environment is kept as the DEV tier under that plan.
Architecture
Section titled “Architecture”Cloudflare (DNS + proxy + R2) *.real-estate-core.com → VPS IP │Hostinger VPS ── Dokploy (Docker Swarm + Traefik + Let's Encrypt) Public: svelte-web :3000 → dev.real-estate-core.com api :3000 → api-dev.real-estate-core.com ← /api/auth + /api/trpc imgproxy :8080 → dev-assets.real-estate-core.com (sources from R2 over S3) docs :8080 → docs-dev.real-estate-core.com Background: worker (Bun + BullMQ) — no public domain Private: postgres (PostGIS) :5432 · valkey :6379 · meilisearch :7700 (one Compose on dokploy-network — no ports, no domains) Off-box: Cloudflare R2 (uploads-dev) · Resend · Google · Knock · GetStream · Sentry · PostHog · Replicate · GeminiOnly web/api/imgproxy/docs (and the Dokploy panel at dokploy.real-estate-core.com) are public. The data services are reachable solely over Dokploy’s internal Swarm network.
Bring-up phases
Section titled “Bring-up phases”0 — VM hardening
Section titled “0 — VM hardening”Non-root deploy sudo user + SSH keys; SSH drop-in (PermitRootLogin no, PasswordAuthentication no); fail2ban + unattended-upgrades; 4 GB swap (the svelte-web build spikes 2–4 GB); UFW (deny incoming, allow SSH/80/443).
Docker bypasses UFW — Docker/Swarm iptables rules run ahead of UFW, so container-published ports are not filtered by it. The Hostinger cloud firewall is the real edge: 80/443 open; 3000 (panel) + 22 restricted to your IP; Swarm ports 2377/7946/4789 closed.
1 — Dokploy
Section titled “1 — Dokploy”curl -sSL https://dokploy.com/install.sh | sh (installs Docker, single-node Swarm, Traefik + Let’s Encrypt). Create the admin at http://<vps-ip>:3000, assign the panel domain dokploy.real-estate-core.com + Let’s Encrypt, then close 3000 at the cloud firewall (keep 80 for renewals).
2 — Cloudflare, Resend, OAuth
Section titled “2 — Cloudflare, Resend, OAuth”- DNS: A records for
dokploy,dev,api-dev,dev-assets,docs-dev(all onreal-estate-core.com) → VPS IP. Grey-cloud during first Let’s Encrypt issuance (HTTP-01 fails behind the proxy), then orange with SSL mode Full (strict) — never Flexible (redirect loop). - R2: bucket
uploads-dev; S3 API token → Access Key ID + Secret. Endpointhttps://<ACCOUNT_ID>.r2.cloudflarestorage.com, regionauto. R2 speaks standard S3, so@aws-sdk/client-s3, imgproxy, and presigned URLs work unchanged (keepforcePathStyle). - Resend: verify a sender domain (SPF/DKIM in Cloudflare) or use
[email protected](delivers only to the Resend account’s own inbox). The API key must be full-access — otherwise Better Auth logins fail with400 "associated domain … is not verified". - Google OAuth redirect URIs:
https://dev.real-estate-core.com/api/auth/callback/googleandhttps://api-dev.real-estate-core.com/api/auth/callback/google.
3 — GitHub
Section titled “3 — GitHub”Install the Dokploy GitHub App on the repo (auto-deploy + build triggers). Create project real-estate-core.
4 — Data services (one Compose, private)
Section titled “4 — Data services (one Compose, private)”One Dokploy Compose service for all three data stores. This gives predictable hostnames (postgres / valkey / meilisearch) and explicit network control — individual Database resources get random suffixed names and can miss dokploy-network, which surfaces as getaddrinfo ENOTFOUND from the apps.
services: postgres: image: postgis/postgis:16-3.5 # bare :16 doesn't exist; Debian variant, pinned restart: unless-stopped environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: app volumes: [ pg_data:/var/lib/postgresql/data ] networks: { dokploy-network: { aliases: [ postgres ] } }
valkey: image: valkey/valkey:8 restart: unless-stopped command: ["valkey-server", "--requirepass", "${VALKEY_PASSWORD}"] # alphanumeric password volumes: [ valkey_data:/data ] networks: { dokploy-network: { aliases: [ valkey ] } }
meilisearch: image: getmeili/meilisearch:v1.9.0 restart: unless-stopped environment: MEILI_MASTER_KEY: ${MEILI_MASTER_KEY} MEILI_ENV: development volumes: [ meili_data:/meili_data ] networks: { dokploy-network: { aliases: [ meilisearch ] } }
networks: dokploy-network: external: true # join Dokploy's existing overlay network — do not recreate
volumes: pg_data: valkey_data: meili_data:The PostGIS image is required — migration 0001 runs CREATE EXTENSION postgis (only postgis; pg_trgm is not enabled anywhere in this repo). No published ports + no Traefik labels ⇒ private. Deploy the data services before the apps (aliases resolve at app boot).
5 — imgproxy
Section titled “5 — imgproxy”Docker Image resource ghcr.io/imgproxy/imgproxy:v3.30.1, port 8080, domain dev-assets.real-estate-core.com. Env: IMGPROXY_KEY/IMGPROXY_SALT (hex64), IMGPROXY_USE_S3=true, IMGPROXY_S3_ENDPOINT=<R2 endpoint>, IMGPROXY_S3_REGION=auto, R2 key/secret.
6 — The four apps
Section titled “6 — The four apps”Each Application: GitHub provider, branch development, Build Type = Dockerfile, Build Path (context) = / (the turbo prune multi-stage builds need the repo root), Dockerfile path per app, auto-deploy + trigger paths.
| App | Dockerfile | Port | Domain |
|---|---|---|---|
| svelte-web | apps/svelte-web/Dockerfile | 3000 | dev.real-estate-core.com |
| api | apps/api/Dockerfile | 3000 | api-dev.real-estate-core.com |
| worker | apps/worker/Dockerfile | 3000 | — (optional bull-dev + Basic auth) |
| docs | apps/docs/Dockerfile | 8080 | docs-dev.real-estate-core.com |
In the Domain dialog, Port = the container port (3000/8080), not 80.
7 — Environment variables
Section titled “7 — Environment variables”Runtime env goes in each app’s Environment tab. Internal connections use the Compose aliases:
DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/appDIRECT_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/appVALKEY_URL=redis://:${VALKEY_PASSWORD}@valkey:6379QUEUE_SUFFIX=-devMEILISEARCH_HOST=http://meilisearch:7700AWS_ENDPOINT_URL=https://<ACCOUNT_ID>.r2.cloudflarestorage.com # + bucket, keys, AWS_DEFAULT_REGION=autoIMGPROXY_URL=https://dev-assets.real-estate-core.com # + key/saltBETTER_AUTH_URL=https://dev.real-estate-core.comCOOKIE_DOMAIN=.real-estate-core.com # web + api are sibling subdomains — without this, cross-subdomain tRPC 401sORIGIN=https://dev.real-estate-core.comPlus the integration secrets (Google, Resend, Knock, GetStream, PostHog, Sentry). Per-app extras:
- api:
CORS_ORIGINS=https://dev.real-estate-core.com,BETTER_AUTH_URL_API=https://api-dev.real-estate-core.com, emptyTRUSTED_PROXY_CIDRS, staging producer settings, andREPLICATE_WEBHOOK_SIGNING_SECRET. The Knock/GetStream token secrets (KNOCK_SIGNING_KEY,KNOCK_SECRET_API_KEY,GETSTREAM_API_SECRET) must also be set here—the browser’s token tRPC runs on apps/api. - svelte-web runtime: the same
AI_ROOM_STAGING_ENABLED, alias sunset, quality, aspect-ratio, format, and compression values as apps/api because its server actions use the shared service directly. - worker:
REPLICATE_API_TOKEN,REPLICATE_WEBHOOK_BASE_URL=https://api-dev.real-estate-core.com,AI_ROOM_STAGING_ENABLED,AI_STAGING_CONCURRENCY=4,AI_STAGING_RATE_LIMIT_PER_MINUTE=6,AI_STAGING_USER_HASH_SECRET,GEMINI_API_KEY,BULL_BOARD_*,LEAD_SLA_DEFAULT_MINUTES=120.
Keep staging disabled until the migration, worker, API/web, signed webhook, and queue checks in the AI room staging runbook pass.
svelte-web build-time values go in Build Arguments, not Build-time Secrets. The Dockerfile consumes them as ARG (--build-arg); Dokploy’s Build-time Secrets are BuildKit --secret mounts the Dockerfile doesn’t read — values placed there silently never reach the build:
PUBLIC_API_URL=https://api-dev.real-estate-core.com— required. svelte-web serves no/api/trpcroute; the browser tRPC client bakes this origin at build time. Empty ⇒ browser tRPC hits svelte-web’s HTML 404 ⇒Unexpected token '<', "<!doctype "…(breaks Knock signed tokens + GetStream init). Rebuild after changing.VITE_SENTRY_DSN,SENTRY_ORG,SENTRY_PROJECT— optional.SENTRY_AUTH_TOKEN(sourcemap upload) is omitted in dev.
8 — Initialize DB + search
Section titled “8 — Initialize DB + search”Run the .ts scripts directly — the npm scripts hardcode --env-file=../../.env.local, which doesn’t exist in containers.
Migrations (worker terminal; its WORKDIR is /app/apps/worker, so cd first):
cd /app/packages/database && bun run scripts/migrate.ts# ✓ 0001_extensions_and_id_functions ✓ 0002_baseline_schema ✓ 0003_triggers_and_functionsMeilisearch seed — from a laptop over an SSH tunnel (ssh -L 7700:127.0.0.1:7700 deploy@<vps-ip> against a temporary Meili external port, removed afterwards): scripts/sync-locations.ts (reads locations/locations.xml — not committed to git), scripts/enrich-viewports.ts (needs GOOGLE_MAPS_API_KEY), scripts/sync-service-areas.ts. There is no sync:agents — agents/teams index automatically via the worker’s search-indexing queue.
9 — Verify
Section titled “9 — Verify”Web loads and hydrates · Google + OTP login works · browser tRPC hits api-dev.real-estate-core.com/api/trpc returning JSON · location search returns results · image upload lands in R2 and renders via dev-assets · worker processes a job (-dev queue suffix) · a push to development rebuilds only the changed app.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Cause → Fix |
|---|---|
manifest for postgis/postgis:16 not found | No bare major tag → postgis/postgis:16-3.5 (Debian, pinned). |
ENOTFOUND real-estate-core-valkey-… / ENOTFOUND valkey | Service not on dokploy-network or random hostname → the Compose above (external: true + aliases); data services up before apps. Test: node -e "require('dns').lookup('valkey',(e,a)=>console.log(e?e.code:a))" |
Module not found "packages/database/scripts/migrate.ts" | Worker WORKDIR is /app/apps/worker → cd /app/packages/database first. |
Resend 400 domain … not verified | Verify the sender domain (SPF/DKIM) or [email protected]; full-access API key. |
tRPC Unexpected token '<', "<!doctype "… | PUBLIC_API_URL empty at build → set as Build Argument (not Build-time Secret) + rebuild svelte-web; api healthy; CORS_ORIGINS on api. |
| tRPC 401 after that | COOKIE_DOMAIN=.real-estate-core.com missing on web + api. |
| Let’s Encrypt won’t issue | Cloudflare proxy on during HTTP-01 → grey-cloud, issue, then orange + Full (strict). Keep port 80 open. |
| svelte-web build OOM | 2–4 GB spike → 4 GB swap; stagger parallel deploys. |
Ops notes
Section titled “Ops notes”- Data services stay private — no ports, no domains; only apps on
dokploy-networkreach them. - Dev data is disposable: Postgres re-migrates, Meili re-seeds, Valkey holds cache/queues. A weekly VPS snapshot suffices.
- Bull Board grants queue control and exposes lead PII — if exposed, HTTP Basic auth is mandatory (
BULL_BOARD_USER/BULL_BOARD_PASSWORD).