Skip to content

Latest commit

 

History

History
647 lines (512 loc) · 25.6 KB

File metadata and controls

647 lines (512 loc) · 25.6 KB

Azure Deployment Guide

Deploy the GroundShare backend and frontend to Azure, wire the custom domain, and build native iOS/Android apps with Capacitor.

Architecture:

                    Cloudflare DNS (groundshare.app)
                    │                       │
   groundshare.app  │                       │  api.groundshare.app  (same-site
   (proxied)        ▼                       ▼   so the refresh cookie works)
┌──────────────────────────┐     ┌──────────────────────────────┐
│  Azure App Service        │     │  Azure App Service (.NET 8)  │
│  app-groundshare-web      │────►│  app-groundshare-api         │
│  Node host: server.cjs    │HTTPS│  ├── Azure SQL Database      │
│  (Vite dist/ + headers)   │◄────│  ├── Azure Blob Storage      │
└──────────────────────────┘     │  ├── Azure Key Vault         │
   ▲                              │  └── App Insights / Content  │
   │ same bundle, native shell    │      Safety                  │
┌──┴───────────────────┐         └──────────────────────────────┘
│  iOS / Android App    │                  ▲
│  (Capacitor wrapping  │──────────────────┘  HTTPS direct to API
│   the React build)    │
└───────────────────────┘

Two App Services, both Linux, both in Israel Central: app-groundshare-api (the .NET 8 API) and app-groundshare-web (Node host serving the Vite dist/ via the zero-dependency src/03-client/deploy/server.cjs, which does SPA fallback and injects the security headers — X-Frame-Options, nosniff, HSTS, Permissions-Policy, and a CSP). Static Web Apps isn't used because the subscription is region-locked to regions where SWA isn't offered. The custom domain and the api. subdomain are fronted by Cloudflare, which keeps the web app and the API same-site so the httpOnly SameSite=Lax refresh cookie is sent on the boot /auth/refresh (pointing the frontend at the raw *.azurewebsites.net API host makes the cookie cross-site → withheld → logout on every reload).


Prerequisites

  • Azure for Students subscription (active)
  • Azure CLI installed: winget install Microsoft.AzureCLI
  • Logged in: az login
  • GitHub repo with push access to main branch
  • Node.js 18+ and npm
  • For iOS builds: macOS with Xcode 15+
  • For Android builds: Android Studio with SDK 34+

Step 7.1 — Provision Azure Resources

What you do: Run the script below in PowerShell. It creates all 4 Azure resources.

Before running: Pick a unique suffix for globally-unique names. Replace groundshare below if it's taken.

# ── Variables (edit these) ────────────────────────────────────
$RG                = "rg-groundshare-prod-IL"
$LOCATION          = "israelcentral"
$SQL_SERVER        = "sql-groundshare-il"
$SQL_DB            = "GroundShareDB"
$SQL_ADMIN_USER    = "groundshare-admin"
$SQL_ADMIN_PASS    = "<SET_A_STRONG_PASSWORD_HERE>"  # 8+ chars, upper+lower+digit+symbol. Never commit.
$APP_SERVICE_PLAN  = "plan-groundshare"
$APP_NAME          = "app-groundshare-api"
$STORAGE_ACCOUNT   = "stgroundshareil"     # 3-24 lowercase alphanumeric only
$KEYVAULT_NAME     = "rg-groundshare-prod" # Note: Key Vault was named after the Resource Group

# ── 1. Resource Group ─────────────────────────────────────────
az group create --name $RG --location $LOCATION

# ── 2. Azure SQL ──────────────────────────────────────────────
az sql server create `
  --name $SQL_SERVER `
  --resource-group $RG `
  --location $LOCATION `
  --admin-user $SQL_ADMIN_USER `
  --admin-password $SQL_ADMIN_PASS

# Allow Azure services to connect (App Service → SQL)
az sql server firewall-rule create `
  --name AllowAzureServices `
  --resource-group $RG `
  --server $SQL_SERVER `
  --start-ip-address 0.0.0.0 `
  --end-ip-address 0.0.0.0

