Skip to content

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

  1. 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.
  2. 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.
  3. Given the app issues a request to /api/health from its own origin, when the request is made, then Render proxies it server-side to the API service with the /api prefix stripped (the API receives /health), and the browser receives the API's 200 response same-origin — no redirect, no CORS.
  4. Given the API service is deployed, when /health is requested at the API's own public URL, then it returns 200 — proving the API booted and bound the platform's port.
  5. Given a client-side route with no matching file or /api prefix, when it is requested, then the Static Site falls back to /index.html so 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

  1. Given the blueprint's auto-deploy is set to trigger on passing checks, when a commit lands on main and its CI run goes green, then Render deploys the changed service(s) from that commit.
  2. Given the same configuration, when a commit's CI run is red or still pending, then Render does not deploy that commit.
  3. 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

  1. 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.
  2. Given the same filters, when a commit changes packages/**, then only the API redeploys — apps/web imports no @gw2priory/* package, so a workspace-package change cannot affect its bundle — while a pnpm-lock.yaml change (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's ci.yml.
  • R2 — The blueprint declares two services: a Static Site for apps/web (built with the workspace's build script, publishing apps/web/dist) and a web service for apps/api built 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 /api prefix (so /api/health reaches 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 /api prefix is added. Same-origin /api is achieved entirely by the Static Site rewrite (R3), keeping apps/web's origin-relative /api client base (orval.config.ts) and the committed openapi.json contract 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 /health as the platform health check.
  • R5 — apps/api/src/main.ts binds the platform-provided port — Number(process.env.PORT) (a fallback to 3000 for local dev) on host 0.0.0.0 — instead of the hardcoded 3000, so Render's port detection succeeds. This is the only change to application code. Confirmed behaviour pending — research.md V6 (Render injects PORT, 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), tracking main, 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/** plus pnpm-lock.yaml and root package.json — not packages/**, because apps/web imports no @gw2priory/* workspace package; the API to apps/api/** plus packages/**, pnpm-lock.yaml, and package.json. A change to one app does not rebuild the other; a packages/** change rebuilds only the API; a lockfile or root-package.json change 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.md with 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 200 through the proxy, a deploy that fires only on green — are verified once on the real Render deploy and recorded with a date in docs/architecture/deploy.md, mirroring ci.md's verification log. In particular, the proxy-not-redirect behaviour (R3) is confirmed with curl -I <web-url>/api/health showing 200 (not a 3xx) from the web origin.
  • R12 — A structural test under tests/deploy/ parses the committed render.yaml and asserts its shape, mirroring spec 003's tests/ci/workflow.test.ts over ci.yml: valid YAML; exactly the two services of R2; the two routes of R3 present and in order (the /api/* proxy before the /*→/index.html fallback) with the prefix-stripping destination; the health-check path of R6; the checksPass trigger 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 in research.md with 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.md V1)
  • V2 — the rewrite's :splat destination strips /api so the API receives /health. (V2)
  • V3 — autoDeployTrigger: checksPass deploys a main commit only after its CI is green, and skips red/pending commits. (V3)
  • V4 — per-service buildFilter isolates the two services (a one-app change rebuilds only that app; a packages/** change rebuilds only the API, since apps/web has 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 PORT and requires binding 0.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 HTTP 200, no redirect and no CORS error — i.e. /api/health at 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 200 at /health on 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.html fallback), not a 404, while real asset requests are served directly. (observational — verified on the first real deploy)
  • SC5 — A merge to main whose 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); a pnpm-lock.yaml change redeploys both. (observational — verified from deploy logs, recorded with a date)
  • SC7 — The committed render.yaml has the required shape — two services, the two ordered routes with the prefix-stripping proxy first, the health-check path, the checksPass trigger, 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 checksPass config 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.com URLs; 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 main only. 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 deploy job 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 file render.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.yml runs lint → typecheck → test → docs:build on every push to main; this spec consumes that green signal via checksPass and adds no CI of its own.
  • The apps build with the existing scripts. apps/web's build (panda codegen && vite build → apps/web/dist) and apps/api's build (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.ts and tests/workflow/* already parse committed config and assert invariants; the blueprint structural suite (R12) is the same technique applied to render.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.

CriterionTest / verification
P1 #1manual — docs/architecture/deploy.md log: web URL renders over HTTPS
P1 #2manual — deploy.md log: real asset served directly
P1 #3manual — deploy.md log: curl -I <web>/api/health → 200 same-origin, prefix stripped
P1 #4manual — deploy.md log: API /health 200 at its own URL
P1 #5manual — deploy.md log: deep-link reload serves the app
P2 #1manual — deploy.md log: green commit deploys
P2 #2manual — deploy.md log: red/pending commit does not deploy
P2 #3tests/deploy/render-blueprint.test.ts — build filters (also P3)
P3 #1tests/deploy/render-blueprint.test.ts — web filter excludes api; manual deploy-log check
P3 #2tests/deploy/render-blueprint.test.ts — shared-input filter covers both
SC1manual — deploy.md verification log
SC2manual — deploy.md verification log (the R11 curl)
SC3manual — deploy.md verification log
SC4manual — deploy.md verification log
SC5manual — deploy.md verification log
SC6manual — deploy.md verification log
SC7tests/deploy/render-blueprint.test.ts — services, ordered routes, health path, trigger, filters
SC8tests/deploy/render-blueprint.test.ts — no new workflow; checksPass present
SC9tests/workflow/repo-invariants.test.ts — docs/superpowers count + prior suites
SC10tests/deploy/render-blueprint.test.ts — this table complete + named tests exist