Skip to content

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)

  1. The Svelte apps' dev containers are currently unbuildable from a clean checkout. compose.yml points admin-svelte-dev/frontend-svelte-dev at build/dev/Dockerfile relative to each app's own directory, but neither file exists on disk — confirmed with docker compose build admin-svelte-dev, which fails with could 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.
  2. Root cause of why it's easy to lose: each app's .gitignore has a bare build entry, meant to ignore SvelteKit's own build output (adapter-static writes there). That pattern also swallows a build/dev/Dockerfile if 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 — not build/dev/Dockerfile again.
  3. The old React apps' equivalent files (tracker-frontend/build/dev/Dockerfile, tracker-admin/build/dev/Dockerfile) are the right reference pattern: plain node:20-alpine, an entrypoint.sh, port 3000, healthcheck.

  4. No production Dockerfile exists for either Svelte app. docker-bake.hcl's frontend/admin targets currently build the old ./tracker-frontend and ./tracker-admin (CRA/Vite, non-SvelteKit) directories straight into the tracker-frontend/tracker-admin ECR repos that staging's ECS services pull by tag. Nothing today builds tracker-frontend-svelte or tracker-admin-svelte for production.

  5. The new apps need a repo-root build context, unlike the old ones. Both depend on the packages/tracker-shared npm workspace, so a production Dockerfile can't use context: ./tracker-frontend-svelte — it needs context: . (same reasoning as the Phase 0 dev-compose mount change: npm install must run somewhere that can see the workspace root), then build only the target app inside that context.

  6. 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" in packages/tracker-shared/src/api/client.ts, and both .env/.env.example set VITE_API_URL=/api/v1. The client bundle never needs to know the absolute backend URL, so nginx's existing location /api/ { proxy_pass $api_upstream; } + sed s/__API_URL__/$API_URL/ (substituted from the container's API_URL env var at container start) pattern still works as-is.

  7. New wrinkle: the SSO feature flags need a build-time env var, not a runtime one. /login's +page.server.ts in both apps reads $env/dynamic/private (GOOGLE_CLIENT_ID/MICROSOFT_CLIENT_ID) to decide whether to show SSO buttons. Under adapter-static there is no server at runtime, so that load function only ever executes once, at vite build/prerender time — its result gets baked into the static output at image-build time, not injected at container start the way API_URL is today.

  8. Good news: staging's current ECS task definitions for frontend/admin don't set GOOGLE_CLIENT_ID/MICROSOFT_CLIENT_ID at 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.
  9. 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.

  10. Admin's nginx config is not a simple copy-paste. tracker-admin/nginx.conf has ~10 hardcoded location = 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 fixed Host tracker.glimpse.technology header override and an @handle_redirect block for 301/302/307 responses. tracker-frontend/nginx.conf is much simpler — one generic location /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.

  11. No Terraform/ECR schema changes needed for a like-for-like swap. module.ecr already defines tracker-frontend/tracker-admin repos, and module.ecs (infra/envs/staging/main.tf) references them via image_tags.frontend/.admin, container_port = 80, and injects API_URL / API_V1_STR / PROJECT_NAME at container runtime — same contract the new apps' nginx layer expects. If the new images listen on port 80 and honor the same API_URL substitution, they can ship into the exact same ECR repos/ECS services with zero changes to infra/envs/staging/*.tf — this is purely a docker-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-admin once 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.sh for both tracker-admin-svelte and tracker-frontend-svelte (avoiding the gitignored build/ path this time), updated compose.yml's dockerfile: 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's node_modules/.bin and all three workspaces' package.json files, not the app's own (hoisted-away) node_modules. Verified: docker compose build succeeds from scratch, docker compose up --force-recreate runs the install check and starts both dev servers cleanly, svelte-check still 0 errors/warnings in both apps, both containers 200.
  • Phase 1 — Legacy React dev containers behind a compose profile (done, 2026-07-07). Added profiles: [legacy] to frontend-dev/ admin-dev in compose.yml. Verified with docker compose config --services (excluded by default, included with --profile legacy); nothing else in the file depends_on either 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 future docker compose up selects, 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.Dockerfile to each app: a node:20-alpine build stage using the repo root as context (npm ci needs to see all 3 workspace package.json files, then only the target app's + packages/tracker-shared's full source get copied in and built via npm run build --workspace=<app>), then an nginx:alpine serve stage carrying forward each app's existing nginx.conf unchanged (copied to docker/nginx.conf) and the same __API_URL__ sed-at-container-start pattern as the old apps. - Extended the root .dockerignore (shared by all docker-bake.hcl targets using context: ".") to allow tracker-admin-svelte/, tracker-frontend-svelte/, packages/, and the root package.json/package-lock.json, while explicitly re-excluding node_modules, .svelte-kit, build, build-dist, coverage, and — importantly — each app's checked-in dev .env (which contains a real GOOGLE_CLIENT_SECRET). Verified the built image's static output contains no trace of it. - VITE_API_URL/PUBLIC_THEME_STORAGE_KEY are passed as Docker build ARGs (defaulted to each app's .env.example values) 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 require API_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 sets API_URL today, failing loudly on a misconfiguration is safer than silently serving from a guessed internal address. - GOOGLE_CLIENT_ID/MICROSOFT_CLIENT_ID are 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 build from the repo root succeeds, docker run -e API_URL=... serves 200 on / 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 when API_URL is unset, HEALTHCHECK reports healthy, 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 the frontend/admin targets' context from ./tracker-frontend/ ./tracker-admin to . (repo root) and dockerfile to tracker-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 admin resolves correctly, docker buildx bake frontend admin (local, no push) builds both, and running the resulting tracker-frontend:sha-bootstrap/ tracker-admin:sha-bootstrap images serves 200 for both — same result as the standalone docker build in Phase 2, just produced through the real bake pipeline this time. api/services targets unaffected (docker buildx bake --print api services still resolves to their original context/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, the production-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's redirect_slashes behavior never triggers a 307 for any of them. Confirmed empirically with curl straight 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 generic location /api/ block do the exact same proxy_pass $api_upstream; (no path suffix, so nginx forwards the original URI unchanged either way) with the same Host override — the special blocks added nothing the generic block doesn't already do, including implicit Authorization/Cookie header forwarding (nginx passes those through by default even without an explicit proxy_set_header). - Removed all 7 redundant blocks from tracker-admin-svelte/docker/nginx.conf. Kept /static/ (the backend really does mount StaticFiles there, 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_redirect safety 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 live dev API container, and exercised every affected endpoint (both slash forms) through the simplified nginx config — including a full authenticated flow (real login, bearer-token users/me, cookie- based refresh-cookie, status-counts, production-runs/1) — all returned identical results to hitting the backend directly. tracker-frontend-svelte/docker/nginx.conf needed no changes; it was already the simple generic-only version.
  • Phase 5 — make staging-build (build + push only, done, 2026-07-07). Ran make staging-build TARGETS="frontend admin" (scoped to just the two changed targets — api/services are 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 tag sha-6aafe23308d8. write_tfvars merges rather than overwrites, so infra/envs/staging/image-tags.auto.tfvars.json only changed the frontend/admin keys — api/services/anisette stayed on their existing tags. Verified both images actually landed in ECR via aws ecr describe-images. No terraform apply has 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 plan for staging initially came back with an unrelated 11-add/7-change/11-destroy plan that included replacing the live database EC2 instance — a most_recent = true AMI 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 — see infra/modules/database). Before concluding a deliberate replacement would even be safe, verified via aws ssm against the live instance that the tracker-ops Ansible bootstrap role is genuinely idempotent (conditional mkfs, UUID-keyed fstab, conditional pg_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 plan came back clean: only the frontend/admin ECS 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/ and https://tracker.staging.glimpse.technology/ both return 200 and serve genuine SvelteKit output (_app/immutable asset paths, not the old CRA static/js/main.*), and /api/v1/users/me through the trimmed nginx config returns a real 401 (not a 502 or redirect) — the same proxy audit from Phase 4 holds up in production, not just against the local dev backend.
  • Post-Phase-6 incident: /login was 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 to adapter-static's fallback SPA mode, neither caught by local vite dev testing since dev has a real server that masks them: 1. /login is the only route with a +page.server.ts (reads $env/dynamic/private for 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 — no login.html, no login/__data.json ever got generated. Any client-side navigation that re-fetches page data hit nginx's generic index.html fallback instead of real JSON, and the browser threw SyntaxError: Unexpected token '<' trying to parse it. Fixed with export const prerender = true on both apps' /login +page.server.ts. 2. Fixing #1 exposed a second bug: nginx's try_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-existing login/ directory (which only exists because of the __data.json sidecar) and 301-redirected /login/login/, which then 403'd since there's no index.html inside that directory. SvelteKit's adapter-static emits flat route.html files, not route/index.html — the fallback chain needed $uri.html before $uri/. - Verified locally against the real production images before redeploying: /login full-page load, the /login/__data.json sidecar, 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/admin bake targets and images — api was 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-calendar endpoints (and 2 real Alembic migrations: additive users columns for auth provider, and a new password_reset_tokens table) even existed. The new frontend code calling those endpoints got real 404s, surfacing in the UI as "Not Found" widgets. - Rebuilt and pushed api from current HEAD (make staging-build TARGETS="api"). - Caught a near-miss before planning: this branch's image-tags.auto.tfvars.json still had the pre-hotfix frontend/admin tags (since the Phase-6-incident tag bump was sitting in this same still-unmerged PR, but a different, now-abandoned branch had rebuilt api from 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 plan then came back clean: only api (service) and the migrations/seed-users one-off task definitions (which share the api image) changed — no drift anywhere else. Applied. - Ran the updated migrations task definition via aws ecs run-task (both Alembic migrations applied, exit code 0) before considering the rollout complete, since the new API code expects that schema. - Verified: api ECS service stabilized on the new image; /api/v1/dashboard/stats now returns 401 Not authenticated instead of 404 Not Found when hit without credentials — confirming the route now genuinely exists on the deployed API. - Correction: services was not actually left safely untouched — see Phase 6c below. Checking only commits touching the services/ directory missed that services/docker/Dockerfile bundles the exact same app//alembic/ code as api, so it was equally 21 commits stale.
  • Phase 6c: the worker services (tracker-services image) were also stale, same root cause as Phase 6b (done, 2026-07-07). Surfaced when manually running alembic upgrade head inside a tracker-fetcher-2 worker container failed with Can'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. Confirmed services/docker/Dockerfile COPYs app/, scripts/, services/, and alembic/ — the same backend code api bundles — so all 4 worker services (tracker-fetcher-2, unified-geofence, notification-service, materialized-view-service) were equally stale, not just the one trivial services/-specific commit checked in Phase 6b. - Rebuilt and pushed services from current HEAD (make staging-build TARGETS="services"). - terraform plan came 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 new tracker-services:sha-18315de016e0 image, running == desired for each service.
  • Phase 7 — Decommission the old React apps (done, 2026-07-07). Deleted ./tracker-frontend and ./tracker-admin entirely (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 named frontend/admin needed no changes -- Phase 3 already repointed them at the Svelte apps' prod.Dockerfiles, they just happened to share the same image names. Also removed the frontend-dev/admin-dev services and the now-fully-unused legacy compose profile from compose.yml, stopped and removed their running containers, and deleted run_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.