Skip to content

Deploy ​

How the app reaches a public URL, established by spec 014 and verified rather than assumed. Graduated from specs/014-render-deploy/research.md so the next spec does not re-derive it — the deploy-side counterpart to ci.md, which covers the check that gates this deploy. Spec 015 replaced 014's same-origin proxy with the direct-URL + CORS model below; see "Why there is no proxy" for why.

The deploy shape ​

Deployment is declared by a single committed Render Blueprint, render.yaml at the repo root — the deploy-side counterpart to spec 003's ci.yml. It declares two services:

  • gw2priory — a free Static Site (Render's global CDN, no spin-down) publishing apps/web/dist. It carries one route, a rewrite of source: /* → /index.html, the SPA fallback so a reload on a client-side route (React Router) still serves the app; real static asset paths are matched and served directly before it applies. The app calls the API directly, cross-origin: its build-time VITE_APP_ENV env var (set to prod here) selects the API's absolute base URL (apps/web/src/api/apiBase.ts), and every request goes to {base}/api/*. There is no /api/* rewrite route — see "Why there is no proxy".
  • gw2priory-api — a free Docker web service (region Frankfurt), built from the committed apps/api/Dockerfile with the monorepo root as build context (so the workspace packages — @gw2priory/domain, @gw2priory/recipe-graph, @gw2priory/legendary-recipes — resolve). It serves every route under a runtime /api prefix (app.setGlobalPrefix('api') in main.ts), so healthCheckPath: /api/health gates traffic on the platform's own health probe. It enables CORS (app.enableCors, Nest/Fastify built-in, no new dependency) with a hardcoded allow-list — http://localhost:5173 (dev web) and https://gw2priory-l21x-7m5b.onrender.com (prod web, apps/api/src/config/cors.ts) — which reflects a matching request Origin into Access-Control-Allow-Origin and answers preflight automatically, so the cross-origin browser request from the web app succeeds without a proxy. Swagger lives at /api/docs on the API's own URL (no proxy to route it through). openapi.json itself stays origin-relative (unchanged by the prefix — the /api segment lives only in the client's now-absolute base); see docs/architecture/stack.md.

Both services set autoDeployTrigger: checksPass, tracking main. No new GitHub Actions workflow is added — Render watches the repo itself and deploys a main commit only once that commit's existing spec-003 CI run (lint → typecheck → test → docs:build) reports success, and never deploys a red or pending commit. The deploy gate is entirely CI's, reused rather than reimplemented. Each service also declares a buildFilter: the API scoped to apps/api/** plus packages/**, pnpm-lock.yaml, and package.json; the web to apps/web/** plus pnpm-lock.yaml and package.json — not packages/**, because apps/web imports no @gw2priory/* workspace package. So a one-app change rebuilds only that app, and a packages/** change rebuilds only the API.

Region note ​

Render's Static Site product has no region — it is a global CDN by construction, so there is nothing to pin. Only compute (the API's Docker web service) has a region, and that is pinned to Frankfurt. This is how the spec's "both services in Frankfurt" decision is actually realised: the compute lives in Frankfurt, the CDN in front of the static assets is global by design.

The one-time human step (R10) ​

Before the blueprint can deploy anything, three things happen once, by hand, in the Render dashboard:

  1. Create the Render account.
  2. Connect this GitHub repository via Render's GitHub integration (the equivalent of the branch-watching ci.yml already relies on, but on Render's side).
  3. Run the first Blueprint sync, which reads the committed render.yaml and provisions the two services from it for the first time.

This is not a committed artifact, for the same reason spec 003's branch-protection rule in ci.md isn't one: it is a setting inside a third-party dashboard (here, Render's GitHub App installation and project), not a file this repository can express or a repo test can assert. It is done once by hand and recorded here, not automated.

Why there is no proxy ​

Spec 014 declared a Static Site rewrite source: /api/* → …/:splat, assuming Render would substitute the :splat capture with the matched tail (as Netlify's :splat / Vercel's :path* do) and strip the /api prefix along the way. It doesn't: Render's static-site rewrites forward the literal destination string, capture and all. Measured on the spec-014 deploy: GET https://gw2priory-l21x-7m5b.onrender.com/api/legendaries returned HTTP 404 with body {"message":"Cannot GET /:splat", …} — the API's own 404 for the literal path /:splat, proof the request reached the API with the destination forwarded verbatim, no substitution (specs/015-env-api-url/research.md, V1). A same-origin proxy is therefore impossible on a Render static site, not merely misconfigured — spec 015 drops the approach rather than patching it, so it is not re-attempted.

The one part of the old design that still matters — the assumption that a Render service's *.onrender.com URL is stable once assigned (Render appends a numeric suffix only if the plain name is already taken by another account) — carries over unchanged. It now backs two hardcoded, non-secret constants instead of a proxy destination: the web app's build-time API base (apps/web/src/api/apiBase.ts) and the API's CORS allow-list (apps/api/src/config/cors.ts). Both were set from the real post-first-sync URLs (gw2priory-api-l21x-lbn5.onrender.com, gw2priory-l21x-7m5b.onrender.com) and only need updating again if Render ever reassigns them.

The free-tier trade-off (R9) ​

gw2priory-api runs on Render's free web-service plan. The known consequence: the free tier spins the service down after a period of inactivity, and the next request cold-starts it — observed to take on the order of a minute before the API answers. The Static Site (gw2priory) is a CDN and is unaffected — it does not sleep and serves instantly regardless of API activity.

Moving the API off the free tier is a one-line change — plan: free → a paid plan name in render.yaml — deferred until a cold start actually costs a demo or a user (see the spec's Out of scope). Similarly, the front-end's default gw2priory.onrender.com URL can be prettified later with a free custom domain on the Static Site; that is also deferred, not required for this spec.

Verification log ​

The spec's observational criteria (SC1–SC6) are only provable on the real Render deploy. Each is recorded here with the date it was observed — this log is what spec 014's traceability table cites for those rows.

CriterionObservationDate
SC1 (web app reachable at a public HTTPS URL, renders)pending first real deploy
SC2 (/api/* from the web origin reaches the API, 200, no redirect, no CORS)pending first real deploy
SC3 (API independently reachable, 200 at its own /health) — superseded post-015: /health no longer exists, only /api/health doespending first real deploy
SC4 (client-route reload falls back to /index.html; real assets served directly)pending first real deploy
SC5 (green commit deploys; red/pending commit does not)pending first real deploy
SC6 (one-app change redeploys only that app; shared-input change redeploys both)pending first real deploy

Spec 015 supersedes 014's SC2 row (proxy-shaped) with its own criteria — labels reused from specs/015-env-api-url/spec.md, distinct from 014's rows above:

CriterionObservationDate
SC1 (local: VITE_APP_ENV=dev web loads data from http://localhost:3000/api/*, no proxy/CORS error)Web dev server (VITE_APP_ENV=dev) boots and serves; its inlined import.meta.env.VITE_APP_ENV is "dev" so API_ORIGIN resolves to http://localhost:3000 (verified from the Vite-served apiBase.ts module). Combined with the SC2 exchange below, the app fetches its data from the local API cross-origin. Live in-browser visual render not observed this session (browser extension unavailable) — folded into SC6 on the real deploy.2026-08-14
SC2 (curl -H 'Origin: http://localhost:5173' http://localhost:3000/api/legendaries returns data with matching Access-Control-Allow-Origin)200 with access-control-allow-origin: http://localhost:5173 and the real legendaries payload (Frostfang, …); a disallowed Origin receives no ACAO (a browser would block it); the old unprefixed /legendaries now 404s (prefix confirmed).2026-08-14
SC6 (real deploy: prod web loads data cross-origin, matching ACAO for the prod origin, no proxy)Confirmed on the real Render deploy: the prod web app loads and populates its data from the API cross-origin, with no proxy and no CORS error (in-browser). This also closes SC1's deferred in-browser render.2026-08-15

Reference ​

  • specs/014-render-deploy/ — the spec, discovery, plan, and tasks that established the blueprint shape.
  • specs/015-env-api-url/ — the spec and research (research.md V1) that replaced the proxy with the direct-URL + CORS model.
  • render.yaml — the committed blueprint itself.
  • docs/architecture/ci.md — the CI pipeline this deploy is gated on via autoDeployTrigger: checksPass.
  • docs/architecture/stack.md — the higher-level infra decisions this refines, including the origin-relative openapi.json pattern.