Skip to content

Research 014 — Render deploy on merge ​

Status: complete

Step 1.5 output, written between the spec draft and the approval gate. All six [NEEDS VERIFICATION] markers in spec.md (V1 on R3, V2 on R3, V3 on R7, V4 on R8, V5 on R2, V6 on R5/R9) have a verdict here, so this file is complete. No [NEEDS CLARIFICATION] markers remained open — the one product decision (region) was answered by the human (Frankfurt / EU-Central, 2026-08-13) before discovery.

Verified against, on 2026-08-13: Render's official documentation (render.com/docs/*) as of that date, and this repo at branch 014-render-deploy (spec commit e7e898a, worktree base cd7fae7). Outside-world claims cite Render docs pages; repo claims cite a file and line. A structural caveat runs through all six: every on-Render runtime behaviour (a proxied 200, a build that succeeds on Render's builders, a deploy that fires only on green) is confirmed here on paper, from the vendor's own docs, and is finalised on the first real deploy — which is exactly why the spec makes SC1–SC6 observational and adds the R11 dated verification log. This mirrors spec 003, where the Linux-runner behaviour was a doc-backed residual proven only on the first CI run.

V1 — Is a Static Site rewrite to an external URL a true server-side proxy (same-origin 200, no CORS), not a redirect? ​