# Allow YOUR current IP for SSMS access
$MY_IP = (Invoke-RestMethod -Uri "https://api.ipify.org")
az sql server firewall-rule create `
  --name MyIP `
  --resource-group $RG `
  --server $SQL_SERVER `
  --start-ip-address $MY_IP `
  --end-ip-address $MY_IP

# Create database (Basic tier — free under Students credit)
az sql db create `
  --name $SQL_DB `
  --resource-group $RG `
  --server $SQL_SERVER `
  --edition Basic `
  --capacity 5

# ── 3. App Service (Backend API) ─────────────────────────────
az appservice plan create `
  --name $APP_SERVICE_PLAN `
  --resource-group $RG `
  --location $LOCATION `
  --sku B1 `
  --is-linux

az webapp create `
  --name $APP_NAME `
  --resource-group $RG `
  --plan $APP_SERVICE_PLAN `
  --runtime "DOTNETCORE:8.0"

# Enable system-assigned managed identity
az webapp identity assign `
  --name $APP_NAME `
  --resource-group $RG

# Force HTTPS
az webapp update `
  --name $APP_NAME `
  --resource-group $RG `
  --https-only true

# Set minimum TLS
az webapp config set `
  --name $APP_NAME `
  --resource-group $RG `
  --min-tls-version 1.2

# ── 4. Storage Account (Blob uploads) ────────────────────────
az storage account create `
  --name $STORAGE_ACCOUNT `
  --resource-group $RG `
  --location $LOCATION `
  --sku Standard_LRS `
  --kind StorageV2 `
  --min-tls-version TLS1_2 `
  --https-only true

# Create private blob container
az storage container create `
  --name uploads `
  --account-name $STORAGE_ACCOUNT `
  --public-access off

# Enable soft delete (7 days)
az storage blob service-properties delete-policy update `
  --account-name $STORAGE_ACCOUNT `
  --enable true `
  --days-retained 7

# Grant App Service managed identity "Storage Blob Data Contributor"
$APP_PRINCIPAL_ID = (az webapp identity show --name $APP_NAME --resource-group $RG --query principalId -o tsv)
$STORAGE_ID       = (az storage account show --name $STORAGE_ACCOUNT --resource-group $RG --query id -o tsv)

az role assignment create `
  --role "Storage Blob Data Contributor" `
  --assignee $APP_PRINCIPAL_ID `
  --scope $STORAGE_ID

# ── 5. Key Vault ─────────────────────────────────────────────
az keyvault create `
  --name $KEYVAULT_NAME `
  --resource-group $RG `
  --location $LOCATION `
  --enable-rbac-authorization true `
  --enable-soft-delete true `
  --enable-purge-protection true

# Grant App Service "Key Vault Secrets User"
$KV_ID = (az keyvault show --name $KEYVAULT_NAME --resource-group $RG --query id -o tsv)

az role assignment create `
  --role "Key Vault Secrets User" `
  --assignee $APP_PRINCIPAL_ID `
  --scope $KV_ID

# Grant YOURSELF "Key Vault Secrets Officer" so you can add secrets
$MY_OBJECT_ID = (az ad signed-in-user show --query id -o tsv)

az role assignment create `
  --role "Key Vault Secrets Officer" `
  --assignee $MY_OBJECT_ID `
  --scope $KV_ID

# ── 6. Print connection info ─────────────────────────────────
Write-Host ""
Write-Host "=== PROVISIONING COMPLETE ==="
Write-Host ""
Write-Host "SQL Server:  $SQL_SERVER.database.windows.net"
Write-Host "SQL DB:      $SQL_DB"
Write-Host "SQL Admin:   $SQL_ADMIN_USER"
Write-Host ""
Write-Host "App Service: https://$APP_NAME.azurewebsites.net"
Write-Host "Storage:     $STORAGE_ACCOUNT.blob.core.windows.net"
Write-Host "Key Vault:   https://$KEYVAULT_NAME.vault.azure.net"

After the script runs, verify in Azure Portal that all resources exist in rg-groundshare-prod-IL.


Step 7.2 — Deploy Database Schema

What you do: Connect to Azure SQL from SSMS and run the schema script.

  1. Open SSMS and connect to:

    • Server: sql-groundshare-il.database.windows.net
    • Auth: SQL Server Authentication
    • Login: groundshare-admin
    • Password: (the one you set above)
    • Database: GroundShareDB
  2. Open src/01-database/GroundShareDB.sql and execute it. This creates all tables and stored procedures.

  3. Do NOT run SeedDemoData.sql — production starts empty. The first user is created via the live /register endpoint.

  4. Verify:

    SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE';
    -- Should return the number of tables in your schema
    
    SELECT COUNT(*) FROM [user];
    -- Should return 0

Step 7.3 — Store Secrets in Key Vault

What you do: Add production secrets to Key Vault. These are the values the backend reads at runtime.

Key Vault uses -- as separator instead of :. So Jwt:Key becomes Jwt--Key.

$KEYVAULT_NAME  = "rg-groundshare-prod" # Key Vault name (same as Resource Group name)
$SQL_SERVER     = "sql-groundshare-il"
$SQL_DB         = "GroundShareDB"
$SQL_ADMIN_USER = "groundshare-admin"

# Generate a strong random JWT signing key (64 chars)
$JWT_KEY = [Convert]::ToBase64String((1..48 | ForEach-Object { [byte](Get-Random -Maximum 256) }))

# Set secrets (replace placeholders with YOUR real values)
az keyvault secret set --vault-name $KEYVAULT_NAME `
  --name "Jwt--Key" --value $JWT_KEY

