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 configapi/track/index.js— The endpoint code- Any frontend code that calls
/api/track
Steps:
- [x] Check what frontend code calls
/api/track→static/js/analytics-track.js(loaded inbaseof.html, exposeswindow.__trackbut 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.htmlline 97 - [x] Remove
analytics-trackexclusion fromstatic/sw.js - [x] Verify no remaining references:
grepreturns 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:
- [x] ~~Reduce
learnDNS TTL to 60 seconds~~ (skipped — Cloudflare auto-managed) - [x] Remove
learn.aguidetocloud.comfrom Azure SWA ✅ - [x] Add
learn.aguidetocloud.comas custom domain in CF Pages ✅ (instant activation) - [x] DNS auto-configured by Cloudflare (same zone) ✅
- [x] SSL auto-provisioned ✅
- [x] Site live on
learn.aguidetocloud.com✅ - [ ] Delete
AZURE_STATIC_WEB_APPS_API_TOKENfrom learning-docs repo secrets - [x] Simplified
.github/workflows/deploy.yml— removed SWA deploy, CF auto-builds on push - [ ] Decommission
sutheesh-learning-hubSWA 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:
- Fetches GA4 + GSC data using
GOOGLE_SERVICE_ACCOUNT_KEY - Writes
static/data/stats/latest.json(and per-range files: 7d, 30d, 90d, all) - 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.ymlGitHub Action - [ ] Update
command-centre.jsto read static JSON - [ ] Delete
api/stats/andapi/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-revampto CF Pages - Build command:
hugo --minify && npx pagefind@latest --site public - Output directory:
public - Set Hugo version via
HUGO_VERSIONenv 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/feedbackform submission creates GitHub Discussion - [ ]
/api/discussionsloads 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 - [ ]
/adminSveltia 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):
- Reduce DNS TTL to 60 seconds on ALL records
- Verify CF Pages staging passes full test checklist
- Document exact Cloudflare DNS changes needed
Cutover sequence:
- Remove
aguidetocloud.com,www.aguidetocloud.com,preview.aguidetocloud.comfrom Azure SWA - Add all three as custom domains in CF Pages project
- Update Cloudflare DNS CNAMEs to CF Pages hostname
- For proxied records (apex, www): change takes effect instantly
- For non-proxied records: wait for TTL (60 seconds)
- Verify SSL auto-provisions on all domains
- Run full smoke test:
pwsh scripts/smoke-test.ps1 - 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:
- Downloads Hugo
- Builds the site
- Deploys via
swa deploywithMAIN_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, addOPENAI_API_KEYenv 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-revampSWA from Azure portal - [ ] Delete
sutheesh-learning-hubSWA from Azure portal - [ ] Delete
ainews-openaiAzure OpenAI resource - [ ] Remove
AZURE_STATIC_WEB_APPS_API_TOKENfrom 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:
- Log into Azure portal with lab tenant credentials
- Create new App Registration:
service-health-lab - Add API permission:
Microsoft Graph → Application → ServiceHealth.Read.All - Grant admin consent
- 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
- Issuer:
- Update GitHub secrets in
susanthgit/service-health:AZURE_CLIENT_ID→ new app's client IDAZURE_TENANT_ID→ lab tenant IDAZURE_SUBSCRIPTION_ID→ lab subscription ID
- Trigger manual workflow run to verify
- Estimated time: 30 minutes
Rollback Plan¶
If anything goes catastrophically wrong during migration:
Quick Rollback (5 minutes)¶
- Update Cloudflare DNS CNAMEs back to Azure SWA hostnames:
aguidetocloud.com→red-sand-0c2ca9d00.4.azurestaticapps.netwww→red-sand-0c2ca9d00.4.azurestaticapps.netlearn→brave-island-019a58c0f.4.azurestaticapps.net
- Re-add custom domains in Azure SWA portal
- Site is back on Azure
Full Rollback (30 minutes)¶
- Quick rollback (above)
- Restore
swa deploysteps in pipeline workflows (git revert) - Re-add
MAIN_SITE_SWA_TOKENto pipeline repos - Re-add
AZURE_STATIC_WEB_APPS_API_TOKENto site repos - 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:
- [ ] Squarespace: Log in → Settings → Domains →
aguidetocloud.com - [ ] Unlock domain (toggle off domain lock)
- [ ] Click "Transfer Out" → copy EPP/Authorization Code
- [ ] Disable WHOIS privacy temporarily (so Cloudflare can send confirmation emails)
- [ ] Cloudflare: Dashboard → Domain Registration → Transfer
- [ ] Paste EPP code, confirm details, pay renewal (~$9.77)
- [ ] Check email for confirmation from Squarespace's registrar → approve transfer
- [ ] Wait for completion (hours to 5 days)
- [ ] Verify
aguidetocloud.comshows as registered in Cloudflare dashboard - [ ] Enable auto-renew in Cloudflare Registrar settings
- [ ] 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.
-
CF Pages is strict on trailing slashes in
_redirects— Azure SWA matched both/bioand/bio/even if the config only had one. CF Pages does exact match. Fix:generate-cf-config.pynow emits both variants (130 → 250 rules). Always test redirects both with and without trailing slashes. -
permissions: contents: writeis required for GitHub Actions to push — When CF Pages auto-builds on push, theGITHUB_TOKENdefaults to read-only in some contexts. The OG image workflow needed this permission explicitly to push generated images back to the repo. -
Old
api/directory was 202 MB — Azure Functionsnode_modulesare 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. -
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.
-
CF Pages Functions use Web standard APIs —
fetch(),Request,Response,crypto.subtleare all native. The/api/statsendpoint (845 lines as Azure Function withgoogleapisnpm package) was rewritten to 601 lines using nativefetch()+ Web Crypto API for JWT signing. Zero npm dependencies. -
staticwebapp.config.jsonis still useful as a source of truth — Rather than manually maintaining separate_redirectsand_headersfiles (error-prone), we keep the SWA config and auto-generate CF formats. One source, two outputs. -
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.
-
Truncated env var values cause cryptic errors —
GITHUB_REPO_IDwas pasted asR_kgDOSAK5R(missing trailingg). 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. -
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. -
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 |