Skip to content

Project Independence — Cloudflare Migration

What This Guide Covers

A complete runbook for migrating aguidetocloud.com and learn.aguidetocloud.com from Azure Static Web Apps to Cloudflare Pages. Goal: run everything from Cloudflare at $0/month with zero Azure hosting dependency.

☕ Cloud Café Analogy

Your café is currently renting kitchen space inside a hotel (Azure, your employer's building). The food is great, but if the hotel decides to renovate or kick you out, your kitchen disappears. This project moves you to your own standalone kitchen (Cloudflare) that you own outright — same recipes, same food, completely independent.


Why We're Doing This

Reason Detail
Subscription risk Site hosted on Microsoft corp Azure subscription — could be decommissioned
Independence Personal project shouldn't depend on employer infrastructure
Cost savings Azure SWA Standard = $9/mo → Cloudflare Pages = $0/mo
Simplification CF auto-builds on git push — no deployment tokens, no swa deploy CLI steps

Architecture — Before vs After

Before (Azure SWA)

GitHub Repos → GitHub Actions → Hugo build → swa deploy → Azure SWA → DNS (Cloudflare) → Users
                                              Needs: AZURE_STATIC_WEB_APPS_API_TOKEN
                                              Needs: MAIN_SITE_SWA_TOKEN (in 4 pipeline repos)
                                              Needs: Azure OpenAI (for AI summaries)
                                              Needs: OIDC service principals (for 3 pipelines)
                                              Cost: $9/month

After (Cloudflare Pages)

GitHub Repos → git push → Cloudflare Pages auto-build → DNS (Cloudflare) → Users
                              No deployment tokens needed!
                              No swa deploy steps!
                              OpenAI API directly (no Azure OpenAI)
                              Only 1 OIDC remaining (Service Health)
                              Cost: $0/month (+$0.50/mo OpenAI API)

Full Inventory — What We're Migrating

Azure Resources (Current)

Resource Type Purpose Migration Target
aguidetocloud-revamp (SWA Standard) Static Web App Main site hosting + 4 API functions Cloudflare Pages + CF Functions
sutheesh-learning-hub (SWA Free) Static Web App Learning portal hosting Cloudflare Pages
ainews-openai Azure OpenAI AI News + Roadmap summarisation Direct OpenAI API
ainews app registration Entra ID OIDC for AI News + Roadmap + Cert Tracker Remove (no longer needed)
service-health app registration Entra ID OIDC for Service Health Graph API Keep (or switch to lab tenant)

SWA Environment Variables (Main Site)

Variable Purpose Needs Cloudflare Equivalent?
GA4_PROPERTY_ID Google Analytics property ⚠️ Only if precomputed stats runs in CF (moved to GitHub Action)
YOUTUBE_API_KEY YouTube Data API ❌ Only used by scan-youtube.yml (GitHub Action, not SWA)
YOUTUBE_OAUTH_CLIENT_ID YouTube OAuth ❌ Only used by /api/stats (being removed)
YOUTUBE_OAUTH_CLIENT_SECRET YouTube OAuth ❌ Only used by /api/stats (being removed)
YOUTUBE_OAUTH_REFRESH_TOKEN YouTube OAuth ❌ Only used by /api/stats (being removed)
GOOGLE_SERVICE_ACCOUNT_KEY GA4 + GSC access ❌ Moved to GitHub Action secret
GITHUB_FEEDBACK_PAT Feedback API ✅ Set in Cloudflare Pages dashboard
GITHUB_REPO_ID Feedback API ✅ Set in Cloudflare Pages dashboard

GitHub Secrets — What Changes

Secret Repos Action
AZURE_STATIC_WEB_APPS_API_TOKEN aguidetocloud-revamp, learning-docs DELETE — CF auto-builds
MAIN_SITE_SWA_TOKEN -ainews, m365-roadmap, service-health, cert-tracker DELETE — CF auto-builds
AZURE_CLIENT_ID -ainews, m365-roadmap DELETE — no more Azure OpenAI OIDC
AZURE_TENANT_ID -ainews, m365-roadmap DELETE — no more Azure OpenAI OIDC
AZURE_SUBSCRIPTION_ID -ainews, m365-roadmap DELETE — no more Azure OpenAI OIDC
AZURE_CLIENT_ID service-health KEEP — still needs Graph API
AZURE_TENANT_ID service-health KEEP — still needs Graph API
AZURE_SUBSCRIPTION_ID service-health KEEP — still needs Graph API
OPENAI_API_KEY -ainews, m365-roadmap ADD — new direct OpenAI API key
PERSONAL_PAT All 6 repos KEEP — still used for git push
NEWSAPI_KEY -ainews KEEP — not Azure related
GOOGLE_SERVICE_ACCOUNT_KEY aguidetocloud-revamp KEEP — for refresh-stats GitHub Action

DNS Records (Cloudflare — Already Ours)

Record Current Target New Target Notes
aguidetocloud.com (apex) red-sand-0c2ca9d00.4.azurestaticapps.net CF Pages hostname Proxied (orange cloud) — change is instant
www red-sand-0c2ca9d00.4.azurestaticapps.net CF Pages hostname Proxied — instant
learn brave-island-019a58c0f.4.azurestaticapps.net CF Pages hostname Grey cloud — TTL-dependent
preview red-sand-0c2ca9d00.4.azurestaticapps.net Can be removed or pointed to CF Optional

Migration Log

Each phase is documented below with exact commands, decisions, and results. Updated in real-time as we execute.


Phase 1: Remove /api/track (Simplification)

Date started: 2026-04-17
Status: ✅ Complete

Why: This API function writes to a local JSON file. It's not portable to any serverless platform and it's unnecessary — GA4 + Microsoft Clarity already track everything.

What we're removing:

  • api/track/function.json — Azure Function config
  • api/track/index.js — The endpoint code
  • Any frontend code that calls /api/track

Steps:

  • [x] Check what frontend code calls /api/trackstatic/js/analytics-track.js (loaded in baseof.html, exposes window.__track but never called externally)
  • [x] Remove api/track/ directory (function.json + index.js)
  • [x] Remove static/js/analytics-track.js
  • [x] Remove script tag from layouts/_default/baseof.html line 97
  • [x] Remove analytics-track exclusion from static/sw.js
  • [x] Verify no remaining references: grep returns 0 matches ✅
  • [x] Test Hugo build: passes in 9.8 seconds ✅
  • [x] Commit and deploy

Result: Clean removal. No other code depended on window.__track. GA4 + Clarity continue tracking normally.


Phase 2: Learning Portal → Cloudflare Pages (Proof of Concept)

Date started: 2026-04-17
Status: ✅ Complete

Why: Zero APIs, zero env vars, simplest possible migration. Proves the Cloudflare Pages pattern before we touch the main site.

Pre-flight checklist:

  • [x] Cloudflare account access verified (susanth.ss@gmail.com)
  • [x] CF Pages project created (connected to susanthgit/learning-docs)
  • [x] Build settings: Framework=None, Build=pip install mkdocs-material && python -m mkdocs build, Output=site
  • [x] First build succeeded ✅ ("Region: Earth")
  • [x] Test on CF Pages default URL (learning-portal-acd.pages.dev) ✅
  • [x] All pages render correctly ✅
  • [x] Search works ✅
  • [x] Navigation works ✅

Domain cutover steps:

  1. [x] ~~Reduce learn DNS TTL to 60 seconds~~ (skipped — Cloudflare auto-managed)
  2. [x] Remove learn.aguidetocloud.com from Azure SWA ✅
  3. [x] Add learn.aguidetocloud.com as custom domain in CF Pages ✅ (instant activation)
  4. [x] DNS auto-configured by Cloudflare (same zone) ✅
  5. [x] SSL auto-provisioned ✅
  6. [x] Site live on learn.aguidetocloud.com
  7. [ ] Delete AZURE_STATIC_WEB_APPS_API_TOKEN from learning-docs repo secrets
  8. [x] Simplified .github/workflows/deploy.yml — removed SWA deploy, CF auto-builds on push
  9. [ ] Decommission sutheesh-learning-hub SWA in Azure portal (Phase 7)

Result: ✅ Learning portal successfully migrated to Cloudflare Pages. Zero downtime — domain activation was instant because DNS zone is already on Cloudflare. Build time ~30 seconds. Site loads fast from global edge network.


Phase 3: Redesign Non-Portable APIs

Date started: 2026-04-17
Status: ✅ Complete

3A: /api/stats → Precomputed JSON

Problem: The googleapis npm package is too heavy for Cloudflare Workers/Functions. The /api/stats endpoint calls Google Analytics and Search Console APIs in real-time.

Solution: Move to a scheduled GitHub Action that runs daily:

  1. Fetches GA4 + GSC data using GOOGLE_SERVICE_ACCOUNT_KEY
  2. Writes static/data/stats/latest.json (and per-range files: 7d, 30d, 90d, all)
  3. Commits to repo → picked up by next build/deploy

Frontend change: command-centre.js reads /data/stats/latest.json instead of calling /api/stats

Benefits:

  • ✅ Static JSON works on ANY hosting platform
  • ✅ Faster page loads (CDN-cached, no API round-trip)
  • ✅ No env vars needed on hosting platform for analytics
  • ✅ Reduces attack surface (no server-side Google auth in production)

3B: Port /api/feedback → CF Pages Function

Current: Azure Function at api/feedback/index.js
Target: CF Pages Function at functions/api/feedback.js

Key differences:

// Azure Function signature:
module.exports = async function (context, req) {
  context.res = { status: 200, body: { ok: true } };
};

// CF Pages Function signature:
export async function onRequestPost(context) {
  const { request, env } = context;
  return new Response(JSON.stringify({ ok: true }), { status: 200 });
}

3C: Port /api/discussions → CF Pages Function

Same pattern as feedback. Reads GitHub Discussions via GraphQL.

Steps:

  • [ ] Create functions/api/feedback.js (CF Pages Function format)
  • [ ] Create functions/api/discussions.js (CF Pages Function format)
  • [ ] Create refresh-stats.yml GitHub Action
  • [ ] Update command-centre.js to read static JSON
  • [ ] Delete api/stats/ and api/track/ directories
  • [ ] Test all endpoints on CF Pages staging

Phase 4: Main Site → Cloudflare Pages

Date started: 2026-04-17
Status: ✅ Complete

4A: Convert SWA Config → Cloudflare Format

The staticwebapp.config.json has 147 redirects, cache rules, and security headers. Cloudflare Pages uses different files:

SWA Format CF Pages Format
routes[].redirect _redirects file
globalHeaders _headers file
routes[].headers _headers file (path-specific)
navigationFallback Custom 404.html (built-in)

Script: scripts/generate-cf-config.py reads the SWA config and outputs both files.

4B: CF Pages Project Setup

  • Connect susanthgit/aguidetocloud-revamp to CF Pages
  • Build command: hugo --minify && npx pagefind@latest --site public
  • Output directory: public
  • Set Hugo version via HUGO_VERSION env var
  • Set Cloudflare env vars: GITHUB_FEEDBACK_PAT, GITHUB_REPO_ID

4C: Full Test Checklist

  • [ ] All 31 tool pages load and function correctly
  • [ ] AI News data loads (daily/weekly/monthly tabs)
  • [ ] M365 Roadmap data loads with filtering
  • [ ] Service Health data loads with incident details
  • [ ] Cert Tracker 52 study guides load
  • [ ] Deprecation Timeline loads
  • [ ] Blog posts render with Mermaid diagrams
  • [ ] Pagefind search returns results
  • [ ] /api/feedback form submission creates GitHub Discussion
  • [ ] /api/discussions loads and displays feedback
  • [ ] Precomputed stats JSON loads in Command Centre
  • [ ] All 147 redirects work correctly
  • [ ] Security headers present (HSTS, CSP, X-Frame-Options, X-Content-Type-Options)
  • [ ] Cache headers on /data/* routes
  • [ ] /admin Sveltia CMS login works (OAuth callback URLs updated)
  • [ ] Mobile responsive — no broken layouts
  • [ ] OG images load on social preview
  • [ ] RSS feeds work
  • [ ] PWA service worker loads
  • [ ] Bio link page works
  • [ ] Free Tools page loads all 31 tool cards

4D: Domain Cutover

This is the moment of truth — expected 5-15 minutes downtime

Pre-cutover (1 day before):

  1. Reduce DNS TTL to 60 seconds on ALL records
  2. Verify CF Pages staging passes full test checklist
  3. Document exact Cloudflare DNS changes needed

Cutover sequence:

  1. Remove aguidetocloud.com, www.aguidetocloud.com, preview.aguidetocloud.com from Azure SWA
  2. Add all three as custom domains in CF Pages project
  3. Update Cloudflare DNS CNAMEs to CF Pages hostname
  4. For proxied records (apex, www): change takes effect instantly
  5. For non-proxied records: wait for TTL (60 seconds)
  6. Verify SSL auto-provisions on all domains
  7. Run full smoke test: pwsh scripts/smoke-test.ps1
  8. Monitor for 24 hours

Phase 5: Pipeline Simplification

Date started: 2026-04-17
Status: ✅ Complete

What changes: All 5 pipeline workflows currently have a 🚀 Deploy main website step that:

  1. Downloads Hugo
  2. Builds the site
  3. Deploys via swa deploy with MAIN_SITE_SWA_TOKEN

On Cloudflare Pages, this is unnecessary — the git push to aguidetocloud-revamp (step before) automatically triggers a CF Pages build.

Delete from each pipeline:

Pipeline Workflow File Delete Steps
AI News nightly-news.yml 🚀 Deploy main website
M365 Roadmap daily-roadmap.yml 🚀 Deploy main website
Service Health service-health.yml 🚀 Deploy main website
Cert Tracker weekly-cert-tracker.yml 🚀 Deploy main website
Deprecation Timeline daily-deprecation.yml Check if it has deploy step

Delete secrets:

# Remove MAIN_SITE_SWA_TOKEN from 4 repos
$env:GH_TOKEN = Get-Content "$env:USERPROFILE\.copilot\secrets\github-personal-pat"
@('-ainews','m365-roadmap','service-health','cert-tracker') | ForEach-Object {
    gh secret delete MAIN_SITE_SWA_TOKEN --repo "susanthgit/$_"
}

Phase 6: Replace Azure OpenAI → Direct OpenAI API

Date started: (deferred)
Status: 🔶 Deferred — Corp Azure OpenAI is free and working. Switch when/if needed.

Current: Pipelines use OIDC to get Azure AD token → call Azure OpenAI endpoint
Target: Use OpenAI API key directly → call api.openai.com

Azure OpenAI (current) OpenAI API (target)
Endpoint ainews-openai.openai.azure.com api.openai.com
Auth OIDC → Azure AD token API key header
Model gpt-4o-mini (deployment name) gpt-4o-mini (model ID)
Cost Corp subscription pays ~$0.50/month personal
Azure dependency ✅ Full ❌ None

Apply to:

  • [ ] ainews/scripts/summarise.py — update OpenAI client
  • [ ] ainews/.github/workflows/nightly-news.yml — remove OIDC login, add OPENAI_API_KEY env var
  • [ ] m365-roadmap/scripts/summarise.py — same changes
  • [ ] m365-roadmap/.github/workflows/daily-roadmap.yml — same changes

Service Health pipeline: Keeps OIDC for Graph API (only remaining Azure dependency).


Phase 7: Decommission Azure Resources

Date started: (pending — after 1 week stability)
Status: ⬜ Waiting for 1 week stability confirmation

Only do this after 1+ week of stable operation on Cloudflare Pages

  • [ ] Delete aguidetocloud-revamp SWA from Azure portal
  • [ ] Delete sutheesh-learning-hub SWA from Azure portal
  • [ ] Delete ainews-openai Azure OpenAI resource
  • [ ] Remove AZURE_STATIC_WEB_APPS_API_TOKEN from aguidetocloud-revamp repo
  • [ ] Remove all OIDC secrets from ainews + m365-roadmap repos
  • [ ] Verify service-health repo still has its OIDC secrets
  • [ ] Update this documentation with final state

Service Health — Tenant Migration Runbook

When to use: If corporate Entra ID tenant access (72f988bf) is lost.
Fallback tenant: M365CPI52224224.onmicrosoft.com (lab tenant)

Why this works

Microsoft Graph ServiceHealth API returns global M365 service health data — not tenant-specific incidents. An Exchange Online outage shows up identically in any tenant's API. The lab tenant gives the same data as the corp tenant.

Steps to switch to lab tenant:

  1. Log into Azure portal with lab tenant credentials
  2. Create new App Registration: service-health-lab
  3. Add API permission: Microsoft Graph → Application → ServiceHealth.Read.All
  4. Grant admin consent
  5. Create federated credential for GitHub Actions OIDC:
    • Issuer: https://token.actions.githubusercontent.com
    • Subject: repo:susanthgit/service-health:ref:refs/heads/main
    • Audience: api://AzureADTokenExchange
  6. Update GitHub secrets in susanthgit/service-health:
    • AZURE_CLIENT_ID → new app's client ID
    • AZURE_TENANT_ID → lab tenant ID
    • AZURE_SUBSCRIPTION_ID → lab subscription ID
  7. Trigger manual workflow run to verify
  8. Estimated time: 30 minutes

Rollback Plan

If anything goes catastrophically wrong during migration:

Quick Rollback (5 minutes)

  1. Update Cloudflare DNS CNAMEs back to Azure SWA hostnames:
    • aguidetocloud.comred-sand-0c2ca9d00.4.azurestaticapps.net
    • wwwred-sand-0c2ca9d00.4.azurestaticapps.net
    • learnbrave-island-019a58c0f.4.azurestaticapps.net
  2. Re-add custom domains in Azure SWA portal
  3. Site is back on Azure

Full Rollback (30 minutes)

  1. Quick rollback (above)
  2. Restore swa deploy steps in pipeline workflows (git revert)
  3. Re-add MAIN_SITE_SWA_TOKEN to pipeline repos
  4. Re-add AZURE_STATIC_WEB_APPS_API_TOKEN to site repos
  5. Trigger manual deploy to Azure SWA

Safety net

We keep Azure SWA alive and deploying in parallel until we're confident in Cloudflare Pages. We only decommission Azure in Phase 7 — after 1+ week of stable CF Pages operation.


Phase 8: Transfer Domain Registrar (Squarespace → Cloudflare)

Date started: (deferred until ~2030)
Status: 🔶 Deferred — Squarespace paid through ~2030. Transfer at renewal for at-cost pricing.

Why: Domain is still registered at Squarespace — a separate dependency. Moving it to Cloudflare puts domain, DNS, hosting, and Workers all under one account at cost price.

Detail Value
Current registrar Squarespace (likely via Tucows)
Target registrar Cloudflare Registrar
Cost ~$9.77/year for .com (at-cost, no markup)
Downtime Zero — DNS is already on Cloudflare
Duration Hours to 5 days

Steps:

  1. [ ] Squarespace: Log in → Settings → Domains → aguidetocloud.com
  2. [ ] Unlock domain (toggle off domain lock)
  3. [ ] Click "Transfer Out" → copy EPP/Authorization Code
  4. [ ] Disable WHOIS privacy temporarily (so Cloudflare can send confirmation emails)
  5. [ ] Cloudflare: Dashboard → Domain Registration → Transfer
  6. [ ] Paste EPP code, confirm details, pay renewal (~$9.77)
  7. [ ] Check email for confirmation from Squarespace's registrar → approve transfer
  8. [ ] Wait for completion (hours to 5 days)
  9. [ ] Verify aguidetocloud.com shows as registered in Cloudflare dashboard
  10. [ ] Enable auto-renew in Cloudflare Registrar settings
  11. [ ] Re-enable WHOIS privacy (free on Cloudflare)

Final state after all phases complete:

Cloudflare account (susanth.ss@gmail.com) owns EVERYTHING:
├── Domain registration: aguidetocloud.com (~$9.77/yr)
├── DNS zone: aguidetocloud.com (free)
├── Pages: main site ($0/mo)
├── Pages: learning portal ($0/mo)
├── Workers: CMS OAuth ($0/mo)
└── Total annual cost: ~$10.27/year + ~$6/yr OpenAI API

Was: $9/mo Azure SWA + ~$15/yr Squarespace domain = ~$123/year
Savings: ~$112/year (91% reduction)


Lessons Learned

Updated as we discover things during migration.

  1. CF Pages is strict on trailing slashes in _redirects — Azure SWA matched both /bio and /bio/ even if the config only had one. CF Pages does exact match. Fix: generate-cf-config.py now emits both variants (130 → 250 rules). Always test redirects both with and without trailing slashes.

  2. permissions: contents: write is required for GitHub Actions to push — When CF Pages auto-builds on push, the GITHUB_TOKEN defaults to read-only in some contexts. The OG image workflow needed this permission explicitly to push generated images back to the repo.

  3. Old api/ directory was 202 MB — Azure Functions node_modules are heavy. Delete old API directories immediately after migration — the CF Pages Functions (functions/api/) are tiny (41 KB total, zero dependencies). Don't leave dead weight in the repo.

  4. Domain cutover is instant on Cloudflare — Because DNS was already on Cloudflare (proxied, orange cloud), changing the backend from Azure SWA to CF Pages took ~4 minutes total. No TTL waiting, no DNS propagation delays. This is the biggest advantage of having DNS and hosting on the same provider.

  5. CF Pages Functions use Web standard APIsfetch(), Request, Response, crypto.subtle are all native. The /api/stats endpoint (845 lines as Azure Function with googleapis npm package) was rewritten to 601 lines using native fetch() + Web Crypto API for JWT signing. Zero npm dependencies.

  6. staticwebapp.config.json is still useful as a source of truth — Rather than manually maintaining separate _redirects and _headers files (error-prone), we keep the SWA config and auto-generate CF formats. One source, two outputs.

  7. Post-migration audit caught 3 issues — Even after thorough staging tests, the production audit found: redirect trailing-slash gaps, deploy.yml permission issues, and 202 MB of dead files. Always run a full audit after migration, not just a quick smoke test.

  8. Truncated env var values cause cryptic errorsGITHUB_REPO_ID was pasted as R_kgDOSAK5R (missing trailing g). GitHub returned "Could not resolve to a node with the global id" — not obvious it's a truncation issue. Always verify env var values character-by-character after pasting.

  9. CF Pages env var changes need a new deployment — Updating a secret in the CF dashboard doesn't apply to running instances. Push an empty commit (git commit --allow-empty) to trigger a redeploy.

  10. In-memory rate limiters reset on CF Function cold start — Acceptable for our usage. Each CF Worker instance has its own memory. The 3-per-10-min limit works but isn't global across instances.

Post-Migration Audit Results (2026-04-17)

Test Status Details
46-page smoke test All tools, blog, cert tracker, AI news, roadmap
3 API endpoints /api/stats, /api/stats?realtime, /api/discussions
Learning portal 4 pages + search working
DNS (3 domains) All → Cloudflare IPs (172.67.172.1, 104.21.79.237)
SSL (3 certs) Google Trust Services, 89 days remaining
Security headers HSTS, XFO, XCTO, CSP, XSS, Referrer, Permissions
Pagefind search pagefind.js (44.5KB) + index loading
Sveltia CMS /admin/ loads correctly
Pipelines (5 repos) All healthy post-migration
Stale references 9/10 clean (staticwebapp.config.json kept intentionally)
Redirects Fixed — 250 rules with both slash variants
deploy.yml Fixed — permissions added, OG workflow passing
Old API cleanup 202 MB deleted, functions/api/ confirmed working
Cache headers /data/* routes serving correct Cache-Control