az keyvault secret set --vault-name $KEYVAULT_NAME `
  --name "ConnectionStrings--DefaultConnection" `
  --value "Server=sql-groundshare-il.database.windows.net;Database=$SQL_DB;User Id=$SQL_ADMIN_USER;Password=<YOUR_SQL_PASSWORD>;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;ConnectRetryCount=3;ConnectRetryInterval=10;"

az keyvault secret set --vault-name $KEYVAULT_NAME `
  --name "Google--MapsApiKey" --value "<YOUR_GOOGLE_MAPS_KEY>"

az keyvault secret set --vault-name $KEYVAULT_NAME `
  --name "Google--GeminiApiKey" --value "<YOUR_GEMINI_KEY>"

az keyvault secret set --vault-name $KEYVAULT_NAME `
  --name "Google--ClientId" --value "<YOUR_GOOGLE_CLIENT_ID>"

# FCM push notifications — Firebase Admin SDK service account JSON
# How to get it:
#   Firebase Console → Project Settings → Service accounts → Generate new private key
#   Open the downloaded .json file, copy its entire contents (it's one long JSON object).
# The backend reads Fcm:ServiceAccountJson; Key Vault maps Fcm--ServiceAccountJson → Fcm:ServiceAccountJson.
$FCM_JSON = Get-Content "<PATH_TO_DOWNLOADED_SERVICE_ACCOUNT.json>" -Raw
az keyvault secret set --vault-name $KEYVAULT_NAME `
  --name "Fcm--ServiceAccountJson" --value $FCM_JSON

# Admin token for the clustering/relevance run-now endpoints (Admin:Token).
# WITHOUT this the daily cluster-daily.yml / relevance-daily.yml pokes get 401
# and — because the free tier has no Always On for the in-process timers —
# the clustering + relevance jobs never run at all. The same value must be
# stored as the CLUSTERING_ADMIN_TOKEN GitHub secret (see the secrets table).
$ADMIN_TOKEN = [Convert]::ToBase64String((1..32 | ForEach-Object { [byte](Get-Random -Maximum 256) }))
az keyvault secret set --vault-name $KEYVAULT_NAME `
  --name "Admin--Token" --value $ADMIN_TOKEN

Important: Use freshly rotated Google API keys (not the ones that were in git history). If you haven't rotated them yet, do it now in Google Cloud Console.


Step 7.4 — Configure App Service

What you do: Set the App Service environment variables that tell the backend where Key Vault is and which storage provider to use.

$RG               = "rg-groundshare-prod-IL"
$APP_NAME         = "app-groundshare-api"
$KEYVAULT_NAME    = "rg-groundshare-prod" # Key Vault name (accidentally named same as old RG name)
$STORAGE_ACCOUNT  = "stgroundshareil"

az webapp config appsettings set `
  --name $APP_NAME `
  --resource-group $RG `
  --settings `
    "AZURE_KEY_VAULT_URI=https://rg-groundshare-prod.vault.azure.net" `
    "Storage__Provider=Azure" `
    "Storage__AccountName=stgroundshareil" `
    "Storage__ContainerName=uploads" `
    "Storage__SasExpiryMinutes=60" `
    "Jwt__Issuer=GroundShareAPI" `
    "Jwt__Audience=GroundShareClient" `
    "Jwt__ExpireMinutes=15" `
    "AllowedHosts=$APP_NAME.azurewebsites.net" `
    "Cors__AllowedOrigins__0=capacitor://localhost" `
    "Cors__AllowedOrigins__1=http://localhost" `
    "Cors__AllowedOrigins__2=https://localhost"

