Skip to content

Deployment Topology ​

The whole non-production fleet is described by a single Render Blueprint — render.yaml in luke-platform — that stands up every service across three environments sharing one database instance.

Topology ​

Environments ​

EnvironmentPurposeBranch(es)
devIntegration / daily developmentdevelop
qaTest & verificationqa
uatUser-acceptance(promoted)
prodProductionDeliberately excluded from this blueprint (deferred)

Deploy the right branch

Service repos deploy from develop (and qa), not main. A change merged to main for a service repo does not ship. Open PRs against develop (or qa). The one exception is the luke-email library, whose deploy branch is main.

Services per environment ​

Each environment runs the same six deployables plus shared Postgres:

ServiceTypeNotes
Core EngineDocker web serviceThe engine + capability layer; readiness probe; postgres,prod profile in prod
Auth EngineDocker web serviceStateless WorkOS↔engine gateway
File ProxyDocker web serviceS3 byte-proxy + headless-Chromium PDF render
AgentsRender-native (Python)FastAPI AI agents; no Dockerfile, Render buildpack
Consumer UIStatic siteWith security headers (DENY frame, HSTS, nosniff)
Core UIStatic siteOperator cockpit; CSP currently report-only

All environments share one PostgreSQL instance with per-environment schema-name + table-prefix isolation (see Multi-Tenancy).

The shared database rule ​

Because dev/qa/uat share one Postgres, each engine instance must set a distinct schema-name + table-prefix. Without it, the second environment to boot crashes creating act_id_user. This is the single most important deployment invariant.

Static apps & vendored bundles ​

The UIs deploy as Render static sites. Two wrinkles:

  • The public /embed page is a standalone bundle copied into the engine (static/embed-assets) — a Consumer UI push does not update it; you must rebuild the embed bundle, copy it into the engine, and push the engine.
  • The apps consume the headless libraries as vendored built dist, so a library change requires rebuilding the package, re-vendoring, and rebuilding the app.

CORS & edge ​

The public surface (/api/public/**, /embed*) uses an any-origin / no-credentials carve-out in both engine layers and the auth gateway, because module-script bundle loads send an Origin header even same-origin. The *.lukeflow.com wildcard was intentionally dropped in favor of these explicit carve-outs. See Authentication & Authorization.

Filter order matters as much as the policy. The CORS filter must run before anything that can reject a request — the security chain (Spring Boot orders it at -100) and the rate-limit filter — or those rejections leave without Access-Control-Allow-Origin. The browser then blocks the response, so fetch() rejects with a network/CORS TypeError instead of resolving with the real status. A plain Filter bean is registered at LOWEST_PRECEDENCE, i.e. after the security chain, which is the trap: the gateway's 401 for an aged-out access token became an unreadable "CORS error", and the UI's refresh-on-401 retry (which must read the 401) never fired. Both services now pin the order explicitly and test it.

Operations tooling ​

luke-platform also carries the fleet runbooks (DR, rollback, incident triage, scaling/HA), a k6 load-test harness, a ZAP DAST baseline, an S3/IAM provisioning kit, and the merge master prompts. See Platform & Deployment.

See also ​

Lukeflow Manual · documentation snapshot July 2026