Spec 014 — Render deploy on merge
Status: implemented Branch: 014-render-deploy
Status is set by the human, never by the agent. It moves draft → approved → implemented.
Problem
docs/project-brief.md and docs/architecture/stack.md commit the project to a real, public deploy — "Fast to a live public URL" — and to "deploy on merge" as the other half of the CI/CD story that spec 003 began. Today only half exists: CI runs lint → typecheck → test → docs:build on every PR and every push to main (spec 003), but nothing is ever published. apps/web builds to static assets and apps/api builds to dist/ and boots on 0.0.0.0:3000, yet neither is reachable by anyone; there is no Dockerfile, no blueprint, no hosting. A merge to main produces a green check and nothing a person can open in a browser.
This spec wires the deploy half: on every merge to main, once CI is green, the React app and the NestJS API are published to public HTTPS URLs, with the app reaching the API same-origin so the existing hardcoded /api/* client keeps working without CORS. It is release plumbing, not product — the apps it publishes were built by prior specs; this spec makes a machine host them at the right moment.
The platform is Render, chosen in brainstorming after a cost/topology comparison against Fly.io and Railway (recorded in research.md). The decisive facts: Render's Static Site is a free, always-on CDN (no cold start on the page itself), and a Static Site rewrite proxies /api/* to the API's public URL server-side — recreating, in production, exactly what the dev Vite proxy does, so the app's origin-relative /api base is untouched. The API rides Render's free web-service tier to start.
User stories
Ordered by priority. Each story must be independently testable and shippable — if only P1 ships, there is still something usable.
P1 — The app is live at a public URL, talking to a live API
As the developer (and portfolio viewer), I want the React app served at a public HTTPS URL and the NestJS API deployed and reachable, with the app's /api/* calls proxied to the API from the same origin, so that I can open one link and see the app fetch real data — the "a tool I actually use, live" outcome the brief asks for — with no CORS and no client change.
Independent test: with both services provisioned from the committed blueprint (first sync may be triggered by hand), open the web app's public URL in a browser; the SPA loads from the CDN, and a request the app makes to /api/legendaries (or /api/health) returns the API's real JSON — same origin, HTTP 200, no CORS error in the console. Deep-linking to a client route (e.g. reloading on a React Router path) still serves the app rather than a 404.
Acceptance scenarios
- Given the web Static Site is deployed, when a browser loads its public URL, then the SPA is served over HTTPS from Render's CDN and renders.
- Given a real static asset path (e.g. a built JS bundle), when it is requested, then the file is served directly and no rewrite rule rewrites it.
- Given the app issues a request to
/api/healthfrom its own origin, when the request is made, then Render proxies it server-side to the API service with the/apiprefix stripped (the API receives/health), and the browser receives the API's200response same-origin — no redirect, no CORS. - Given the API service is deployed, when
/healthis requested at the API's own public URL, then it returns200— proving the API booted and bound the platform's port. - Given a client-side route with no matching file or
/apiprefix, when it is requested, then the Static Site falls back to/index.htmlso React Router can resolve it.
P2 — Merging to main publishes automatically, but only when CI is green
As the developer, I want every merge to main to deploy both services automatically, and only after that commit's CI checks pass, so that publishing is a consequence of merging — not a manual step — and a commit that fails lint/typecheck/test/docs:build never reaches the public URL.
Independent test: merge a trivial change to main and observe both services deploy after — and only after — the commit's CI run goes green; separately, push a commit whose CI fails and observe that no deploy is triggered for it.
Acceptance scenarios
- Given the blueprint's auto-deploy is set to trigger on passing checks, when a commit lands on
mainand its CI run goes green, then Render deploys the changed service(s) from that commit. - Given the same configuration, when a commit's CI run is red or still pending, then Render does not deploy that commit.
- Given a merge that changes nothing in a service's build inputs, when the deploy triggers, then that unchanged service is not needlessly rebuilt (see P3).
P3 — A one-app change deploys only that app
As the developer, I want a change that touches only apps/web to rebuild and redeploy only the web service (and only apps/api / shared packages to touch the API), so that a front-end tweak does not burn build minutes rebuilding the API, and deploys stay fast and cheap on the free tier.
Independent test: the committed blueprint declares per-service build filters; a structural test asserts the API's filter covers apps/api/** plus packages/** and the lockfile, and the web's filter covers apps/web/** plus the lockfile but excludes packages/** (apps/web imports no workspace package) and the other app. Behaviourally, a web-only commit's deploy log shows the API skipped.
Acceptance scenarios
- Given per-service build filters in the blueprint, when a commit changes only
apps/web/**, then only the web service redeploys and the API is skipped. - Given the same filters, when a commit changes
packages/**, then only the API redeploys —apps/webimports no@gw2priory/*package, so a workspace-package change cannot affect its bundle — while apnpm-lock.yamlchange (which both filters include) redeploys both.
Requirements
- R1 — Deployment is declared by a single committed Render Blueprint at the repo root (
render.yaml). It is the only blueprint this spec adds, and it is the source of truth for both services' configuration — the committed-artifact counterpart to spec 003'sci.yml. - R2 — The blueprint declares two services: a Static Site for
apps/web(built with the workspace'sbuildscript, publishingapps/web/dist) and a web service forapps/apibuilt and run from a committed Dockerfile (apps/api/Dockerfile) whose build context is the monorepo root so the API's workspace dependencies (@gw2priory/domain,@gw2priory/recipe-graph,@gw2priory/legendary-recipes) resolve. Docker is chosen over Render's native Node runtime for a deterministic pnpm-workspace install and a pinned Node 22 — research.md V5. Both services are pinned to the same region — Frankfurt (EU-Central) — decided by the human 2026-08-13. - R3 — The Static Site declares two ordered routes: first, a rewrite of
source: /api/*to the API service's public URL that strips the/apiprefix (so/api/healthreaches the API as/health, mirroring the dev Vite proxy); second, an SPA fallback rewrite of/*to/index.html. Real static assets are served directly regardless of these rules. The rewrite being a server-side proxy (not a redirect) and the prefix-strip both require confirmation — R11, research.md V1/V2. - R4 — The API serves unprefixed paths (
/health,/legendaries,/recipe-graph/:id) exactly as today; no Nest global/apiprefix is added. Same-origin/apiis achieved entirely by the Static Site rewrite (R3), keepingapps/web's origin-relative/apiclient base (orval.config.ts) and the committedopenapi.jsoncontract unchanged. This spec adds no HTTP endpoints and makes no OpenAPI change; the only contract it touches is the routing one above, and it reuses the existing/healthas the platform health check. - R5 —
apps/api/src/main.tsbinds the platform-provided port —Number(process.env.PORT)(a fallback to3000for local dev) on host0.0.0.0— instead of the hardcoded3000, so Render's port detection succeeds. This is the only change to application code. Confirmed behaviour pending — research.md V6 (Render injectsPORT, default 10000, and requires binding it). - R6 — The API web service declares a health check path of
/health, so the platform gates a release on the API answering before routing traffic to it. - R7 — Both services set auto-deploy to trigger on passing checks (
autoDeployTrigger: checksPass), trackingmain, so a deploy happens on merge only after that commit's GitHub Actions CI (spec 003) reports success — never on a red or pending commit. No new GitHub Actions workflow is added; CI stays exactly as spec 003 built it and becomes the deploy gate. Confirmed behaviour pending — research.md V3. - R8 — Each service declares a build filter scoping it to its own inputs: the web service to
apps/web/**pluspnpm-lock.yamland rootpackage.json— notpackages/**, becauseapps/webimports no@gw2priory/*workspace package; the API toapps/api/**pluspackages/**,pnpm-lock.yaml, andpackage.json. A change to one app does not rebuild the other; apackages/**change rebuilds only the API; a lockfile or root-package.jsonchange rebuilds both. Confirmed behaviour pending — research.md V4. - R9 — The API runs on Render's free web-service tier to start. The known consequence — the free tier spins the service down when idle and cold-starts on the next request — is accepted and documented (see Assumptions); the Static Site is unaffected (it is a CDN and does not sleep). Moving the API to a paid always-on tier is a one-line blueprint change, deferred until a cold start actually costs something (see Out of scope).
- R10 — The one-time platform setup — creating the Render account, connecting the GitHub repo via Render's GitHub integration, and the first Blueprint sync — is a human, dashboard-side step, not a committed artifact, exactly as spec 003's branch protection (R10) is. It is documented in
docs/architecture/deploy.mdwith the reason it cannot live in the repo, and the first successful deploy is recorded there with a date. - R11 — The genuinely observational outcomes — a reachable URL, a same-origin
200through the proxy, a deploy that fires only on green — are verified once on the real Render deploy and recorded with a date indocs/architecture/deploy.md, mirroringci.md's verification log. In particular, the proxy-not-redirect behaviour (R3) is confirmed withcurl -I <web-url>/api/healthshowing200(not a3xx) from the web origin. - R12 — A structural test under
tests/deploy/parses the committedrender.yamland asserts its shape, mirroring spec 003'stests/ci/workflow.test.tsoverci.yml: valid YAML; exactly the two services of R2; the two routes of R3 present and in order (the/api/*proxy before the/*→/index.htmlfallback) with the prefix-stripping destination; the health-check path of R6; thechecksPasstrigger of R7; and each service's build filter of R8. A malformed or drifted blueprint fails the suite loudly. - R13 — No artifact is written under any
docs/superpowers/path, and the conformance suites from prior specs continue to pass unchanged.
Mark anything unresolved inline rather than assuming an answer. Two markers, split by who can answer:
[NEEDS CLARIFICATION: specific question]— only the human can answer. A product decision, a scope boundary, a preference. Blocks step 1.5.[NEEDS VERIFICATION: specific question]— only reality can answer. Whether the codebase works that way, whether an endpoint returns that field, whether that number is achievable. Answered inresearch.mdwith cited evidence, never by assumption. Blocks the approval gate.
Verification outcomes (region resolved — Frankfurt, 2026-08-13; all six verification questions have a verdict in research.md, so no open marker remains and the approval gate is clear). Each was Confirmed on paper against Render's docs; the on-Render runtime behaviours are finalised on the first deploy (SC1–SC6 observational, R11 log):
- V1 — a Static Site rewrite to an external URL is a true same-origin server-side proxy (200, no CORS), not a 3xx redirect; residual is the R11
curl -I <web>/api/health. (research.mdV1) - V2 — the rewrite's
:splatdestination strips/apiso the API receives/health. (V2) - V3 —
autoDeployTrigger: checksPassdeploys amaincommit only after its CI is green, and skips red/pending commits. (V3) - V4 — per-service
buildFilterisolates the two services (a one-app change rebuilds only that app; apackages/**change rebuilds only the API, sinceapps/webhas no workspace deps; the lockfile rebuilds both). (V4) - V5 — the API Docker build resolves the three workspace packages with the monorepo as build context; confirmed as mechanism, the concrete build finalised on the first deploy. (V5)
- V6 — Render injects
PORTand requires binding0.0.0.0:$PORT; the free API cold-starts on idle while the Static Site (CDN) does not. (V6)
Success criteria
Measurable and technology-agnostic — outcomes, not implementation. Deployment is inherently Render here (chosen in brainstorming; see Assumptions), so the criteria name the outcome — a reachable URL, a same-origin 200, an automatic deploy — rather than the blueprint YAML that produces it.
- SC1 — The web app is reachable at a public HTTPS URL and renders. (observational — verified on the first real deploy, recorded with a date)
- SC2 — A request the app makes to
/api/*from its own origin reaches the API and returns the API's response with HTTP200, no redirect and no CORS error — i.e./api/healthat the web origin yields the API's health payload. (observational — the R11 curl, recorded with a date) - SC3 — The API is independently reachable and returns
200at/healthon its own public URL. (observational — verified on the first real deploy, recorded with a date) - SC4 — Reloading on a client-side route serves the app (via the
/index.htmlfallback), not a 404, while real asset requests are served directly. (observational — verified on the first real deploy) - SC5 — A merge to
mainwhose CI run is green triggers an automatic deploy of the changed service(s); a commit whose CI is red or pending triggers no deploy. (observational — verified on the first real deploys, recorded with dates) - SC6 — A commit that changes only one app redeploys only that app; a
packages/**change redeploys only the API (web has no workspace deps); apnpm-lock.yamlchange redeploys both. (observational — verified from deploy logs, recorded with a date) - SC7 — The committed
render.yamlhas the required shape — two services, the two ordered routes with the prefix-stripping proxy first, the health-check path, thechecksPasstrigger, and each service's build filter — asserted by an automated test over the committed file. - SC8 — No new GitHub Actions workflow is added; the deploy is gated entirely by the existing spec 003 CI. (asserted by the absence of a new workflow file and the
checksPassconfig in SC7) - SC9 — The count of files under any
docs/superpowers/path stays zero, and prior specs' suites still pass — asserted by the existing invariants. - SC10 — Every acceptance scenario and success criterion above maps either to a named automated test or to a dated manual-verification record, with no gap; the automated portion passes.
Out of scope
- A custom domain. The services are reachable at their default
*.onrender.comURLs; attaching a custom domain (e.g.gw2priory.app) and its certificate — which Render supports free even on this tier — is a later, trivial change and not this spec's work. - An always-on (paid) API. The API starts on the free tier and accepts cold starts (R9). Flipping it to a paid always-on tier is a one-line blueprint change, deferred until a cold start actually costs a demo or a user.
- Observability. Structured logs (pino), Sentry error tracking, and request metrics — named in the brief — are a separate concern; this spec adds no logging or monitoring integration beyond the platform's own health check.
- A database / managed Postgres. The API is stateless today; no datastore is provisioned. When persistence arrives (users, encrypted GW2 keys), its hosting is that feature's spec.
- A per-PR preview environment. This spec deploys
mainonly. Per-PR preview URLs (Render supports them) are deferred, as spec 003 already deferred the docs preview. - Deploy via a GitHub Actions job. The deploy is triggered by Render's own branch-watching gated on CI (R7), not by an Actions
deployjob running a CLI. No new workflow is added. - Managing the platform connection as code. The GitHub↔Render connection and first sync (R10) are set by hand and documented, not provisioned by a committed script — the same acknowledged tension spec 003 captured for branch protection.
Assumptions
Like spec 003, this is partly a workbench spec: it names its tooling (Render, its Blueprint, Docker) because choosing that tooling is the point of the feature, not an implementation leak. The following are fixed by prior decisions or taken as given.
- Render is the platform, decided in brainstorming over Fly.io and Railway on cost and topology fit (
research.md). The blueprint filerender.yaml, the Static-Site/Docker service model, and the dashboard-side GitHub connection all follow from that choice. - The private-API requirement was dropped. The API is stateless with no secrets today, so a network-private API buys nothing; it is deployed public (which the rewrite destination requires anyway). When user data arrives it will be protected by auth, not network topology.
- CI exists and is the gate. Spec 003's
ci.ymlrunslint → typecheck → test → docs:buildon every push tomain; this spec consumes that green signal viachecksPassand adds no CI of its own. - The apps build with the existing scripts.
apps/web'sbuild(panda codegen && vite build→apps/web/dist) andapps/api'sbuild(swc src -d dist) already exist; this spec hosts their output, it does not change how they build. The one code change is R5's port binding. - The free tier's shape is as researched. Static Site = free CDN, no spin-down; free API web service spins down when idle and cold-starts on the next request. These are confirmed with citations in
research.md(V1–V6) and observed once on the real deploy (R11). - The in-repo config-test pattern is established.
tests/ci/workflow.test.tsandtests/workflow/*already parse committed config and assert invariants; the blueprint structural suite (R12) is the same technique applied torender.yaml.
Traceability
Each acceptance scenario and success criterion maps to a named test or to a dated manual-verification record — the latter only for the inherently observational criteria (a reachable URL, a same-origin 200, an automatic deploy) that no in-repo test can prove. SC10 asserts this table has no empty cell. Filled in during implementation.
| Criterion | Test / verification |
|---|---|
| P1 #1 | manual — docs/architecture/deploy.md log: web URL renders over HTTPS |
| P1 #2 | manual — deploy.md log: real asset served directly |
| P1 #3 | manual — deploy.md log: curl -I <web>/api/health → 200 same-origin, prefix stripped |
| P1 #4 | manual — deploy.md log: API /health 200 at its own URL |
| P1 #5 | manual — deploy.md log: deep-link reload serves the app |
| P2 #1 | manual — deploy.md log: green commit deploys |
| P2 #2 | manual — deploy.md log: red/pending commit does not deploy |
| P2 #3 | tests/deploy/render-blueprint.test.ts — build filters (also P3) |
| P3 #1 | tests/deploy/render-blueprint.test.ts — web filter excludes api; manual deploy-log check |
| P3 #2 | tests/deploy/render-blueprint.test.ts — shared-input filter covers both |
| SC1 | manual — deploy.md verification log |
| SC2 | manual — deploy.md verification log (the R11 curl) |
| SC3 | manual — deploy.md verification log |
| SC4 | manual — deploy.md verification log |
| SC5 | manual — deploy.md verification log |
| SC6 | manual — deploy.md verification log |
| SC7 | tests/deploy/render-blueprint.test.ts — services, ordered routes, health path, trigger, filters |
| SC8 | tests/deploy/render-blueprint.test.ts — no new workflow; checksPass present |
| SC9 | tests/workflow/repo-invariants.test.ts — docs/superpowers count + prior suites |
| SC10 | tests/deploy/render-blueprint.test.ts — this table complete + named tests exist |