Note: Jwt:Key, ConnectionStrings:DefaultConnection, and Google keys are NOT set here — they come from Key Vault via the AZURE_KEY_VAULT_URI config provider.

CORS origins capacitor://localhost (iOS), https://localhost (Android, Capacitor 7+ default), and http://localhost (legacy Android / cleartext fallback) are required for Capacitor native apps to reach the API.


Step 7.5 — Deploy Backend

What you do: Publish and deploy the .NET API to App Service using "Run From Package" mode.

Why Run From Package? /home/site/wwwroot on Linux App Service is a locked mounted network share — you can't rm -rf the folder itself, only files inside. If a previous deploy ever left corrupted files (e.g. names with backslashes from a Windows-built zip), normal rsync deploys will keep failing forever because Kudu can't overwrite them. WEBSITE_RUN_FROM_PACKAGE=1 mounts the new zip directly as a read-only filesystem, completely bypassing the broken wwwroot. Bonus: deploys become faster and atomic.

⚠️ Startup command is pinned to the assembly name. app-groundshare-api has an explicit App Service Startup Command of dotnet GroundShareAPI.dll (the assembly name is GroundShareAPI, set in GroundShareAPI.csproj). If you ever rename the assembly, you must update the startup command in the Portal (App Service → Configuration → General settings → Startup Command) or the site returns 503 on the next deploy. Check it with: az webapp config show --name app-groundshare-api --resource-group rg-groundshare-prod-IL --query appCommandLine -o tsv.

One-time setup (run once per App Service):

