Repository navigation
azure migration
Status: complete (2026-07-14). Azure serves https://classmode.inmodel.in
(Container Apps + PG Flexible Server + Managed Redis, resource group
classmode-rg). Data migrated with verified-identical row counts; final dump
archived in Azure Blob (classmodeuploads/backups) and locally. GCP frozen
(public access removed) and project plp-prod-2026 deleted — 30-day
pending-deletion window was the last-resort net.
Execution notes vs. the original plan: Cloud Run now rejects
--max-instances=0, so the freeze used IAM (remove allUsers invoker);
Managed Redis needed --public-network-access Enabled and
--access-keys-auth Enabled; azure.extensions needed CITEXT,VECTOR; the
schema guards CREATE EXTENSION behind a pg_extension check; BullMQ uses a
{bull} hash-tag prefix for clustered Redis.
Everything in resource group classmode-rg, region Central India:
| GCP (today) | Azure (target) |
|---|---|
Cloud Run personallearningpro
|
Container Apps classmode-app (Consumption, min 1 / max 3, 0.5 vCPU/1Gi) |
| Cloud SQL Postgres 16 HA | PG Flexible Server classmode-pg (B1ms, 32 GiB, PITR 7d, pgvector) |
| Memorystore Redis | Azure Managed Redis classmode-redis (Balanced_B0, Redis 7.x — CLI-managed, see scripts/setup-azure-redis.sh) |
| Secret Manager | ACA native secrets (scripts/setup-azure-secrets.sh) |
| Artifact Registry | GHCR (unchanged; ACA pulls with fine-grained read:packages PAT) |
| Uploads on ephemeral disk | Azure Files share mounted at /app/public/uploads
|
| Workload Identity Federation | user-assigned identity github-actions-deployer + OIDC federated credentials |
| Cloud Monitoring | Log Analytics + App Insights availability test → alerts@inmodel.in |
| Cloud Armor | dropped in v1 (documented risk; app has rate limiting + JWT) |
Why min replicas = 1: BullMQ workers and Redis Streams consumers run inside the web process; scale-to-zero silently stalls them (a latent defect on Cloud Run today). One warm replica fixes it.
Why bullmq forces Azure Managed Redis: bullmq 5.x requires Redis ≥ 6.2; classic Azure Cache for Redis Basic/Standard runs 6.0.
-
server/db-pg.ts— Pool gainsssl: … PG_SSL === "true" …(Azure PG requires TLS). -
server/lib/redis.ts—keepAlive: 15_000, connectTimeout: 10_000(Azure idle-drop); TLS comes from therediss://scheme. -
.github/workflows/cd.yml—deploy-gcpjob replaced bydeploy-azure(azure/login OIDC +az containerapp update). Build/Trivy job unchanged. - New:
terraform-azure/,scripts/setup-azure-secrets.sh,scripts/setup-azure-redis.sh.
GitHub repo secrets needed before merging: AZURE_CLIENT_ID, AZURE_TENANT_ID,
AZURE_SUBSCRIPTION_ID (values from terraform output). Remove GCP_* secrets
after decommission.
The billing profile on the Microsoft account was found Disabled
(NoAzurePlanFound), so subscription creation must go through signup:
https://azure.microsoft.com/free → sign in as the outlook.com account → add
payment method. Then:
az account list --refresh -o table
az account set --subscription <new-subscription>
for ns in Microsoft.App Microsoft.OperationalInsights Microsoft.DBforPostgreSQL \
Microsoft.Cache Microsoft.Storage Microsoft.ManagedIdentity Microsoft.Insights; do
az provider register --namespace $ns
done
az extension add --name containerapp --upgradeSet a budget alert (Cost Management → Budgets, e.g. $100/mo @ 80% email) immediately.
# One-time state-store bootstrap (chicken-and-egg with the backend)
az group create -n classmode-rg -l centralindia
az storage account create -n classmodetfstate -g classmode-rg -l centralindia \
--sku Standard_LRS --min-tls-version TLS1_2 --allow-blob-public-access false
az storage container create --account-name classmodetfstate -n tfstate --auth-mode login
cd terraform-azure
export TF_VAR_ghcr_token=<fine-grained PAT, read:packages, 1y expiry>
export TF_VAR_admin_client_ip=$(curl -s ifconfig.me)
terraform init
terraform apply
# Redis (CLI-managed — azurerm 4.x can't express Balanced_B0)
../scripts/setup-azure-redis.sh- Pull current values from GCP:
gcloud secrets versions access latest --secret=NAMEinto a local.env(never commit).POSTGRESQL_URLmust end?sslmode=require;REDIS_URLcomes fromsetup-azure-redis.shoutput. -
./scripts/setup-azure-secrets.sh— sets ACA secrets +secretref:env vars. - Deploy an image:
az containerapp update -n classmode-app -g classmode-rg --image ghcr.io/nitishkumar-ai/class-mode/personallearningpro:sha-<sha> - Smoke test on the ACA default hostname (
terraform output container_app_fqdn):-
GET /api/health→ 200, reports pg/redis connected - JWT login + Google OAuth (temporarily add the ACA hostname to the OAuth client's authorized redirect URIs in Google Cloud Console)
- RAG/vector query (proves pgvector), file upload +
/uploads/...fetch (proves the Azure Files mount), a classroom-generation job end-to-end (proves BullMQ on Managed Redis) - Logs visible in Log Analytics (
ContainerAppConsoleLogs_CL)
-
# 1. open Cloud SQL to this workstation (reversible)
gcloud sql instances patch plp-pg --project=plp-prod-2026 \
--assign-ip --authorized-networks=$(curl -s ifconfig.me)/32
# 2. dump
pg_dump "host=<cloudsql-ip> dbname=eduai user=plpapp sslmode=require" \
-Fc --no-owner --no-privileges -f eduai-$(date +%Y%m%d).dump
# 3. prepare target (admin creds: terraform output postgres_admin_*)
psql "host=classmode-pg.postgres.database.azure.com user=plpadmin dbname=postgres sslmode=require" \
-c "CREATE ROLE plpapp WITH LOGIN PASSWORD '<generate>';" \
-c "GRANT ALL ON DATABASE eduai TO plpapp;"
psql "... dbname=eduai ..." -c "CREATE EXTENSION IF NOT EXISTS vector;"
# 4. restore (safe against AUTO_MIGRATE-created tables)
pg_restore -h classmode-pg.postgres.database.azure.com -U plpadmin -d eduai \
--clean --if-exists --no-owner --no-privileges eduai-*.dump
psql "... dbname=eduai ..." \
-c "GRANT ALL ON ALL TABLES IN SCHEMA public TO plpapp;" \
-c "GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO plpapp;" \
-c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO plpapp;"
# 5. verify: row counts (pg_stat_user_tables) match; \dx shows vector;
# spot-check one similarity query
# 6. close the door
gcloud sql instances patch plp-pg --project=plp-prod-2026 --no-assign-ipRedis: nothing to migrate — drain BullMQ queues on GCP before the freeze, cache rebuilds.
- T–24h: DNS TTL for
classmode.inmodel.in→ 300s. Add theasuid.classmodeTXT record (az containerapp hostname addprints the verification id) so the domain pre-validates while GCP still serves. - Freeze:
gcloud run services update personallearningpro --region=asia-south1 --max-instances=0(brief hard downtime; pick a low-traffic window). Confirm queues empty. - Final dump/restore (Phase 4 steps 2–6).
- Flip CNAME
classmode→<container_app_fqdn>; thenaz containerapp hostname bind --hostname classmode.inmodel.in -n classmode-app -g classmode-rgwith managed certificate (5–15 min issuance; brief TLS-warning window accepted). - Flip
APP_URL/CORS_ORIGINsecrets tohttps://classmode.inmodel.in; remove the temporary ACA-hostname OAuth redirect URI. - Verify: health, login, OAuth, upload, RAG, background job, and the Expo mobile app end-to-end from a real device.
Rollback: CNAME back to Cloud Run, --max-instances=5. Keep GCP warm and
untouched for 2 weeks. Writes that landed on Azure during a failed window
need manual reconciliation — keep the window short.
Archive a final pg_dump (local + Azure Blob cool tier) → terraform destroy
in terraform-gcp/ → delete the WIF pool + GCP_* GitHub secrets → delete GCP
project plp-prod-2026 (30-day pending-deletion window is the last-resort net).
Then land the cleanup commit: remove cloudbuild.yaml, firebase.json,
firestore.rules, stale terraform/ (AWS), VITE_FIREBASE_* Dockerfile args,
and finally terraform-gcp/.
Front Door/WAF, Azure OpenAI, VNet/private endpoints, Key Vault, ACR, zone-redundant HA, Blob-storage upload rewrite, separate KEDA-scaled worker app.
ACA min-1 ~$30 · PG B1ms ~$25 · Managed Redis B0 ~$14 · Files ~$1 ·
monitoring $5 → **$70–85** (vs GCP where Memorystore STANDARD_HA alone was $70+).
Cheap variant ($25–35): min 0 replicas + skip Redis (REDIS_URL unset — the
code no-ops queues/cache cleanly) — not recommended for production traffic.