Skip to content

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.

  1. 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 injects PORT (default 10000) and fails port detection unless the process binds it (research V6); 3000 stays as the local-dev fallback so pnpm dev and the Vite proxy target (vite.config.ts → localhost:3000) are untouched. This is the only application-code change in the spec.

  2. 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 outside node_modules, letting Node ≥ 22.18 strip the .ts on the fly (measured, spec 008). The image therefore must preserve the pnpm workspace layout and run on node:22 (≥ 22.18) — it must not use pnpm deploy/a flattened copy that de-symlinks the packages into node_modules (that reintroduces the type-strip failure of monorepo.md's F12). Shape: install with a frozen lockfile (with @swc/core's build allowed, per pnpm-workspace.yaml), pnpm --filter @gw2priory/api build, then CMD ["node", "apps/api/dist/main.js"] from a filesystem where the workspace symlinks are intact. A local docker 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.

  3. The blueprint — root render.yaml (spec R1–R3, R6–R9). One committed file, two services, both in Frankfurt, both autoDeployTrigger: checksPass (deploy only after this commit's GitHub Actions CI is green — research V3; no new workflow):

    • web — a Static Site building apps/web (pnpm --filter @gw2priory/web build → apps/web/dist), with two ordered routes: first a rewrite of source: /api/* → destination: https://gw2priory-api.onrender.com/:splat (server-side proxy, :splat strips /api — research V1/V2), then the SPA fallback rewrite of /* → /index.html. Real assets win over both (research). buildFilter.paths: apps/web/**, plus the shared inputs pnpm-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).
  4. The structural guard — tests/deploy/render-blueprint.test.ts (spec R12, SC7/SC8). A Vitest test that parses render.yaml with the existing yaml dep (no new dependency) and asserts its shape, mirroring tests/ci/workflow.test.ts over ci.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 :splat destination first and the /*→/index.html fallback second; autoDeployTrigger: checksPass on both; region frankfurt on both. A drifted or reordered blueprint fails loudly. A new tests/deploy/ sits beside tests/ci/.

  5. The capture — docs/architecture/deploy.md + a CLAUDE.md Reference 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 tension ci.md captured for branch protection; the standing gotchas F1 (Swagger /api-docs is not proxied) and F2 (the hardcoded rewrite destination must match the API service's real onrender.com hostname — verify on first sync, fix render.yaml if Render suffixed the name); and a dated verification log for the observational criteria, mirroring ci.md's log.

  6. 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 region frankfurt, both autoDeployTrigger: 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 .ts workspace packages at runtime (research V6, monorepo.md; also require(esm) support). corepack provides pnpm from packageManager.
  • yaml (^2.9.0) — existing devDependency (added by spec 003 for ci.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 node project (tests/**/*.test.ts is already in include). (Existing.)
  • No runtime library changes. The API and web build with their existing scripts (swc src -d dist; panda codegen && vite build); only main.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. Use unknown plus 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-error without 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" and baseUrl are removed in TS 7. Use "nodenext" (or "bundler" for Vite/bundler-resolved code) plus paths: { "*": ["./*"] } in place of baseUrl.
  • TypeScript's default lib includes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), or document/window resolve everywhere, silently defeating profile isolation. A profile that needs DOM adds "DOM", "DOM.Iterable" on top of that floor.
  • experimentalDecorators and emitDecoratorMetadata are supported for typecheck only — tsc --noEmit accepts decorator syntax and resolves metadata types, but --noEmit never emits design:paramtypes at runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; see stack.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, 429 on 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.

PathChangeResponsibility
apps/api/src/main.tsmodifiedBind `Number(process.env.PORT)
apps/api/DockerfilenewBuild+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).
.dockerignorenewKeep the build context small and force a clean in-container install: exclude **/node_modules, **/dist, .git, .claude, specs, docs, VitePress output.
render.yamlnewThe 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.tsnewParse 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.mdnewDeploy 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.mdmodifiedReference section: add a link to docs/architecture/deploy.md (matching how ci.md is linked).
specs/014-render-deploy/spec.mdmodifiedFill 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 and openapi.json is 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. Parses render.yaml once 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 :splat destination first; checksPass on both; frankfurt on 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), matching tests/ci/workflow.test.ts.
  • Build-time smoke, local — the Dockerfile's own deliverable test. docker build -f apps/api/Dockerfile . then docker run -e PORT=8080 -p 8080:8080 <img> then curl 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 in deploy.md, not a CI test (CI does not build images).
  • Observational — dated manual verification in docs/architecture/deploy.md. SC1 (web URL renders), SC2 (the curl -I <web>/api/health → 200 same-origin, prefix stripped — the R11 proxy-not-redirect proof), SC3 (API /health 200 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; typecheck still passes with the one-line main.ts change; the docs/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:

CriterionTest / verification
P1 #1–#5manual — docs/architecture/deploy.md verification log (URL, asset, proxy curl, API /health, deep-link)
P2 #1, #2manual — deploy.md log: green deploys / red-or-pending does not
P2 #3, P3 #1, #2tests/deploy/render-blueprint.test.ts — build filters
SC1–SC6manual — deploy.md verification log (SC2 = the proxy curl)
SC7, SC8tests/deploy/render-blueprint.test.ts — services, ordered routes, health, trigger, filters, no new workflow
SC9existing repo-invariants / spec-001 invariants
SC10this 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 .ts workspace packages resolve at runtime is preserved automatically, with Node pinned via NODE_VERSION. Docker buys a fully-pinned, portable image but makes us responsible for preserving that layout (and forbids pnpm 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/api to slim the image — rejected: it flattens workspace packages into node_modules as real dirs, moving their realpath inside node_modules, where Node refuses to strip .ts types (monorepo.md F12) — it would break the runtime import of netSellPrice et al.
  • Set a Nest global /api prefix and a pass-through rewrite — rejected: it would change openapi.json and diverge from the dev proxy; :splat prefix-stripping keeps the contract identical (research V2).
  • A GitHub Actions deploy job (CLI) instead of checksPass — rejected: checksPass gates 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-.ts workspace packages; this works only with the pnpm symlink layout intact and Node ≥ 22.18. Mitigation: the Dockerfile task's own deliverable is a local docker build+run+curl /health proving it before Render; the plan forbids the pnpm deploy de-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.com URL. Route destinations can't reference services dynamically (research V2 caveat). Mitigation: name the API service gw2priory-api; verify the actual URL on first sync and fix render.yaml if Render suffixed it; documented in deploy.md as a first-sync check.
  • The one-time Render↔GitHub connection is a dashboard step, not a committed artifact (R10).Mitigation: documented in deploy.md with the reason, mirroring ci.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.
  • checksPass needs the commit to report checks. Our push:main CI guarantees they exist (research V3 caveat); if CI is ever removed the deploy would stop gating — noted in deploy.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-.ts package model, but Docker's portability and full pinning won; the plan's Dockerfile task carries the mitigations (preserve the pnpm symlink layout, forbid pnpm deploy, prove with a local docker build+run+curl /health before Render).
  • The exact Render static-site blueprint key (type: static vs type: web + runtime: static, and staticPublishPath vs publishPath) — 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).