Skip to content

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.

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 · Gemini

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

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.

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

  • DNS: A records for dokploy, dev, api-dev, dev-assets, docs-dev (all on real-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. Endpoint https://<ACCOUNT_ID>.r2.cloudflarestorage.com, region auto. R2 speaks standard S3, so @aws-sdk/client-s3, imgproxy, and presigned URLs work unchanged (keep forcePathStyle).
  • 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 with 400 "associated domain … is not verified".
  • Google OAuth redirect URIs: https://dev.real-estate-core.com/api/auth/callback/google and https://api-dev.real-estate-core.com/api/auth/callback/google.

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

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.

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.

AppDockerfilePortDomain
svelte-webapps/svelte-web/Dockerfile3000dev.real-estate-core.com
apiapps/api/Dockerfile3000api-dev.real-estate-core.com
workerapps/worker/Dockerfile3000— (optional bull-dev + Basic auth)
docsapps/docs/Dockerfile8080docs-dev.real-estate-core.com

In the Domain dialog, Port = the container port (3000/8080), not 80.

Runtime env goes in each app’s Environment tab. Internal connections use the Compose aliases:

Terminal window
DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/app
DIRECT_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/app
VALKEY_URL=redis://:${VALKEY_PASSWORD}@valkey:6379
QUEUE_SUFFIX=-dev
MEILISEARCH_HOST=http://meilisearch:7700
AWS_ENDPOINT_URL=https://<ACCOUNT_ID>.r2.cloudflarestorage.com # + bucket, keys, AWS_DEFAULT_REGION=auto
IMGPROXY_URL=https://dev-assets.real-estate-core.com # + key/salt
BETTER_AUTH_URL=https://dev.real-estate-core.com
COOKIE_DOMAIN=.real-estate-core.com # web + api are sibling subdomains — without this, cross-subdomain tRPC 401s
ORIGIN=https://dev.real-estate-core.com

Plus 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, empty TRUSTED_PROXY_CIDRS, staging producer settings, and REPLICATE_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.comrequired. svelte-web serves no /api/trpc route; 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.

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):

Terminal window
cd /app/packages/database && bun run scripts/migrate.ts
# ✓ 0001_extensions_and_id_functions ✓ 0002_baseline_schema ✓ 0003_triggers_and_functions

Meilisearch 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.xmlnot 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.

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.

SymptomCause → Fix
manifest for postgis/postgis:16 not foundNo bare major tag → postgis/postgis:16-3.5 (Debian, pinned).
ENOTFOUND real-estate-core-valkey-… / ENOTFOUND valkeyService 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/workercd /app/packages/database first.
Resend 400 domain … not verifiedVerify 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 thatCOOKIE_DOMAIN=.real-estate-core.com missing on web + api.
Let’s Encrypt won’t issueCloudflare proxy on during HTTP-01 → grey-cloud, issue, then orange + Full (strict). Keep port 80 open.
svelte-web build OOM2–4 GB spike → 4 GB swap; stagger parallel deploys.
  • Data services stay private — no ports, no domains; only apps on dokploy-network reach 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).