az webapp config appsettings set `
  --name app-groundshare-api `
  --resource-group rg-groundshare-prod-IL `
  --settings WEBSITE_RUN_FROM_PACKAGE="1"

Every deploy:

cd C:\Users\Yuval\source\repos\Ovalvoi\GroundShare\src\02-server

# 1. Clean old artifacts so stale files don't leak into the zip
Remove-Item -Recurse -Force ./publish, ./publish-linux, ./deploy.zip, ./deploy-linux.zip -ErrorAction SilentlyContinue

# 2. Publish for Linux (drops runtimes\win\* — those backslash paths break Kudu)
dotnet publish -c Release -r linux-x64 --self-contained false -o ./publish-linux

# 3. Create the zip with .NET's ZipFile (NOT PowerShell's Compress-Archive,
#    which writes backslash path separators that Linux rejects)
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::CreateFromDirectory(
    (Resolve-Path ./publish-linux).Path,
    (Join-Path (Get-Location) 'deploy-linux.zip')
)

# 4. Deploy — with WEBSITE_RUN_FROM_PACKAGE=1, this mounts the zip instead
#    of running rsync, so it's immune to the corrupted-wwwroot problem
az webapp deploy `
  --name app-groundshare-api `
  --resource-group rg-groundshare-prod-IL `
  --src-path ./deploy-linux.zip `
  --type zip

Verify:

  • Visit https://app-groundshare-api.azurewebsites.net/api/health — should return {"status":"ok",...}

Step 7.5b — Deploy Frontend (App Service, Node host)

What you do: Build the Vite app and deploy dist/ + server.cjs to the second App Service (app-groundshare-web).

Why App Service and not Static Web Apps? The subscription is region-locked to regions where SWA isn't offered. So we host the static build on a Linux App Service behind src/03-client/deploy/server.cjs — a zero-dependency Node server that does SPA fallback (React Router deep links), injects the security headers (X-Frame-Options, nosniff, HSTS, Permissions-Policy, CSP), and sets the cache policy (immutable hashed assets; no-cache sw.js/index.html so PWA updates propagate on deploy).

⚠️ The App Service has an explicit Startup Command (node /home/site/wwwroot/server.cjs) that overrides the deploy zip's npm start. If server.cjs is renamed or moved, update the Startup Command too (az webapp config set --startup-file ... + restart) — otherwise the old process keeps serving after a green deploy. This is how the previous pm2 serve host silently survived the first server.cjs deploy.

The frontend must be built with VITE_API_BASE_URL=https://api.groundshare.app/apisame-site with the web app so the refresh cookie is sent (see the architecture note at the top). The CD pipeline does this automatically; for a manual build set it in the build env.

cd src/03-client
npm ci

# Build with the production API base + Firebase web-push vars
$env:VITE_API_BASE_URL = "https://api.groundshare.app/api"
# (set $env:VITE_FIREBASE_* too if you want web push)
npm run build

# Package dist/ + server.cjs with a minimal package.json (the App Service
# Startup Command runs node server.cjs directly; npm start is a fallback)
Copy-Item -Recurse dist deploy-frontend
Copy-Item deploy/server.cjs deploy-frontend/server.cjs
@'
{ "name": "groundshare-web", "version": "1.0.0", "private": true,
  "scripts": { "start": "node server.cjs" } }
'@ | Out-File -Encoding utf8 deploy-frontend/package.json
Compress-Archive -Path deploy-frontend/* -DestinationPath deploy-frontend.zip -Force

az webapp deploy `
  --name app-groundshare-web `
  --resource-group rg-groundshare-prod-IL `
  --src-path ./deploy-frontend.zip `
  --type zip

Verify:

  • Visit https://groundshare.app (or the app-groundshare-web-*.azurewebsites.net host) — the app should load and refresh should keep you logged in.
  • curl -I https://groundshare.app must show X-Frame-Options, HSTS, and the CSP header. If instead you see Access-Control-Allow-Origin: * and no security headers, the old process is still serving — check the Startup Command (see the warning above).

Step 7.5c — Custom domain (Cloudflare)

groundshare.app and api.groundshare.app are managed in Cloudflare DNS (GroundShare owns the domain). The two records:

  • groundshare.app → CNAME/proxy to app-groundshare-web-*.azurewebsites.net
  • api.groundshare.app → CNAME/proxy to app-groundshare-api.azurewebsites.net

Both must be added as custom domains on their App Service (Portal → App Service → Custom domains → Add) and bound to a managed certificate. Keeping the web app and API under the same registrable domain (groundshare.app) is what makes the SameSite=Lax refresh cookie work — do not skip the api. subdomain in favor of the raw Azure host. Transactional email also uses noreply@groundshare.app (domain verified in Resend).


Step 7.5d — GitHub Actions CD (recommended)

.github/workflows/cd.yml deploys both App Services on every push to main (after CI passes), then runs post-deploy health checks. Each job is gated by a GitHub repository variable so you can land code before the Azure side is ready without turning CI red:

Gate (Variables tab) Effect
DEPLOY_BACKEND_ENABLED=true enables the backend deploy job
DEPLOY_FRONTEND_ENABLED=true enables the frontend deploy job

If a variable is unset or != "true", that job is skipped (the run stays green).

Required GitHub secrets (Settings → Secrets and variables → Actions):

# Backend publish profile
az webapp deployment list-publishing-profiles `
  --name app-groundshare-api --resource-group rg-groundshare-prod-IL --xml
# → paste into secret AZURE_WEBAPP_PUBLISH_PROFILE

