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) andapp-groundshare-web(Node host serving the Vitedist/via the zero-dependencysrc/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 theapi.subdomain are fronted by Cloudflare, which keeps the web app and the API same-site so the httpOnlySameSite=Laxrefresh cookie is sent on the boot/auth/refresh(pointing the frontend at the raw*.azurewebsites.netAPI host makes the cookie cross-site → withheld → logout on every reload).
- Azure for Students subscription (active)
- Azure CLI installed:
winget install Microsoft.AzureCLI - Logged in:
az login - GitHub repo with push access to
mainbranch - Node.js 18+ and npm
- For iOS builds: macOS with Xcode 15+
- For Android builds: Android Studio with SDK 34+
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
groundsharebelow 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.
What you do: Connect to Azure SQL from SSMS and run the schema script.
-
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
- Server:
-
Open
src/01-database/GroundShareDB.sqland execute it. This creates all tables and stored procedures. -
Do NOT run SeedDemoData.sql — production starts empty. The first user is created via the live
/registerendpoint. -
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
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:. SoJwt:KeybecomesJwt--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_TOKENImportant: 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.
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 theAZURE_KEY_VAULT_URIconfig provider.CORS origins
capacitor://localhost(iOS),https://localhost(Android, Capacitor 7+ default), andhttp://localhost(legacy Android / cleartext fallback) are required for Capacitor native apps to reach the API.
What you do: Publish and deploy the .NET API to App Service using "Run From Package" mode.
Why Run From Package?
/home/site/wwwrooton Linux App Service is a locked mounted network share — you can'trm -rfthe 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=1mounts 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-apihas an explicit App Service Startup Command ofdotnet GroundShareAPI.dll(the assembly name isGroundShareAPI, set inGroundShareAPI.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.
az webapp config appsettings set `
--name app-groundshare-api `
--resource-group rg-groundshare-prod-IL `
--settings WEBSITE_RUN_FROM_PACKAGE="1"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- Visit
https://app-groundshare-api.azurewebsites.net/api/health— should return{"status":"ok",...}
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-cachesw.js/index.htmlso PWA updates propagate on deploy).
⚠️ The App Service has an explicit Startup Command (node /home/site/wwwroot/server.cjs) that overrides the deploy zip'snpm start. Ifserver.cjsis 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 previouspm2 servehost silently survived the firstserver.cjsdeploy.
The frontend must be built with VITE_API_BASE_URL=https://api.groundshare.app/api
— same-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- Visit
https://groundshare.app(or theapp-groundshare-web-*.azurewebsites.nethost) — the app should load and refresh should keep you logged in. curl -I https://groundshare.appmust showX-Frame-Options, HSTS, and the CSP header. If instead you seeAccess-Control-Allow-Origin: *and no security headers, the old process is still serving — check the Startup Command (see the warning above).
groundshare.app and api.groundshare.app are managed in Cloudflare DNS
(GroundShare owns the domain). The two records:
groundshare.app→ CNAME/proxy toapp-groundshare-web-*.azurewebsites.netapi.groundshare.app→ CNAME/proxy toapp-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).
.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_PROFILEThe 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.
What you do: Add Capacitor to your existing React/Vite app. This wraps dist/ into native iOS and Android projects.
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/androidCreate 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.urlblock above if your app already reads the API URL from an environment variable orapi.tsconfig. Theserver.urlfield redirects ALL requests — you only need it if the app uses relative URLs.
npm run build
npx cap add ios
npx cap add android
npx cap syncnpm install @capacitor/geolocation @capacitor/camera @capacitor/filesystem @capacitor/splash-screen @capacitor/status-bar
npx cap syncnpx cap open androidThis opens Android Studio. From there:
- Click Run (green play button) with a device/emulator selected
- App loads → login → map should render
npx cap open iosThis opens Xcode. From there:
- Select your team/signing certificate
- Pick a simulator or physical device
- Click Run
- 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
What you do: Set up an alert in Azure Portal.
- Go to Application Insights > appi-groundshare > Alerts > Create alert rule
- Condition:
Server exceptions> 5 in 5 minutes - Action: Email notification to your email
- 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.
What you do: Run these checks against your live API.
- 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→ noAccess-Control-Allow-Originheader - Upload: try uploading a
.exerenamed to.jpg→ rejected - TLS: visit ssllabs.com/ssltest with
app-groundshare-api.azurewebsites.net→ target A+
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| 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 |
| 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) |