Plan 014 — Render deploy on merge
Status: approved Written in plan mode from spec.md and research.md. Approved by the human before any code is written. Status is set by the human, never by the agent: proposed → approved.
Produced alone — tasks.md stays untouched until this plan is approved in turn.
Goal
After this plan, a merge to main whose CI is green publishes the app to the internet: the React SPA is served from Render's CDN at a public HTTPS URL, and the NestJS API runs as a Render service the SPA reaches through a same-origin /api/* proxy — no CORS, no client change. A single committed render.yaml is the source of truth; a repo test guards its shape; a documented human step performs the one-time Render↔GitHub connection; and a dated log records the observational outcomes the first real deploy proves. The apps are not new — this plan hosts what prior specs built, and changes exactly one line of app code.
Approach
Six deliverables, in dependency order. The first two make the API runnable on a platform; the third declares both services; the rest guard, document, and prove it.
The one code change —
apps/api/src/main.ts. Bind the platform port:await app.listen(Number(process.env.PORT) || 3000, '0.0.0.0'). Render injectsPORT(default 10000) and fails port detection unless the process binds it (research V6);3000stays as the local-dev fallback sopnpm devand the Vite proxy target (vite.config.ts→localhost:3000) are untouched. This is the only application-code change in the spec.The API image —
apps/api/Dockerfile+ root.dockerignore(spec R2). A Dockerfile whose build context is the monorepo root so the API's three workspace dependencies resolve. The subtlety this plan must respect (discovery,monorepo.md):packages/*are TS source consumed at runtime, and the SWC-built API imports runtime values from them (netSellPrice, recipe-graph functions). That works only because pnpm symlinks each package so its realpath is outsidenode_modules, letting Node ≥ 22.18 strip the.tson the fly (measured, spec 008). The image therefore must preserve the pnpm workspace layout and run onnode:22(≥ 22.18) — it must not usepnpm deploy/a flattened copy that de-symlinks the packages intonode_modules(that reintroduces the type-strip failure ofmonorepo.md's F12). Shape: install with a frozen lockfile (with@swc/core's build allowed, perpnpm-workspace.yaml),pnpm --filter @gw2priory/api build, thenCMD ["node", "apps/api/dist/main.js"]from a filesystem where the workspace symlinks are intact. A localdocker build+docker run -e PORT=8080+curl /health→ 200 is the deliverable's own test, so the runtime resolution is proven before Render is ever involved (de-risks research V5). See Open questions — Render's native runtime is a lower-risk alternative to Docker for exactly this layout reason, and is yours to choose at approval.The blueprint — root
render.yaml(spec R1–R3, R6–R9). One committed file, two services, both in Frankfurt, bothautoDeployTrigger: checksPass(deploy only after this commit's GitHub Actions CI is green — research V3; no new workflow):web— a Static Site buildingapps/web(pnpm --filter @gw2priory/web build→apps/web/dist), with two ordered routes: first arewriteofsource: /api/*→destination: https://gw2priory-api.onrender.com/:splat(server-side proxy,:splatstrips/api— research V1/V2), then the SPA fallbackrewriteof/*→/index.html. Real assets win over both (research).buildFilter.paths:apps/web/**, plus the shared inputspnpm-lock.yaml,package.json(spec R8).api— a Docker web service (dockerfilePath: apps/api/Dockerfile,dockerContext: .),plan: free(R9),healthCheckPath: /health(R6),buildFilter.paths:apps/api/**,packages/**,pnpm-lock.yaml,package.json(R8).
The structural guard —
tests/deploy/render-blueprint.test.ts(spec R12, SC7/SC8). A Vitest test that parsesrender.yamlwith the existingyamldep (no new dependency) and asserts its shape, mirroringtests/ci/workflow.test.tsoverci.yml: exactly the two services; the API's Docker runtime,healthCheckPath, and build filter; the web's static publish and build filter; the two routes present and in order with the prefix-stripping:splatdestination first and the/*→/index.htmlfallback second;autoDeployTrigger: checksPasson both; regionfrankfurton both. A drifted or reordered blueprint fails loudly. A newtests/deploy/sits besidetests/ci/.The capture —
docs/architecture/deploy.md+ aCLAUDE.mdReference link (spec R10/R11). Records the deploy shape; the one-time human step (create the Render account, connect the GitHub repo via Render's integration, first Blueprint sync) and why it cannot be a committed artifact — the same not-in-repo tensionci.mdcaptured for branch protection; the standing gotchas F1 (Swagger/api-docsis not proxied) and F2 (the hardcoded rewrite destination must match the API service's realonrender.comhostname — verify on first sync, fixrender.yamlif Render suffixed the name); and a dated verification log for the observational criteria, mirroringci.md's log.Traceability fill-in —
specs/014-render-deploy/spec.md. Populate the traceability table's Test/verification column with the names below (done during implementation, per the template).
The first real deploy and its dated observations (SC1–SC6) are performed by the human after step 5's doc is in place (R10/R11) — the platform-side half that no repo test can reach.
Architecture
render.yaml ──deploys──► web (Static Site, CDN, Frankfurt) api (Docker web svc, Frankfurt, free)
│ ├─ /api/* ─rewrite :splat (strip)─────► node:22 node apps/api/dist/main.js
│ │ server-side proxy, no CORS bind 0.0.0.0:$PORT · /health check
│ ├─ /assets/* → real files (rule skipped) imports @gw2priory/* .ts at runtime
│ └─ /* → /index.html (React Router) via preserved pnpm symlinks
│ guarded by
▼
tests/deploy/render-blueprint.test.ts parses render.yaml (yaml): 2 services, ordered routes,
│ health path, checksPass, build filters, region (SC7/SC8)
│ documented by
▼
docs/architecture/deploy.md deploy shape · one-time human Render↔GitHub step (R10) · F1/F2 gotchas ·
dated verification log for SC1–SC6 (R11) ◄─ linked from CLAUDE.md
trigger: merge → main → GitHub Actions CI (spec 003) → green → Render checksPass → deploy changed svc(s)Direction: render.yaml is read by Render (to deploy) and by the test (to assert shape); the API image imports the workspace packages at runtime; the doc records decisions nothing depends on at runtime. No new GitHub Actions workflow — CI stays as spec 003 built it and becomes the deploy gate.
Tech stack
- Render — the platform (brainstorming decision,
research.md). One committed Blueprint,render.yaml, at the repo root. Services: a Static Site (apps/web) and a Docker web service (apps/api), both regionfrankfurt, bothautoDeployTrigger: checksPass. - Docker base
node:22(a concrete ≥ 22.18 tag, pinned at implementation) — ≥ 22.18 is required for unflagged TypeScript type-stripping of the source.tsworkspace packages at runtime (research V6,monorepo.md; alsorequire(esm)support).corepackprovides pnpm frompackageManager. yaml(^2.9.0) — existing devDependency (added by spec 003 forci.yml); the blueprint test reuses it. No new npm dependency is added by this plan.- Vitest 4.1.10 — the structural test runs under the existing
nodeproject (tests/**/*.test.tsis already ininclude). (Existing.) - No runtime library changes. The API and web build with their existing scripts (
swc src -d dist;panda codegen && vite build); onlymain.ts's port binding changes.
Global Constraints
Copied verbatim from the architecture docs. Every task inherits these; do not summarise or reword them — a test asserts they appear here unchanged.
From docs/architecture/typescript.md:
- No
any. Not in app code, not in tests. Useunknownplus narrowing, or model the type properly. If a third-party type forces it, isolate it behind one typed adapter and comment why. - No non-null assertions (
!) to silence the compiler. - No
@ts-expect-errorwithout a comment explaining what is expected and when it can be removed. - Validate everything crossing a boundary (GW2 API responses, HTTP input) at runtime, not just at the type level.
- Prefer pure functions for domain logic. The optimizer must be testable without a network or a database.
- Match the style of surrounding code. No new dependency without justification in the spec or plan.
moduleResolution: "node"andbaseUrlare removed in TS 7. Use"nodenext"(or"bundler"for Vite/bundler-resolved code) pluspaths: { "*": ["./*"] }in place ofbaseUrl.- TypeScript's default
libincludes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), ordocument/windowresolve everywhere, silently defeating profile isolation. A profile that needs DOM adds"DOM","DOM.Iterable"on top of that floor. experimentalDecoratorsandemitDecoratorMetadataare supported for typecheck only —tsc --noEmitaccepts decorator syntax and resolves metadata types, but--noEmitnever emitsdesign:paramtypesat runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; seestack.md's api build model.
From docs/architecture/stack.md:
- Monorepo, pnpm workspaces.
apps/api— NestJS (TypeScript).apps/web— React (TypeScript).packages/*— shared code (domain types, the curated Mystic Forge dataset) when sharing is real, not speculative.- Postgres for persistence. In-memory cache for the MVP — no Redis until the caching story earns it.
- Vitest everywhere, both apps.
- Deploy: managed PaaS (Fly.io / Railway). CI: GitHub Actions — lint + typecheck + test + build.
- Static data (items, station recipes) is immutable: cache hard.
- Prices are volatile: short TTL, recomputed live.
- GW2 API rate limit: per-IP token bucket, 300 burst, refill 5/sec,
429on overflow. Batch up to 200 ids per?ids=call. - All GW2 API access goes through the client that budgets this. Never call the GW2 API directly from a service.
- API keys are user secrets: encrypted at rest, never logged, never returned to the client.
From CLAUDE.md: typecheck clean, tests pass, every acceptance scenario and success criterion covered by a test whose name traces to it, no unexplained escape hatches, the human reviews the diff.
File Structure
Exact paths, and what each file is responsible for. A path here is a commitment; a task that touches a file not listed is a signal the plan missed something.
| Path | Change | Responsibility |
|---|---|---|
apps/api/src/main.ts | modified | Bind `Number(process.env.PORT) |
apps/api/Dockerfile | new | Build+run the API from the monorepo root context on node:22 (≥22.18); preserve the pnpm workspace layout so @gw2priory/* .ts packages resolve at runtime; pnpm install --frozen-lockfile → pnpm --filter @gw2priory/api build → CMD node apps/api/dist/main.js (R2). |
.dockerignore | new | Keep the build context small and force a clean in-container install: exclude **/node_modules, **/dist, .git, .claude, specs, docs, VitePress output. |
render.yaml | new | The Blueprint: two services (static web, docker api), Frankfurt, checksPass, the two ordered web routes (:splat /api/* proxy then /*→/index.html), healthCheckPath: /health, per-service buildFilter (R1–R3, R6–R9). |
tests/deploy/render-blueprint.test.ts | new | Parse render.yaml (yaml); assert the two services, ordered routes with the prefix-stripping destination, health path, checksPass, build filters, region (R12, SC7, SC8, P2 #3, P3 #1/#2). |
docs/architecture/deploy.md | new | Deploy shape; the one-time human Render↔GitHub step and why it is not a committed artifact (R10); F1 (/api-docs not proxied) and F2 (hardcoded destination must match the real API URL); dated verification log for SC1–SC6 (R11). |
CLAUDE.md | modified | Reference section: add a link to docs/architecture/deploy.md (matching how ci.md is linked). |
specs/014-render-deploy/spec.md | modified | Fill the traceability table's Test/verification column (during implementation, per the template). |
Not changed, and why: the app build scripts already exist (apps/api swc, apps/web panda+vite) and are called as-is; openapi.json and the generated client are committed, so the web build needs no codegen at deploy; ci.yml is untouched (it becomes the deploy gate via checksPass, no new workflow).
Data & contracts
No runtime types or schemas change. Two "contracts" matter:
- The routing contract (browser → web origin → API), unchanged from dev: browser calls
/api/health; the static-site rewrite proxies it, stripping/api, to the API's/health. The API keeps serving unprefixed paths andopenapi.jsonis untouched (spec R4). - The blueprint's parsed shape the test asserts on:
services length == 2
web: type/runtime static · staticPublishPath 'apps/web/dist' · region 'frankfurt'
autoDeployTrigger 'checksPass' · buildFilter.paths ⊇ ['apps/web/**','pnpm-lock.yaml']
routes[0] { type: 'rewrite', source: '/api/*', destination endsWith '/:splat', includes the api host }
routes[1] { type: 'rewrite', source: '/*', destination: '/index.html' } # order asserted
api: type web · runtime 'docker' · dockerfilePath 'apps/api/Dockerfile' · region 'frankfurt'
plan 'free' · healthCheckPath '/health' · autoDeployTrigger 'checksPass'
buildFilter.paths ⊇ ['apps/api/**','packages/**','pnpm-lock.yaml']Test strategy
Each acceptance scenario and success criterion maps to a named test or a dated manual verification. By kind:
- Structural, automated —
tests/deploy/render-blueprint.test.ts. Parsesrender.yamlonce and asserts the shape in Data & contracts: two services; the API's docker runtime, health path, build filter; the web's static publish and build filter; the two routes in order with the prefix-stripping:splatdestination first;checksPasson both;frankfurton both. Covers SC7, SC8, P2 #3, P3 #1, P3 #2. A removed/reordered route or a flipped trigger fails the relevant assertion and names it — the positive assertions are the guard (no separate negative fixture), matchingtests/ci/workflow.test.ts. - Build-time smoke, local — the Dockerfile's own deliverable test.
docker build -f apps/api/Dockerfile .thendocker run -e PORT=8080 -p 8080:8080 <img>thencurl localhost:8080/health→200. This proves the runtime.ts-package resolution inside the container (research V5) before Render is involved. It is a manual/local gate recorded indeploy.md, not a CI test (CI does not build images). - Observational — dated manual verification in
docs/architecture/deploy.md. SC1 (web URL renders), SC2 (thecurl -I <web>/api/health→ 200 same-origin, prefix stripped — the R11 proxy-not-redirect proof), SC3 (API/health200 at its own URL), SC4 (deep-link reload serves the app), SC5 (green commit deploys / red-or-pending does not), SC6 (one-app change redeploys only that app) are provable only on the real Render deploy. Each is recorded with the date observed; the traceability table cites that record. Pretending a repo test proves a live proxy would be a lie (spec R11). - Non-regression. Existing suites stay green;
typecheckstill passes with the one-linemain.tschange; thedocs/superpowers/file count stays zero (SC9) — an existing invariant this plan does not touch.
Deliberately not tested in-repo: the on-Render runtime behaviours (a live proxied 200, a build on Render's builders, a gated deploy) — the spec makes these observational for exactly this reason; and image size/cold-start latency (noted in deploy.md, not gated).
Traceability the implementation will fill into spec.md:
| Criterion | Test / verification |
|---|---|
| P1 #1–#5 | manual — docs/architecture/deploy.md verification log (URL, asset, proxy curl, API /health, deep-link) |
| P2 #1, #2 | manual — deploy.md log: green deploys / red-or-pending does not |
| P2 #3, P3 #1, #2 | tests/deploy/render-blueprint.test.ts — build filters |
| SC1–SC6 | manual — deploy.md verification log (SC2 = the proxy curl) |
| SC7, SC8 | tests/deploy/render-blueprint.test.ts — services, ordered routes, health, trigger, filters, no new workflow |
| SC9 | existing repo-invariants / spec-001 invariants |
| SC10 | this table complete + tests/deploy/render-blueprint.test.ts names exist |
Alternatives considered
- Render native Node runtime instead of Docker (spec R2) — genuinely competitive, and lower-risk for this repo: native runs
pnpm install+ a build command in the cloned repo, so the pnpm symlink layout that makes the.tsworkspace packages resolve at runtime is preserved automatically, with Node pinned viaNODE_VERSION. Docker buys a fully-pinned, portable image but makes us responsible for preserving that layout (and forbidspnpm deploy). The spec chose Docker; this is flagged in Open questions for the human to confirm or switch at approval — a one-service change either way. - A Static Site rewrite vs. an nginx/Caddy proxy container — the rewrite (chosen) is a free, always-on CDN with a server-side proxy and needs no container; an nginx web service would add a second container and cost. Rejected (research V1 confirms the rewrite is a true proxy).
pnpm deploy --filter @gw2priory/apito slim the image — rejected: it flattens workspace packages intonode_modulesas real dirs, moving their realpath insidenode_modules, where Node refuses to strip.tstypes (monorepo.mdF12) — it would break the runtime import ofnetSellPriceet al.- Set a Nest global
/apiprefix and a pass-through rewrite — rejected: it would changeopenapi.jsonand diverge from the dev proxy;:splatprefix-stripping keeps the contract identical (research V2). - A GitHub Actions deploy job (CLI) instead of
checksPass— rejected:checksPassgates on the existing CI with zero new workflow (research V3); an Actions job would duplicate the trigger. - A paid always-on API from day one — rejected: the free tier starts at $0 and cold starts only affect the first call after idle; flipping
plan: free→ a paid tier is one line (spec Out of scope).
Risks
- The runtime
.ts-package resolution inside the Docker image (research V5). The API imports runtime values from source-.tsworkspace packages; this works only with the pnpm symlink layout intact and Node ≥ 22.18. Mitigation: the Dockerfile task's own deliverable is a localdocker build+run+curl /healthproving it before Render; the plan forbids thepnpm deployde-symlink trap; and native runtime (Open questions) removes the risk entirely if chosen. - F2 — the hardcoded rewrite destination must match the API's real
onrender.comURL. Route destinations can't reference services dynamically (research V2 caveat). Mitigation: name the API servicegw2priory-api; verify the actual URL on first sync and fixrender.yamlif Render suffixed it; documented indeploy.mdas a first-sync check. - The one-time Render↔GitHub connection is a dashboard step, not a committed artifact (R10).Mitigation: documented in
deploy.mdwith the reason, mirroringci.md's branch-protection note. - Free-tier API cold start (~idle spin-down). Mitigation: accepted (spec R9); the SPA is always instant (CDN); flipping to a paid tier is one line. Recorded, not gated.
checksPassneeds the commit to report checks. Ourpush:mainCI guarantees they exist (research V3 caveat); if CI is ever removed the deploy would stop gating — noted indeploy.md.
Open questions
- Docker (spec R2) vs. Render native runtime — resolved: Docker, decided by the human 2026-08-13, confirming approved spec R2 (no amendment). Discovery had surfaced native as the lower-risk choice for the source-
.tspackage model, but Docker's portability and full pinning won; the plan's Dockerfile task carries the mitigations (preserve the pnpm symlink layout, forbidpnpm deploy, prove with a localdocker build+run+curl /healthbefore Render). - The exact Render static-site blueprint key (
type: staticvstype: web+runtime: static, andstaticPublishPathvspublishPath) — pinned against Render's current blueprint spec at implementation and confirmed on first sync; the structural test asserts whichever the schema uses. Reality answers; not blocking. - Nothing left open by
research.md: V1–V6 are confirmed on paper with residuals the first deploy finalises (SC1–SC6 observational).