Can you turn a brittle legacy app into a multi-tenant SaaS without rewriting it from scratch? We just did. In four sprints, our team relaunched a seven-year-old e-commerce monolith as a subscription-based platform powered by Angular 19 SSR, Node 20 + Fastify and Terraform Cloud.This post distills everything that worked, what blew up in our faces, and a copy-paste migration checklist. Grab a coffee: 8-minute read. Can you turn a brittle legacy app into a multi-tenant SaaS without rewriting it from scratch? We just did. In four sprints, our team relaunched a seven-year-old e-commerce monolith as a subscription-based platform powered by Angular 19 SSR, Node 20 + Fastify and Terraform Cloud.This post distills everything that worked, what blew up in our faces, and a copy-paste migration checklist. Grab a coffee: 8-minute read. 1 — Why Even Migrate? (Hint: Money & Velocity) ☕ Metric Before After Delta Monthly Release Cadence 1 / month 12 / month × 12 Infra Cost / Tenant €165 €97 -41 % LCP 75th p (field) 4.1 s 1.9 s -54 % Net Promoter Score 34 65 +31 Metric Before After Delta Monthly Release Cadence 1 / month 12 / month × 12 Infra Cost / Tenant €165 €97 -41 % LCP 75th p (field) 4.1 s 1.9 s -54 % Net Promoter Score 34 65 +31 Metric Before After Delta Metric Metric Before Before After After Delta Delta Monthly Release Cadence 1 / month 12 / month × 12 Monthly Release Cadence Monthly Release Cadence 1 / month 1 / month 12 / month 12 / month × 12 × 12 × 12 Infra Cost / Tenant €165 €97 -41 % Infra Cost / Tenant Infra Cost / Tenant €165 €165 €97 €97 -41 % -41 % -41 % LCP 75th p (field) 4.1 s 1.9 s -54 % LCP 75th p (field) LCP 75th p (field) 4.1 s 4.1 s 1.9 s 1.9 s -54 % -54 % -54 % Net Promoter Score 34 65 +31 Net Promoter Score Net Promoter Score 34 34 65 65 +31 +31 +31 ROI kicker: each 1-second LCP drop boosted funnel conversion by 6 %. Numbers made finance very, very happy. ROI kicker: 2 — Audit the Monster in 3 Dimensions 🕵️ Before touching code, we ran a 3-D audit. Score every module 1 → 5: 3-D audit 1 → 5 Dimension 5 = Red-Zone Symptoms Coupling Cross-module imports, fat controllers, tangled AngularJS & jQuery Test Coverage < 10 % paths exercised Rollback Blast Radius DB migrations are irreversible, prod config differs from staging Dimension 5 = Red-Zone Symptoms Coupling Cross-module imports, fat controllers, tangled AngularJS & jQuery Test Coverage < 10 % paths exercised Rollback Blast Radius DB migrations are irreversible, prod config differs from staging Dimension 5 = Red-Zone Symptoms Dimension Dimension 5 = Red-Zone Symptoms 5 = Red-Zone Symptoms Coupling Cross-module imports, fat controllers, tangled AngularJS & jQuery Coupling Coupling Coupling Cross-module imports, fat controllers, tangled AngularJS & jQuery Cross-module imports, fat controllers, tangled AngularJS & jQuery Test Coverage < 10 % paths exercised Test Coverage Test Coverage Test Coverage < 10 % paths exercised < 10 % paths exercised Rollback Blast Radius DB migrations are irreversible, prod config differs from staging Rollback Blast Radius Rollback Blast Radius Rollback Blast Radius DB migrations are irreversible, prod config differs from staging DB migrations are irreversible, prod config differs from staging Rule of thumb: anything scoring ≥ 4 goes into the “strangler fig” backlog—decouple after you stabilize the happy path. Rule of thumb: anything scoring ≥ 4 goes into the “strangler fig” backlog—decouple after you stabilize the happy path. Rule of thumb: 3 — Architecture Choice: Modular Monolith + Feature Flags 🚀 Why not micro-services right away? not Option ⏱️ Speed to Ship 🔒 Tenant Isolation 👷♂️ Ops Burden Lift-and-Shift Docker ⚡ Fast 😰 Minimal 😀 Low Modular Monolith + Flags 🔄 Balanced 🙂 Good 🟡 Medium Micro-services (DDD) 🐢 Slow 😎 Great 🔴 High Option ⏱️ Speed to Ship 🔒 Tenant Isolation 👷♂️ Ops Burden Lift-and-Shift Docker ⚡ Fast 😰 Minimal 😀 Low Modular Monolith + Flags 🔄 Balanced 🙂 Good 🟡 Medium Micro-services (DDD) 🐢 Slow 😎 Great 🔴 High Option ⏱️ Speed to Ship 🔒 Tenant Isolation 👷♂️ Ops Burden Option Option ⏱️ Speed to Ship ⏱️ Speed to Ship 🔒 Tenant Isolation 🔒 Tenant Isolation 👷♂️ Ops Burden 👷♂️ Ops Burden Lift-and-Shift Docker ⚡ Fast 😰 Minimal 😀 Low Lift-and-Shift Docker Lift-and-Shift Docker ⚡ Fast ⚡ Fast 😰 Minimal 😰 Minimal 😀 Low 😀 Low Modular Monolith + Flags 🔄 Balanced 🙂 Good 🟡 Medium Modular Monolith + Flags Modular Monolith + Flags Modular Monolith + Flags 🔄 Balanced 🔄 Balanced 🔄 Balanced 🙂 Good 🙂 Good 🟡 Medium 🟡 Medium Micro-services (DDD) 🐢 Slow 😎 Great 🔴 High Micro-services (DDD) Micro-services (DDD) 🐢 Slow 🐢 Slow 😎 Great 😎 Great 🔴 High 🔴 High We chose Modular Monolith: Modular Monolith Single repo keeps onboarding trivial. Feature flags let us ship dark features to one tenant at a time. Move to services only when a module outgrows the monolith. Single repo keeps onboarding trivial. Single repo Feature flags let us ship dark features to one tenant at a time. Feature flags Move to services only when a module outgrows the monolith. only when a module outgrows the monolith 4 — Frontend Overhaul: Angular 19 with Native SSR 🖼️ # add server-side rendering in two commands npx ng add @angular/ssr npm run build:ssr && npm run serve:ssr # add server-side rendering in two commands npx ng add @angular/ssr npm run build:ssr && npm run serve:ssr Two lessons learned Lazy-hydrated Islands: heavy graphs & charts blew up renderApplication memory. We wrapped them with ngSkipHydration and hydrated on IntersectionObserver. TC39 Temporal API: Angular 19’s new date pipes + Node 20 eliminated 30 kB of Moment.js dead weight. Lazy-hydrated Islands: heavy graphs & charts blew up renderApplication memory. We wrapped them with ngSkipHydration and hydrated on IntersectionObserver. Lazy-hydrated Islands: renderApplication ngSkipHydration IntersectionObserver TC39 Temporal API: Angular 19’s new date pipes + Node 20 eliminated 30 kB of Moment.js dead weight. TC39 Temporal API: Result: LCP < 2 s on real Moto G4 devices. LCP < 2 s on real Moto G4 devices 5 — Backend & Tenancy: Fastify + Postgres RLS 🗄️ Fastify because 80 k req/s on a single M6g large with zero tuning. Row-Level Security (policy USING (tenant_id = current_setting('app.tenant_id'))) keeps one DB until we hit 1 TB—then we partition. Observability: OpenTelemetry → Grafana Cloud; one dashboard per tenant with UID templating. Fastify because 80 k req/s on a single M6g large with zero tuning. Fastify Row-Level Security (policy USING (tenant_id = current_setting('app.tenant_id'))) keeps one DB until we hit 1 TB—then we partition. Row-Level Security policy USING (tenant_id = current_setting('app.tenant_id')) Observability: OpenTelemetry → Grafana Cloud; one dashboard per tenant with UID templating. Observability 6 — CI/CD: Green-Only Deploys in 45 Lines 📦 # .github/workflows/deploy.yml (core) on: [push] jobs: test: … # npm ci && npm test build_ssr: … # npm run build:ssr deploy: needs: build_ssr runs-on: ubuntu-latest permissions: { id-token: write } steps: - uses: hashicorp/setup-terraform@v3 - run: terraform init && terraform apply -auto-approve # .github/workflows/deploy.yml (core) on: [push] jobs: test: … # npm ci && npm test build_ssr: … # npm run build:ssr deploy: needs: build_ssr runs-on: ubuntu-latest permissions: { id-token: write } steps: - uses: hashicorp/setup-terraform@v3 - run: terraform init && terraform apply -auto-approve Prod deploy in 11 min. If tests fail, prod is untouched. Prod deploy in 11 min. If tests fail, prod is untouched. 7 — Security First (Really) 🔐 Layer Must-Have Control Tooling Auth Passwordless magic-link + OAuth 2.1 Auth.js & Argon2 API Per-tenant rate-limit + HMAC sigs Fastify hooks, Redis Data AES-256 PII encryption + RLS Postgres 15, AWS KMS Infra CIS Level 1 as code tfsec, Open Policy Agent Layer Must-Have Control Tooling Auth Passwordless magic-link + OAuth 2.1 Auth.js & Argon2 API Per-tenant rate-limit + HMAC sigs Fastify hooks, Redis Data AES-256 PII encryption + RLS Postgres 15, AWS KMS Infra CIS Level 1 as code tfsec, Open Policy Agent Layer Must-Have Control Tooling Layer Layer Must-Have Control Must-Have Control Tooling Tooling Auth Passwordless magic-link + OAuth 2.1 Auth.js & Argon2 Auth Auth Passwordless magic-link + OAuth 2.1 Passwordless magic-link + OAuth 2.1 Auth.js & Argon2 Auth.js & Argon2 Auth.js API Per-tenant rate-limit + HMAC sigs Fastify hooks, Redis API API Per-tenant rate-limit + HMAC sigs Per-tenant rate-limit + HMAC sigs Fastify hooks, Redis Fastify hooks, Redis Data AES-256 PII encryption + RLS Postgres 15, AWS KMS Data Data AES-256 PII encryption + RLS AES-256 PII encryption + RLS Postgres 15, AWS KMS Postgres 15, AWS KMS Infra CIS Level 1 as code tfsec, Open Policy Agent Infra Infra CIS Level 1 as code CIS Level 1 as code tfsec, Open Policy Agent tfsec, Open Policy Agent tfsec Fun fact: Week 1, 37 % of traffic was credential-stuffing bots—blocked automatically. Fun fact: 8 — Cost Lever Matrix 💸 Lever Year-1 Savings How Edge Caching -23 % Cloudflare caches SSR HTML + stale-while-revalidate Serverless Cron -11 % Nightly reports moved to AWS Lambda Cloud Credits -17 % AWS Activate + open-source sponsorship Multi-AZ +6 % cost Worth it: SLA 99.95 % → churn -1.2 % Lever Year-1 Savings How Edge Caching -23 % Cloudflare caches SSR HTML + stale-while-revalidate Serverless Cron -11 % Nightly reports moved to AWS Lambda Cloud Credits -17 % AWS Activate + open-source sponsorship Multi-AZ +6 % cost Worth it: SLA 99.95 % → churn -1.2 % Lever Year-1 Savings How Lever Lever Year-1 Savings Year-1 Savings How How Edge Caching -23 % Cloudflare caches SSR HTML + stale-while-revalidate Edge Caching Edge Caching -23 % -23 % -23 % Cloudflare caches SSR HTML + stale-while-revalidate Cloudflare caches SSR HTML + stale-while-revalidate Serverless Cron -11 % Nightly reports moved to AWS Lambda Serverless Cron Serverless Cron -11 % -11 % -11 % Nightly reports moved to AWS Lambda Nightly reports moved to AWS Lambda Cloud Credits -17 % AWS Activate + open-source sponsorship Cloud Credits Cloud Credits -17 % -17 % -17 % AWS Activate + open-source sponsorship AWS Activate + open-source sponsorship Multi-AZ +6 % cost Worth it: SLA 99.95 % → churn -1.2 % Multi-AZ Multi-AZ +6 % cost +6 % cost +6 % Worth it: SLA 99.95 % → churn -1.2 % Worth it: SLA 99.95 % → churn -1.2 % 9 — Five Lessons We Keep Re-Learning 🤹♂️ Feature flags > long-lived branches. Measure field LCP, not just Lighthouse. Docs or die. Every interface change = one ADR file. Tenant-id on Day 0 – retro-fitting is hell. Post-launch “broken windows” sprint saves morale. Feature flags > long-lived branches. Feature flags > long-lived branches. Measure field LCP, not just Lighthouse. Measure field LCP Docs or die. Every interface change = one ADR file. Docs or die. Tenant-id on Day 0 – retro-fitting is hell. Tenant-id on Day 0 Post-launch “broken windows” sprint saves morale. Post-launch “broken windows” sprint 10 — Pocket Checklist (Steal Me) ✅ ☐ Build a coupling matrix ☐ Add tenant_id column everywhere now ☐ Ship risky slices behind flags ☐ Synthetic health check per tenant ☐ Schedule “Fix Broken Windows” sprint after go-live ☐ Build a coupling matrix ☐ Add tenant_id column everywhere now tenant_id ☐ Ship risky slices behind flags ☐ Synthetic health check per tenant ☐ Schedule “Fix Broken Windows” sprint after go-live Shipping a SaaS is never one-click magic. But with a modular plan, ruthless DevOps discipline and an obsession for user experience, you can turn a creaky monolith into a growth flywheel in under a month. Share your war stories below—let’s swap scars! 🚀 Shipping a SaaS is never one-click magic.