Question. R3 rests on the browser calling /api/* on the web origin and receiving the API's response same-origin — no CORS, no client change. That only holds if a Render Static Site rewrite whose destination is the API's external URL is served server-side (the browser stays on the static origin and gets 200), not as a 3xx that sends the browser cross-origin.

Verdict. Confirmed (on paper). Render's rewrite is defined as a server-side fetch that preserves the browser's URL; an external URL is an allowed destination. Final proof is the R11 curl on first deploy.

Evidence.

  • Render docs, Redirects and Rewrites: a rewrite "Does not redirect the browser. Instead, your site serves the content from the rule's destination at the original path." A redirect, by contrast, returns 301/302 and moves the browser. (render.com/docs/redirects-rewrites)
  • The same page's own basic example uses a full external URL as a rewrite destination (/web-host → https://render.com), and frames the pattern as "communicate from a single-page app with an API that doesn't support CORS requests" — i.e. the reverse-proxy, no-CORS use case we need. (render.com/docs/redirects-rewrites)
  • Because the browser keeps the static-site origin and receives the content at the original path, the request is same-origin from the browser's view — no CORS preflight applies. Our client (apps/web/orval.config.ts:25, baseUrl: '/api') is therefore untouched.

Caveat. The docs never literally print the words "proxy" or "CORS-safe"; the guarantee is inferred from "does not redirect the browser; serves content at the original path," which is the definition of a reverse proxy. Residual, finalised on first deploy (SC2 / R11): curl -I https://<web>/api/health must return 200 from the web origin, not a 3xx. If it is a redirect, R3 fails and the spec returns to step 1 to choose the nginx-web-service topology instead.

V2 — Does the rewrite's :splat destination strip the /api prefix so the API receives /health, not /api/health? ​

Question. The API serves unprefixed paths (apps/api/src/main.ts mounts /health, and the committed openapi.json paths are origin-relative), while the browser calls /api/health. R3 needs the rewrite to strip /api before forwarding — exactly what the dev proxy does today.

Verdict. Confirmed (on paper). A source: /api/* wildcard replayed as :splat in the destination forwards only the captured tail, stripping /api.

Evidence.

  • Render's route schema captures the source wildcard * and replays it in destination via :splat; a source: /api/* → destination: https://<api>.onrender.com/:splat forwards /api/health as /health to the external host. (render.com/docs/redirects-rewrites, render.com/docs/blueprint-spec)
  • This mirrors the current dev behaviour we are recreating in prod: apps/web/vite.config.ts:21-25 proxies /api → http://localhost:3000 with rewrite: (path) => path.replace(/^\/api/, '') — the same strip. Prod parity is the goal.
  • Fallback if ever needed (not used): set a Nest global /api prefix so the API serves /api/health and the rewrite becomes a pass-through. Rejected — it would change openapi.json and the dev proxy; the :splat strip keeps the contract identical.

Caveat. Route destination must be a literal string — fromService host references are not usable inside a route destination (only in envVars). So the API's onrender.com URL is hardcoded in render.yaml; see F2 for the coordination gotcha that creates.

V3 — Does autoDeployTrigger: checksPass deploy a main commit only after its CI checks pass, and skip red/pending commits? ​

Question. R7 (and P2) assume the deploy is gated by spec 003's existing GitHub Actions CI, with no new Actions job — Render itself waits for green.

Verdict. Confirmed (on paper). checksPass is a documented blueprint trigger meaning exactly "deploy only when the tracked branch's CI checks pass."

Evidence.

  • Render blueprint spec: autoDeployTrigger takes commit | checksPass | off; checksPass = "Trigger a deploy only if the linked branch's CI checks pass." (render.com/docs/blueprint-spec)
  • Render watches the service's tracked branch (main); a merge to main is a push to main, and spec 003's ci.yml already runs on push to main (docs/architecture/ci.md), so the commit reports check runs that Render can gate on. No deploy Actions job is added — CI stays as 003 built it.
  • The deprecated boolean autoDeploy is superseded by autoDeployTrigger; older examples using autoDeploy: true are stale. (render.com/docs/blueprint-spec)

Caveat. checksPass requires (a) the repo connected via Render's GitHub integration and (b) the commit to actually report checks — if a commit has no check runs, there is nothing to gate on. Our push:main CI guarantees checks exist. This is a genuine runtime behaviour: residual finalised on the first green merge (deploys) and a first red/pending commit (does not deploy) — SC5 / R11.

V4 — Does per-service buildFilter actually stop a web-only change from rebuilding the API, and vice-versa? ​

Question. R8 / P3 assume each service can be scoped to its own inputs so a front-end change does not burn build minutes rebuilding the API on the free tier.

Verdict. Confirmed (on paper). buildFilter.paths / ignoredPaths gate a service's builds to matching changed paths.

Evidence.

  • Render blueprint spec: a service's buildFilter with paths/ignoredPaths (repo-root-relative globs) restricts which changed files trigger that service's build. (render.com/docs/blueprint-spec)
  • The workspace boundaries this maps onto are real in the repo: the API depends on @gw2priory/domain, @gw2priory/recipe-graph, @gw2priory/legendary-recipes (apps/api/package.json:14-16), so the API filter must cover apps/api/** and packages/** and the lockfile; the web filter covers apps/web/** plus the same shared inputs. A packages/** or pnpm-lock.yaml change correctly rebuilds both.

Caveat. Correctly widening the filter to shared inputs is as important as narrowing it: omit packages/** and a shared-package change would ship a stale API. Residual: observed from the deploy logs of a web-only commit (API skipped) and a shared-input commit (both build) — SC6 / R11.

V5 — Does the API Docker build succeed on Render with the monorepo as build context, resolving the three workspace packages? ​

Question. R2 builds the API from a committed Dockerfile whose context is the repo root, so the workspace dependencies resolve. Two things must hold: Render supports Dockerfile web services with a configurable context, and a pnpm-workspace build works in that context.

Verdict. Confirmed (mechanism); the concrete build is an implementation-and-deploy residual. Render supports Docker web services with dockerfilePath + dockerContext; the workspace build itself is standard pnpm and is proven when the Dockerfile is written (step 4) and first deployed.

Evidence.

  • Render supports Dockerfile builds for web services, with a configurable Dockerfile path and build context. (render.com/docs/docker, render.com/docs/blueprint-spec)
  • The API already builds locally via SWC: apps/api/package.json:7 swc src -d dist --strip-leading-paths, boot node dist/main.js; the Dockerfile wraps this same build with a pinned Node 22 and a pnpm install --frozen-lockfile over the workspace. Choosing Docker over Render's native Node runtime buys a deterministic install and avoids native-runtime corepack/pnpm-version drift.
  • The three workspace deps that force a full-repo context are present and named (apps/api/package.json:14-16).

Caveat. The Dockerfile does not exist yet — writing it is step 4, gated. Discovery confirms the platform capability and the local build, not a specific Dockerfile. A discovery spike (building the image locally) was not run: it would require authoring the Dockerfile, which is implementation, and the real question — does it build on Render's builders — is only answerable on the first deploy. Residual tracked by P1 #4 / SC3 (the API answering /health proves it built and booted).

V6 — Does Render inject PORT and require binding 0.0.0.0:$PORT, and does the free API cold-start as researched? ​

Question. R5 changes the one hardcoded value in the app; R9 accepts free-tier cold starts. Both rest on Render's runtime contract.

Verdict. Confirmed (on paper). Render injects PORT and requires the process to bind it on 0.0.0.0; the free web tier spins down on idle and cold-starts, while the Static Site (a CDN) does not.

Evidence.

  • Render web services: the platform sets the PORT env var (default 10000) and the service must bind 0.0.0.0:$PORT or the deploy fails port detection. (render.com/docs/web-services)
  • The repo hardcodes the wrong thing today: apps/api/src/main.ts:26 await app.listen(3000, '0.0.0.0'). R5's change is app.listen(Number(process.env.PORT) || 3000, '0.0.0.0') — one line, keeping 3000 as the local-dev fallback so pnpm dev and the Vite proxy target (apps/web/vite.config.ts:22, http://localhost:3000) are unaffected.
  • Free tier: a free web service spins down after ~15 min idle and cold-starts (~a minute) on the next request; Static Sites are CDN-served, consume no instance hours, and do not spin down. Free-tier allotments: 750 instance-hours/mo, 100 GB egress, 500 build-pipeline minutes. (render.com/docs/free)

Caveat. The cold-start only affects the first API call after idle; the SPA itself always loads instantly from the CDN. Residual: the exact cold-start latency is observed once on the real deploy and noted in the R11 log — but it does not gate correctness, only demo snappiness (the accepted R9 trade-off, one blueprint line to reverse).

F1 — Swagger (/api-docs) is not covered by the /api/* rewrite ​

Not asked about, but worth recording so no one is surprised.

What. The API mounts Swagger UI at /api-docs (apps/api/src/main.ts:24, SwaggerModule.setup('api-docs', …)). The Static Site rewrite matches source: /api/* — paths under /api/. /api-docs starts with /api-, not /api/, so it does not match and is not proxied through the web origin.

Why it matters. Swagger remains reachable only at the API's own onrender.com URL, not through the web app. That is fine (docs live on the API origin), but it means the web origin exposes no API explorer. No action needed; recorded so a future "why isn't /api-docs on the site?" has an answer. There is no secret exposure risk today (the API is stateless), consistent with the spec's dropped-private-API assumption.

F2 — The hardcoded rewrite destination must match the API service's actual onrender.com hostname ​

Load-bearing for R3, and a direct consequence of V2's caveat (route destinations can't use fromService).

What. render.yaml's /api/* rewrite destination is a literal URL, https://gw2priory-api.onrender.com/:splat. Render derives a service's subdomain from its name, but appends a suffix if that name is already taken globally on onrender.com. If the API service does not get exactly gw2priory-api.onrender.com, the hardcoded destination points at nothing.

Why it matters. The blueprint's proxy and the API's real URL must agree. Mitigation, recorded for the plan: on the first Blueprint sync, confirm the API service's actual URL; if Render suffixed the name, update the render.yaml destination to match and re-sync. This is a one-time coordination step, called out in the R10 dashboard-setup doc so it is not discovered as a mystery 404 through the proxy.

Refuted claims ​

None. Every claim the spec rests on held against the docs and the codebase. What remains are residuals, not refutations: the on-Render runtime behaviours (V1 proxied 200, V3 gated deploy, V4 build isolation, V5 Docker build, V6 cold start) are doc-confirmed now and finalised on the first real deploy — which the spec already treats as observational (SC1–SC6) with a dated R11 log. No premise failed, so the spec does not return to step 1.

Graduation ​

Candidates for docs/architecture/ at step 6 — a new docs/architecture/deploy.md — so the next spec does not re-derive them:

  • The deploy shape — Render, one committed render.yaml: a free Static Site (CDN, no spin-down) for apps/web with an ordered /api/* proxy (:splat strip) + /*→/index.html SPA fallback, and a Docker-built apps/api web service on the free tier, both in Frankfurt, deploy-on-main gated by CI via autoDeployTrigger: checksPass (V1–V4, V6).
  • The same-origin contract — the SPA's /api base is served by the static-site rewrite, not CORS; the API stays unprefixed and openapi.json is unchanged (V2). Forward pointer for any future API-auth spec: network isolation was deliberately not used; auth will protect data instead.
  • Runtime contract & standing gotchas — bind 0.0.0.0:$PORT (V6); the rewrite destination is a hardcoded hostname that must match the API service's real URL (F2); /api-docs is not proxied (F1); free API cold-starts, Static Site does not (V6); the GitHub↔Render connection and first sync are a documented human step (spec R10), the same not-a-committed-artifact tension spec 003 recorded for branch protection.
  • The verification log — the dated observations of SC1–SC6 on the first real deploy live in deploy.md, mirroring ci.md's log.