Public pages separation plan¶
Goal¶
Separate the public marketing site from the clinical application so they are independently deployable on different domains:
| Domain | Content | Deployment |
|---|---|---|
quill-medical.com / www.quill-medical.com |
Public landing site (marketing, features, about) | GCS bucket behind staging LB |
staging.quill-medical.com |
React app at / (no /app/ prefix) |
Cloud Run (staging) |
teaching.quill-medical.com |
React app at / (no /app/ prefix) |
Cloud Run (teaching) |
app.quill-medical.com |
React app at / (no /app/ prefix) |
Cloud Run (production) |
Current state¶
The frontend Docker container serves both the public pages and the React app via a single Caddy instance:
/→ public pages from/srv/public_pages(built bypublic_pages/Yarn workspace)/app/→ React SPA from/srv/app(Caddy strips/appprefix, Vitebase: "/app/")/api/*→ routed by the GCP load balancer to the backend Cloud Run service
The React app has /app/ baked in at three levels:
- Vite —
base: "/app/"invite.config.ts - React Router —
basenamederived fromimport.meta.env.BASE_URL - Caddy —
handle /app/*withuri strip_prefix /app
The public_pages/ Vite build currently only outputs index.html — other pages (features.html, not-found.html) are missing from the production build because rollupOptions.input is not configured.
Desired state¶
Public site (quill-medical.com)¶
- Served from a GCS bucket behind the staging load balancer (same as the current landing page setup)
- Built from the
public_pages/workspace — same shared components, same Mantine theme - Fully static HTML/CSS/JS — no server needed
- Independently deployable (updating marketing content doesn't touch the clinical app)
Clinical app (subdomains)¶
- React app served at
/instead of/app/ - The frontend Docker container only contains the React app — no public pages
- Caddy config simplifies to a single SPA handler
/api/*routing unchanged (handled by GCP load balancer)
Plan¶
Phase 1 — Fix public pages build and share theme¶
Goal: Make the current setup work properly before separating.
- Fix Vite multi-page build — add
rollupOptions.inputtopublic_pages/vite.config.tslisting all HTML entry points (index.html,features.html,not-found.html) - Share Mantine theme — extract the app's theme config to a shared file (e.g.
frontend/src/theme.ts), import it in both the main app'sMantineProviderand each public page'sMantineProvider - Create a
PublicLayoutcomponent — lightweight wrapper with consistent header (logo + "Sign in" link) and footer, used by all public pages. Add stories and tests - Verify — build and test that all public pages work locally (
yarn workspace public-pages build && yarn workspace public-pages preview)
Phase 2 — Move the React app from /app/ to /¶
Goal: Remove the /app/ prefix from the clinical application.
- Vite config — change
basefrom"/app/"to"/" - Caddy prod config — remove the
/app/*handler withuri strip_prefix, replace with a simple root SPA handler at/:
handle {
root * /srv/app
try_files {path} /index.html
file_server
}
- Caddy dev config — change
handle /app*tohandlefor the SPA, remove the public pages proxy (port 5174) - Remove public pages from Docker build — remove
yarn workspace public-pages buildfrom the DockerfileRUNstep, remove theCOPY --from=build /app/dist/public_pages /srv/public_pageslayer - Update health check — change
HEALTHCHECKfrom/app/to/ - Update all internal links — any hardcoded
/app/references (API client login redirect, public pages "Sign in" button, etc.) - Update
compose.dev.yml— dev server ports and routing - Test — verify SPA routing, auth redirects, API calls, deep linking all work at
/
Phase 3 — Deploy public pages to GCS¶
Goal: Serve the public site from a GCS bucket, independently from the app.
- Create a GCS bucket in the staging project (e.g.
quill-medical-public-site) — Terraform managed - CI workflow — add a job to the docs or a new
public-site.ymlworkflow that: - Builds
public_pages/with Vite - Uploads the
dist/public_pages/output to the GCS bucket viagsutil rsync - Only triggers on changes to
frontend/public_pages/**orfrontend/src/components/**(shared components) orfrontend/src/theme.ts - Load balancer routing — the staging LB already routes
quill-medical.comto a backend bucket. Update the bucket source to point to the newquill-medical-public-sitebucket - Configure
www.quill-medical.com— add a DNS CNAME or A record pointing to the staging LB, add the domain to the SSL certificate - 404 handling — configure the GCS bucket's
notFoundPageto servenot-found.html - Cache headers — set appropriate
Cache-Controlmetadata on GCS objects (hashed assets: 1 year, HTML: no-cache)
Phase 4 — Update DNS and remove old routing¶
Goal: Clean up the old combined routing.
- Remove public pages from the frontend Caddy config — the
handleblock serving/srv/public_pagesis no longer needed - Update the prod Caddyfile — only serves the SPA at
/and the health check - Remove the
public_pagesworkspace from the frontend Docker build — it's now built and deployed separately - Update
.github/copilot-instructions.md— document the new architecture, update path aliases and routing notes - Update
docs/docs/infrastructure/gcp.md— document the public site GCS bucket and routing
Architecture after completion¶
quill-medical.com (www) staging/teaching/app.quill-medical.com
│ │
GCP Load Balancer GCP Load Balancer
│ ┌───────┴───────┐
GCS Bucket │ │
(static site) /* → Frontend /api/* → Backend
Cloud Run Cloud Run
(Caddy + SPA) (FastAPI)
What stays shared¶
- Mantine theme — single source of truth at
frontend/src/theme.ts - UI components — public pages import from
frontend/src/components/(Storybook catalogue) - Yarn workspace —
public_pages/remains a workspace infrontend/package.jsonfor shared dependencies and path aliases
What becomes independent¶
- Build — public pages have their own CI job, deploy to GCS
- Deploy — marketing site updates don't trigger Cloud Run deploys
- Routing — no more
/app/prefix; each domain serves one thing
Risks and mitigations¶
| Risk | Mitigation |
|---|---|
| Shared component changes breaking public pages | Public pages CI triggers on frontend/src/components/** changes |
| GCS bucket publicly accessible | Bucket is behind the LB, not directly exposed. Use IAM to restrict direct access |
| Theme drift between app and public site | Single theme.ts file imported by both, enforced by shared workspace |
Files affected¶
| File | Change |
|---|---|
frontend/vite.config.ts |
Change base from "/app/" to "/" |
frontend/public_pages/vite.config.ts |
Add rollupOptions.input for all pages |
frontend/src/main.tsx |
Basename logic simplifies (always /) |
frontend/src/lib/api.ts |
Login redirect path simplifies |
frontend/Dockerfile |
Remove public_pages build and copy |
caddy/prod/Caddyfile |
Remove /app/* handler, simplify to SPA at / |
caddy/dev/Caddyfile |
Remove public_pages proxy, SPA at / |
compose.dev.yml |
Remove public_pages dev server port |
frontend/public_pages/src/pages/index.tsx |
"Sign in" link changes from /app/ to subdomain URL |
infra/main.tf |
Add GCS bucket for public site |
infra/modules/load-balancer/main.tf |
Update backend bucket config |
.github/workflows/public-site.yml |
New workflow for public site deploys |
.github/workflows/deploy-staging-teaching.yml |
No longer builds public pages |
.github/copilot-instructions.md |
Update routing and architecture docs |