Svelte Staging Cutover TODO
Goal: replace the legacy React apps (tracker-frontend, tracker-admin) with
the new SvelteKit apps (tracker-frontend-svelte, tracker-admin-svelte) in
staging, then eventually production. Follows on from the
admin/frontend shared-code extraction,
which is fully done — both apps now share packages/tracker-shared.
Tracked here the same way: discovery first, then a phased plan, updated as each phase lands.
Status as of 2026-07-07: staging is live on the Svelte apps, and the old React apps are fully decommissioned (Phases 0-7 all done).
Discovery (2026-07-07)
- The Svelte apps' dev containers are currently unbuildable from a clean
checkout.
compose.ymlpointsadmin-svelte-dev/frontend-svelte-devatbuild/dev/Dockerfilerelative to each app's own directory, but neither file exists on disk — confirmed withdocker compose build admin-svelte-dev, which fails withcould not find .../tracker-admin-svelte/build/dev: no such file or directory. The containers currently running in this environment are using a previously cached local image, not today's compose config. This is a real, pre-existing bug independent of staging — anyone doing a fresh clone right now cannot start these dev containers. - Root cause of why it's easy to lose: each app's
.gitignorehas a barebuildentry, meant to ignore SvelteKit's own build output (adapter-staticwrites there). That pattern also swallows abuild/dev/Dockerfileif one is ever placed there, so it can silently never get committed. The fix should use a path that doesn't collide with the gitignored output dir — e.g.docker/dev.Dockerfile— notbuild/dev/Dockerfileagain. -
The old React apps' equivalent files (
tracker-frontend/build/dev/Dockerfile,tracker-admin/build/dev/Dockerfile) are the right reference pattern: plainnode:20-alpine, anentrypoint.sh, port 3000, healthcheck. -
No production Dockerfile exists for either Svelte app.
docker-bake.hcl'sfrontend/admintargets currently build the old./tracker-frontendand./tracker-admin(CRA/Vite, non-SvelteKit) directories straight into thetracker-frontend/tracker-adminECR repos that staging's ECS services pull by tag. Nothing today buildstracker-frontend-svelteortracker-admin-sveltefor production. -
The new apps need a repo-root build context, unlike the old ones. Both depend on the
packages/tracker-sharednpm workspace, so a production Dockerfile can't usecontext: ./tracker-frontend-svelte— it needscontext: .(same reasoning as the Phase 0 dev-compose mount change:npm installmust run somewhere that can see the workspace root), then build only the target app inside that context. -
Runtime API proxying carries over unchanged. Both new apps call the API via a relative path —
API_BASE_URL = import.meta.env.VITE_API_URL ?? "/api/v1"inpackages/tracker-shared/src/api/client.ts, and both.env/.env.examplesetVITE_API_URL=/api/v1. The client bundle never needs to know the absolute backend URL, so nginx's existinglocation /api/ { proxy_pass $api_upstream; }+sed s/__API_URL__/$API_URL/(substituted from the container'sAPI_URLenv var at container start) pattern still works as-is. -
New wrinkle: the SSO feature flags need a build-time env var, not a runtime one.
/login's+page.server.tsin both apps reads$env/dynamic/private(GOOGLE_CLIENT_ID/MICROSOFT_CLIENT_ID) to decide whether to show SSO buttons. Underadapter-staticthere is no server at runtime, so thatloadfunction only ever executes once, atvite build/prerender time — its result gets baked into the static output at image-build time, not injected at container start the wayAPI_URLis today. - Good news: staging's current ECS task definitions for
frontend/admindon't setGOOGLE_CLIENT_ID/MICROSOFT_CLIENT_IDat all (the old React apps never had SSO), so leaving them unset for the first cutover is a safe, behavior-preserving default — the SSO buttons just won't render, matching current staging exactly. -
Wiring real SSO into staging is a separate follow-up that needs a build-time secret story (a Docker build secret, not an ECS runtime env var) — out of scope for this cutover.
-
Admin's nginx config is not a simple copy-paste.
tracker-admin/nginx.confhas ~10 hardcodedlocation =blocks working around specific endpoints (/api/v1/auth/login/json,/api/v1/users/me,/api/v1/trackers/status-counts, a production-runs regex,/static/) plus a fixedHost tracker.glimpse.technologyheader override and an@handle_redirectblock for 301/302/307 responses.tracker-frontend/nginx.confis much simpler — one genericlocation /api/. Need to determine whether the SvelteKit admin app still needs all of admin's special-casing (it might not, if its fetch behavior around trailing slashes differs from the old CRA app) before assuming a straight carry-forward is correct. -
No Terraform/ECR schema changes needed for a like-for-like swap.
module.ecralready definestracker-frontend/tracker-adminrepos, andmodule.ecs(infra/envs/staging/main.tf) references them viaimage_tags.frontend/.admin,container_port = 80, and injectsAPI_URL/API_V1_STR/PROJECT_NAMEat container runtime — same contract the new apps' nginx layer expects. If the new images listen on port 80 and honor the sameAPI_URLsubstitution, they can ship into the exact same ECR repos/ECS services with zero changes toinfra/envs/staging/*.tf— this is purely adocker-bake.hcl+ new Dockerfile change, not an infrastructure change.
Decisions (2026-07-07)
- Replace the old React bake targets outright — no side-by-side legacy build. ECR tags are immutable, so rollback only ever needs the previous tag to still exist there; it doesn't depend on the old Dockerfile still building.
- Audit admin's nginx special-casing against the Svelte app's actual request behavior rather than carrying it forward unquestioned — keep only what's still needed.
- Delete
./tracker-frontend/./tracker-adminonce staging is validated — no need to wait for prod cutover too. Git history is the safety net if anything needs to be pulled back.
Plan
- Phase 0 — Fix the broken dev Dockerfiles (done, 2026-07-07).
Added
docker/dev.Dockerfile+docker/dev-entrypoint.shfor bothtracker-admin-svelteandtracker-frontend-svelte(avoiding the gitignoredbuild/path this time), updatedcompose.yml'sdockerfile:paths to match. The new entrypoint's reinstall check also fixes the "known rough edge" noted in the shared-code TODO doc — it checks the workspace root'snode_modules/.binand all three workspaces'package.jsonfiles, not the app's own (hoisted-away)node_modules. Verified:docker compose buildsucceeds from scratch,docker compose up --force-recreateruns the install check and starts both dev servers cleanly,svelte-checkstill 0 errors/warnings in both apps, both containers200. - Phase 1 — Legacy React dev containers behind a compose profile
(done, 2026-07-07). Added
profiles: [legacy]tofrontend-dev/admin-devincompose.yml. Verified withdocker compose config --services(excluded by default, included with--profile legacy); nothing else in the filedepends_oneither service, so they can't get pulled in accidentally. Already- running legacy containers from before this change keep running as-is — profiles only affect what a futuredocker compose upselects, not containers already started; stop them manually (docker compose stop frontend-dev admin-dev) whenever convenient. - Phase 2 — Production Dockerfiles for both Svelte apps (done,
2026-07-07). Added
docker/prod.Dockerfileto each app: anode:20-alpinebuild stage using the repo root as context (npm cineeds to see all 3 workspacepackage.jsonfiles, then only the target app's +packages/tracker-shared's full source get copied in and built vianpm run build --workspace=<app>), then annginx:alpineserve stage carrying forward each app's existingnginx.confunchanged (copied todocker/nginx.conf) and the same__API_URL__sed-at-container-start pattern as the old apps. - Extended the root.dockerignore(shared by alldocker-bake.hcltargets usingcontext: ".") to allowtracker-admin-svelte/,tracker-frontend-svelte/,packages/, and the rootpackage.json/package-lock.json, while explicitly re-excludingnode_modules,.svelte-kit,build,build-dist,coverage, and — importantly — each app's checked-in dev.env(which contains a realGOOGLE_CLIENT_SECRET). Verified the built image's static output contains no trace of it. -VITE_API_URL/PUBLIC_THEME_STORAGE_KEYare passed as Docker buildARGs (defaulted to each app's.env.examplevalues) instead of reading the dev.env— neither is sensitive, and this avoids the secret-leak footgun entirely. - Deliberate behavior change from the old apps: the new entrypoints always requireAPI_URL(${API_URL:?API_URL is required}), matching frontend's stricter existing pattern rather than admin's silent${API_URL:-http://api:8000}default — since ECS always setsAPI_URLtoday, failing loudly on a misconfiguration is safer than silently serving from a guessed internal address. -GOOGLE_CLIENT_ID/MICROSOFT_CLIENT_IDare intentionally left unset at build time (see Discovery #5) — SSO buttons won't render, matching current staging behavior exactly. - Verified locally end-to-end for both apps:docker buildfrom the repo root succeeds,docker run -e API_URL=...serves200on/and on a deep client-side route (SPA fallback works), the__API_URL__placeholder is correctly substituted into/etc/nginx/conf.d/default.conf, the container fails fast with a clear error whenAPI_URLis unset,HEALTHCHECKreportshealthy, and the built static output has zero references to the dev.env's secret values. - Phase 3 — Repoint
docker-bake.hcl(done, 2026-07-07). Changed thefrontend/admintargets'contextfrom./tracker-frontend/./tracker-adminto.(repo root) anddockerfiletotracker-frontend-svelte/docker/prod.Dockerfile/tracker-admin-svelte/docker/prod.Dockerfile. Image names/tags (tracker-frontend,tracker-admin) are unchanged, so this needs no Terraform/ECR changes — confirms Discovery #7. Verified:docker buildx bake --print frontend adminresolves correctly,docker buildx bake frontend admin(local, no push) builds both, and running the resultingtracker-frontend:sha-bootstrap/tracker-admin:sha-bootstrapimages serves200for both — same result as the standalonedocker buildin Phase 2, just produced through the real bake pipeline this time.api/servicestargets unaffected (docker buildx bake --print api servicesstill resolves to their originalcontext/dockerfile). - Phase 4 — nginx config reconciliation (done, 2026-07-07). Audited
all 7 of admin's
location =/location ~special-case blocks (/api/v1/auth/login/json,/api/v1/auth/refresh-cookie,/api/v1/users/me×2 slash forms,/api/v1/trackers/status-counts×2 slash forms, theproduction-runs/\d+/regex) against the actual backend routes: - Every one of those backend endpoints already stacks two route decorators (e.g.@router.get("/me")and@router.get("/me/")) — the backend serves both trailing-slash forms natively, so FastAPI'sredirect_slashesbehavior never triggers a 307 for any of them. Confirmed empirically withcurlstraight to the dev API container (localhost:8100) for both slash forms of every affected path — identical status codes (401/400/422), no redirects. - Structurally, the special blocks and the genericlocation /api/block do the exact sameproxy_pass $api_upstream;(no path suffix, so nginx forwards the original URI unchanged either way) with the sameHostoverride — the special blocks added nothing the generic block doesn't already do, including implicitAuthorization/Cookieheader forwarding (nginx passes those through by default even without an explicitproxy_set_header). - Removed all 7 redundant blocks fromtracker-admin-svelte/docker/nginx.conf. Kept/static/(the backend really does mountStaticFilesthere, and production-run images do resolve to/static/...URLs — this one is genuinely still needed) and the generic/api/block including its 301/302/307@handle_redirectsafety net (cheap insurance for any other route that doesn't have the dual-decorator treatment). - Verified for real, not just by reading code: rebuilt the admin image, ran it on the compose network against the livedevAPI container, and exercised every affected endpoint (both slash forms) through the simplified nginx config — including a full authenticated flow (real login, bearer-tokenusers/me, cookie- basedrefresh-cookie,status-counts,production-runs/1) — all returned identical results to hitting the backend directly.tracker-frontend-svelte/docker/nginx.confneeded no changes; it was already the simple generic-only version. - Phase 5 —
make staging-build(build + push only, done, 2026-07-07). Ranmake staging-build TARGETS="frontend admin"(scoped to just the two changed targets —api/servicesare untouched, no need to rebuild/repush them). Confirmed valid AWS credentials first (aws sts get-caller-identity), got explicit user go-ahead before running since this is the first step in the whole effort that leaves the local machine and touches real AWS resources, then built + pushed both images to the real staging ECR under tagsha-6aafe23308d8.write_tfvarsmerges rather than overwrites, soinfra/envs/staging/image-tags.auto.tfvars.jsononly changed thefrontend/adminkeys —api/services/anisettestayed on their existing tags. Verified both images actually landed in ECR viaaws ecr describe-images. Noterraform applyhas run — staging is still serving the old React images. That's Phase 6, which needs its own explicit go/no-go. - Phase 6 —
make staging-apply(done, 2026-07-07). Staging is now live on the Svelte apps. Got explicit go-ahead before running anything, per the plan. -terraform planfor staging initially came back with an unrelated 11-add/7-change/11-destroy plan that included replacing the live database EC2 instance — amost_recent = trueAMI data source had drifted to a newer Debian point release, and since AMI is an immutable instance attribute, that forces a full replacement which cascades into every other service's task definition (they read the DB's private IP/DNS as env vars). Caught this before applying anything; fixed by pinning the AMI (separate PR — seeinfra/modules/database). Before concluding a deliberate replacement would even be safe, verified viaaws ssmagainst the live instance that thetracker-opsAnsible bootstrap role is genuinely idempotent (conditionalmkfs, UUID-keyed fstab, conditionalpg_createcluster) — a real future AMI bump would correctly preserve the data. A fresh manual EBS snapshot (snap-039ed8a066e93a42e) was also taken before any of this as an extra safety net. - With that fixed,terraform plancame back clean: only thefrontend/adminECS services + task definitions changed (new image tag), zero database or unrelated-service changes. - Applied. Both ECS services rolled out the new task definitions and stabilized (old tasks drained, new ones healthy). Confirmed the database instance's ID/launch time/AMI are unchanged post-apply — it was never touched. - Verified against the real staging URLs, not just ECS status:https://tracker-admin.staging.glimpse.technology/andhttps://tracker.staging.glimpse.technology/both return200and serve genuine SvelteKit output (_app/immutableasset paths, not the old CRAstatic/js/main.*), and/api/v1/users/methrough the trimmed nginx config returns a real401(not a502or redirect) — the same proxy audit from Phase 4 holds up in production, not just against the local dev backend. - Post-Phase-6 incident:
/loginwas broken on both apps (done, 2026-07-07). Reported as "500 errors on both admin panel and frontend" shortly after Phase 6 shipped. Root cause was two compounding bugs specific toadapter-static's fallback SPA mode, neither caught by localvite devtesting since dev has a real server that masks them: 1./loginis the only route with a+page.server.ts(reads$env/dynamic/privatefor SSO flags). Nothing links to it with a crawlable<a href>(the redirect there is client-side JS), so SvelteKit's prerender crawl never visited it — nologin.html, nologin/__data.jsonever got generated. Any client-side navigation that re-fetches page data hit nginx's genericindex.htmlfallback instead of real JSON, and the browser threwSyntaxError: Unexpected token '<'trying to parse it. Fixed withexport const prerender = trueon both apps'/login+page.server.ts. 2. Fixing #1 exposed a second bug: nginx'stry_files $uri $uri/ /index.html(copy-pasted from the old CRA app, which never hit this since CRA has no per-route output files at all) matched$uri/against the now-existinglogin/directory (which only exists because of the__data.jsonsidecar) and 301-redirected/login→/login/, which then 403'd since there's noindex.htmlinside that directory. SvelteKit's adapter-static emits flatroute.htmlfiles, notroute/index.html— the fallback chain needed$uri.htmlbefore$uri/. - Verified locally against the real production images before redeploying:/loginfull-page load, the/login/__data.jsonsidecar, root/, and a genuinely unprerendered dynamic route all behave correctly for both apps. - Redeploying hit the exact same AMI-drift trap as Phase 6 — the AMI-pin PR hadn't merged into this branch yet when the first redeploy attempt was planned, so the plan again wanted to replace the database. Caught before applying (same as before), waited for that PR to actually merge, then re-planned clean and applied. Verified fixed against the real staging URLs afterward; database instance ID/launch-time confirmed unchanged again. - Phase 6b (unplanned): the API was 21 commits stale, causing
"Not Found" dashboard widgets (done, 2026-07-07). Phase 6 only
ever touched the
frontend/adminbake targets and images —apiwas never rebuilt as part of this cutover, and it turned out to be sitting on a commit from before the admin dashboard's/dashboard/stats//dashboard/movement-calendarendpoints (and 2 real Alembic migrations: additiveuserscolumns for auth provider, and a newpassword_reset_tokenstable) even existed. The new frontend code calling those endpoints got real404s, surfacing in the UI as "Not Found" widgets. - Rebuilt and pushedapifrom currentHEAD(make staging-build TARGETS="api"). - Caught a near-miss before planning: this branch'simage-tags.auto.tfvars.jsonstill had the pre-hotfixfrontend/admintags (since the Phase-6-incident tag bump was sitting in this same still-unmerged PR, but a different, now-abandoned branch had rebuiltapifrom a base that predated it) — applying as-is would have rolled admin/frontend back to the broken pre-hotfix version while fixing the API. Fixed the tfvars file to reflect what was actually live before planning anything. -terraform planthen came back clean: onlyapi(service) and themigrations/seed-usersone-off task definitions (which share theapiimage) changed — no drift anywhere else. Applied. - Ran the updatedmigrationstask definition viaaws ecs run-task(both Alembic migrations applied, exit code 0) before considering the rollout complete, since the new API code expects that schema. - Verified:apiECS service stabilized on the new image;/api/v1/dashboard/statsnow returns401 Not authenticatedinstead of404 Not Foundwhen hit without credentials — confirming the route now genuinely exists on the deployed API. - Correction:serviceswas not actually left safely untouched — see Phase 6c below. Checking only commits touching theservices/directory missed thatservices/docker/Dockerfilebundles the exact sameapp//alembic/code asapi, so it was equally 21 commits stale. - Phase 6c: the worker services (
tracker-servicesimage) were also stale, same root cause as Phase 6b (done, 2026-07-07). Surfaced when manually runningalembic upgrade headinside atracker-fetcher-2worker container failed withCan't locate revision identified by 'd0e1f2a3b4c5'— not a real migration problem (the database was already correctly migrated in Phase 6b), just evidence that the running worker's bundled Alembic files predated that revision. Confirmedservices/docker/DockerfileCOPYsapp/,scripts/,services/, andalembic/— the same backend codeapibundles — so all 4 worker services (tracker-fetcher-2,unified-geofence,notification-service,materialized-view-service) were equally stale, not just the one trivialservices/-specific commit checked in Phase 6b. - Rebuilt and pushedservicesfrom currentHEAD(make staging-build TARGETS="services"). -terraform plancame back clean: only the 4 worker ECS services + their task definitions changed — no drift anywhere else. Applied. - No migrations needed (schema was already current from Phase 6b); workers just needed the updated code. - Verified: all 4 worker tasks running the newtracker-services:sha-18315de016e0image,running == desiredfor each service. - Phase 7 — Decommission the old React apps (done, 2026-07-07).
Deleted
./tracker-frontendand./tracker-adminentirely (both tracked files and untracked build artifacts/node_modules-- several files were root-owned from the old dev containers' bind mounts, needed a throwaway root container to remove). The bake targets namedfrontend/adminneeded no changes -- Phase 3 already repointed them at the Svelte apps'prod.Dockerfiles, they just happened to share the same image names. Also removed thefrontend-dev/admin-devservices and the now-fully-unusedlegacycompose profile fromcompose.yml, stopped and removed their running containers, and deletedrun_frontend_tests.sh(dead script, hard-coded to the deleted directories, not called from anywhere).
Notes
- This doc intentionally does not touch production — Phase 6 is staging
only. Production promotion (
make prod-promote) is a separate, later decision once staging has been used/validated for a while. - See Deployment Pipeline for the general build/tag/push/apply mechanics this plan hooks into.