# Frontend publish profile
az webapp deployment list-publishing-profiles `
  --name app-groundshare-web --resource-group rg-groundshare-prod-IL --xml
# → paste into secret AZURE_WEBAPP_FRONTEND_PUBLISH_PROFILE

The frontend build reads VITE_FIREBASE_* from GitHub repo variables (not secrets — Firebase web keys are public by design) and hard-codes VITE_API_BASE_URL=https://api.groundshare.app/api.


Step 7.6 — Add Capacitor to Frontend

What you do: Add Capacitor to your existing React/Vite app. This wraps dist/ into native iOS and Android projects.

Install Capacitor:

cd src/03-client

npm install @capacitor/core @capacitor/cli
npx cap init "GroundShare" "com.groundshare.app" --web-dir dist

npm install @capacitor/ios @capacitor/android

Configure API URL:

Create or update src/03-client/capacitor.config.ts:

import type { CapacitorConfig } from '@capacitor/cli';

const config: CapacitorConfig = {
  appId: 'com.groundshare.app',
  appName: 'GroundShare',
  webDir: 'dist',
  server: {
    // Production API
    url: 'https://app-groundshare-api.azurewebsites.net',
    cleartext: false,        // HTTPS only
  },
};

export default config;

Important: Remove the server.url block above if your app already reads the API URL from an environment variable or api.ts config. The server.url field redirects ALL requests — you only need it if the app uses relative URLs.

Build and sync:

npm run build
npx cap add ios
npx cap add android
npx cap sync

Install useful Capacitor plugins:

npm install @capacitor/geolocation @capacitor/camera @capacitor/filesystem @capacitor/splash-screen @capacitor/status-bar
npx cap sync

Step 7.7 — Build and Test on Devices

Android:

npx cap open android

This opens Android Studio. From there:

  1. Click Run (green play button) with a device/emulator selected
  2. App loads → login → map should render

iOS (requires macOS):

npx cap open ios

This opens Xcode. From there:

  1. Select your team/signing certificate
  2. Pick a simulator or physical device
  3. Click Run

Test checklist:

  • Register new account
  • Login, see map with your location
  • Search an address, see planning data
  • Create an event with photo (camera capture)
  • View nearby reports
  • Compare two addresses
  • Toggle favorite
  • Update settings / home address
  • App runs in standalone mode (no browser chrome)
  • Geolocation permission prompt appears and works
  • Camera permission prompt appears and works

Step 7.8 — Monitoring Setup

What you do: Set up an alert in Azure Portal.

  1. Go to Application Insights > appi-groundshare > Alerts > Create alert rule
  2. Condition: Server exceptions > 5 in 5 minutes
  3. Action: Email notification to your email
  4. Save

Also check the Live Metrics blade while running smoke tests to see requests flowing.

Note: Application Insights is optional. If you skipped it during provisioning, monitoring still works via App Service's built-in Log stream and Diagnose and solve problems blades.


Step 7.9 — Post-Deploy Security Audit

What you do: Run these checks against your live API.

Manual:

  • Account enumeration: register with existing email → same error as wrong password
  • Brute force: try login 6 times rapidly → 429 after 5th
  • XSS: create event with title <script>alert(1)</script> → rendered as text
  • CORS: Invoke-WebRequest -Uri "https://app-groundshare-api.azurewebsites.net/api/health" -Headers @{ Origin = "https://evil.com" } -Verbose → no Access-Control-Allow-Origin header
  • Upload: try uploading a .exe renamed to .jpg → rejected
  • TLS: visit ssllabs.com/ssltest with app-groundshare-api.azurewebsites.net → target A+

Nuke and Start Over

If you need to delete everything and start fresh:

az group delete --name rg-groundshare-prod-IL --yes --no-wait
# Runs in background, takes ~2-3 min
# Check status: az group show --name rg-groundshare-prod-IL --query properties.provisioningState -o tsv

Quick Reference — All Resource Names

Resource Name URL
Resource Group rg-groundshare-prod-IL
SQL Server sql-groundshare-il sql-groundshare-il.database.windows.net
SQL Database GroundShareDB
App Service (API) app-groundshare-api https://api.groundshare.app (via Cloudflare) · https://app-groundshare-api.azurewebsites.net
App Service (Web) app-groundshare-web https://groundshare.app (via Cloudflare) · …-ekfneqfaahaca8ek.israelcentral-01.azurewebsites.net
Storage Account stgroundshareil stgroundshareil.blob.core.windows.net
Key Vault rg-groundshare-prod https://rg-groundshare-prod.vault.azure.net
DNS / proxy Cloudflare groundshare.app, api.groundshare.app

GitHub Secrets & Variables Needed

Kind Name Source / value
Secret AZURE_WEBAPP_PUBLISH_PROFILE az webapp deployment list-publishing-profiles --name app-groundshare-api … --xml
Secret AZURE_WEBAPP_FRONTEND_PUBLISH_PROFILE az webapp deployment list-publishing-profiles --name app-groundshare-web … --xml
Secret CLUSTERING_ADMIN_TOKEN Same value as the Admin--Token Key Vault secret (Step 7.3). Used by cluster-daily.yml and relevance-daily.yml to poke the admin run-now endpoints
Variable DEPLOY_BACKEND_ENABLED / DEPLOY_FRONTEND_ENABLED true to enable each CD job
Variable VITE_FIREBASE_* Firebase web-push config (public; baked into the frontend build)