Deploy to Azure
Complete guide for PSI web applications — architecture, deployment, networking, auth, secrets, and CI/CD.
Canonical compliance standard: Start with webapp-compliance-standard before making PSI web app changes.
DNS is the #1 cause of deploy failures here — read the standard first
Before you publish a web app or add/change any app’s DNS, you MUST read dns-standards — the canonical reference for the public zone, the privatelink two-zone rule (Azure Private DNS and the AD domain controllers), deployment-slot DNS, the FQDN-for-on-prem rule, and the Cisco Umbrella interception gotcha. The command-level how-to lives in §5 and §10 below; the rules live there.
Quick Reference
0. PSI Web Apps Overview
PSI web applications provide browser-based access to internal tools, replacing or complementing desktop ProApps. All web apps are internal-only (VPN/onsite), Azure-hosted, auto-deployed via GitHub Actions, and share a unified PSI visual identity.
Application Inventory
| Application | URL | Repository | Status |
|---|---|---|---|
| PSI Portal | portal.progressivesurface.com | ProgressiveSurface/psi-portal | Live |
| PSI Explorer | explorer.progressivesurface.com | ProgressiveSurface/psi-explorer-web | Live |
| Redbook Dashboard | redbook.progressivesurface.com | ProgressiveSurface/redbook-web | Live |
| Quality Analytics | quality.progressivesurface.com | ProgressiveSurface/redbook-dashboard | Live |
| ERP Migration Tool | dmt.progressivesurface.com | ProgressiveSurface/erp-migration-tool | Live |
| Project Explorer | projects.progressivesurface.com | ProgressiveSurface/ps-project-explorer | Live |
| MES | psmes.progressivesurface.com | ProgressiveSurface/PRGJSMES | Live |
| UniData API | api.progressivesurface.com | ProgressiveSurface/PSI.UniData.API | Live |
| PSI Wiki | wiki.progressivesurface.com | ProgressiveSurface/PSI-Wiki-Site | Live |
| Procisely | procisely.com | ProgressiveSurface/procisely-redirect | Live |
Technology Stack
Frontend: React 19 + TypeScript + Vite + TailwindCSS v4 + Lucide React icons
- State: React hooks (simple apps) or React Query (API-heavy apps)
- React version: React 19 is the standard for new apps. Some existing apps (PSI Explorer, Redbook, Project Explorer, Argo Analytics, ERP Migration) are still on React 18 and migrate opportunistically — don’t assume an existing repo is on 19; check its
package.json.
Backend (API): ASP.NET Core 8 + UniData via U2 Toolkit for .NET
- Auth: Windows Authentication (NTLM) for API; Swagger/OpenAPI docs
Infrastructure: Azure App Service (Linux, Node 24 LTS) + GitHub Actions CI/CD
- SSL: Wildcard certificate (
*.progressivesurface.com) - DNS: Azure DNS Zone
- Security: Private endpoints, public access denied
- Region: North Central US
Design System
All UI uses the PSI Design System — tokens, components, and patterns live in the psi-design-system repo (assets/ps.css). Consume the --ps-* custom properties rather than hardcoding a palette.
| Token | Hex | Usage |
|---|---|---|
--ps-green-500 | 027A54 | Primary — actions, headers, buttons (brand green) |
--ps-ink | 1A1D1F | Primary text |
--ps-bg | F6F7F5 | Page background |
--ps-surface | FFFFFF | Cards, panels |
Fonts: the design system self-hosts Inter + JetBrains Mono (ps-fonts.css + vendored .woff2) — no Google Fonts CDN. This matters here: these App Services run behind private endpoints with no public egress, so a CDN font @import would fail silently and fall back to system fonts.
Note: earlier docs referenced a “PSI Blue”
#284b63palette — that predates the green design system and is superseded by#027A54. Don’t anchor to it.
User Access
PSI web apps are registered as Enterprise Applications in Entra ID, appearing in the M365 app launcher (waffle menu) and my.apps.microsoft.com.
| App | Entra App ID |
|---|---|
| PSI Explorer | db5621e9-f3db-495b-ae14-f11d18ba8ad6 |
| PSI Portal | 7f929c7f-2483-4206-93b6-11225e07ca85 |
The PSI Portal serves as the central landing page with app cards, status indicators, and quick stats.
Network Architecture
┌─────────────────────────────────────────────────────────────────────┐
│ Azure Cloud │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ PS-WEBAPPS Resource Group │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ psi-portal │ │ bom-explorer│ │ redbook-web │ ... │ │
│ │ │ :443 │ │ -web :443 │ │ :443 │ │ │
│ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │
│ │ │ │ │ │ │
│ └─────────┼────────────────┼────────────────┼──────────────────┘ │
│ │ │ │ │
│ ┌─────────┴────────────────┴────────────────┴─────────────────┐ │
│ │ PS-SERVERS Subnet (10.160.0.0/23) │ │
│ │ Private Endpoints (internal IPs) │ │
│ │ portal: 10.160.0.6 | explorer: 10.160.0.17 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ progressivesurface.com DNS Zone │ │
│ │ portal. → 10.160.0.6 (A record) │ │
│ │ explorer. → 10.160.0.17 (A record) │ │
│ │ redbook. → [IP] (A record) │ │
│ │ quality. → 10.160.140.14 (A record) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────┬──────────────────────────────────────┘
│
│ VPN / Site-to-Site
│
┌──────────────────────────────┴──────────────────────────────────────┐
│ PSI On-Premises Network │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Users │ │ PS-PROXY │ │ AFTEC Server│ │
│ │ (Browsers) │ │ (API Host) │ │ (UniData) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
1. Decision Framework
Which Azure Service Should I Use?
Do you need a backend/API?
|
+------ No -----+------ Yes -----+
| |
v v
+--------------------+ +--------------------+
| STATIC WEB APPS | | APP SERVICE |
| | | |
| - React/Vue SPA | | - Python/Node API |
| - Static sites | | - Streamlit |
| - Documentation | | - Full-stack apps |
| | | - WebSockets |
| Free - $9/month | | $13 - $138/month |
+--------------------+ +--------------------+
Simple rule: If your app is just HTML/CSS/JS that runs in the browser, use Static Web Apps. If you need server-side code, use App Service.
Quick Comparison
| Feature | Static Web Apps | App Service |
|---|---|---|
| Best for | SPAs, static sites | Full-stack, APIs |
| Runtime | Static files + Functions | Python, Node, .NET, Java |
| Cost | Free - $9/mo | $13 - $138/mo |
| CI/CD | GitHub Actions (auto) | Git push / Actions |
| Auth | Built-in Entra ID | EasyAuth / MSAL |
| Custom domain | Yes | Yes |
| WebSockets | No | Yes |
| SSL | Free (auto) | Free (managed) or Key Vault wildcard |
| Private endpoints | No | Yes |
PSI Examples
| App | Type | Azure Service | URL |
|---|---|---|---|
| PSI-Wiki-Site | Static (Quartz) | Static Web Apps | wiki.progressivesurface.com |
| PSI Portal | React SPA (internal) | App Service + Private Endpoint | portal.progressivesurface.com |
| PSI Explorer | React SPA (internal) | App Service + Private Endpoint | explorer.progressivesurface.com |
| Redbook Dashboard | Python + Streamlit | App Service | redbook.progressivesurface.com |
| Project Explorer | React SPA (internal) | App Service + Private Endpoint | projects.progressivesurface.com |
| MES | .NET (Windows, internal) | App Service + Private Endpoint | psmes.progressivesurface.com |
2. Naming Conventions
Resource Naming Pattern
[company]-[env]-[type]-[name]
Components:
company = ps (Progressive Surface)
env = dev | staging | prod (optional for single-env)
type = swa | app | func | kv | rg | insights
name = descriptive name (lowercase, no spaces)
Examples
| Resource | Name |
|---|---|
| Resource Group | PS-WEBAPPS |
| App Service Plan | ps-redbook-plan |
| App Service | ps-redbook-dashboard |
| Static Web App | ps-wiki-site |
| Key Vault | ps-certificates-kv |
| Key Vault (certs) | ps-certificates-kv |
| Storage Account | psiredbookfeedback |
| Application Insights | ps-redbook-insights |
| Private Endpoint | bom-explorer-web-pe |
3. Azure Static Web Apps
Use for: SPAs, static documentation sites, JAMstack apps
Architecture
+--------------------------------------------------------------------------+
| STATIC WEB APPS DEPLOYMENT FLOW |
+--------------------------------------------------------------------------+
| |
| DEVELOPER GITHUB AZURE |
| ========================================================================|
| |
| +----------+ git push +--------------+ |
| | Local | -------------->| Repository | |
| | Dev | | (main branch)| |
| +----------+ +------+-------+ |
| | |
| | Triggers |
| v |
| +--------------+ |
| | GitHub | |
| | Actions | |
| | Workflow | |
| +------+-------+ |
| | |
| | npm ci |
| | npm run build |
| | |
| v |
| +--------------+ +------------------+ |
| | Built | | Azure Static | |
| | Assets |----->| Web Apps | |
| | (dist/) | | | |
| +--------------+ | +--------------+ | |
| | | CDN Edge | | |
| | | (Global) | | |
| | +--------------+ | |
| | | |
| | +--------------+ | |
| | | Entra ID | | |
| | | Auth | | |
| | +--------------+ | |
| +------------------+ |
| | |
| +----------+ | |
| | User |<-------------- HTTPS -----------------------+ |
| | Browser | |
| +----------+ |
| |
+--------------------------------------------------------------------------+
Step 1: Create Static Web App
# Via Azure CLI
az staticwebapp create \
--name ps-prod-swa-yourappname \
--resource-group PS-WEBAPPS \
--location eastus2 \
--sku Standard
# Or use Azure Portal:
# Search "Static Web Apps" -> Create -> Connect to GitHub repoStep 2: GitHub Actions Workflow
Create .github/workflows/azure-static-web-apps.yml:
name: Azure Static Web Apps CI/CD
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened, closed]
branches: [main]
jobs:
build_and_deploy_job:
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed')
runs-on: ubuntu-latest
name: Build and Deploy
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
env:
VITE_CLIENT_ID: ${{ secrets.VITE_CLIENT_ID }}
VITE_TENANT_ID: ${{ secrets.VITE_TENANT_ID }}
- name: Deploy to Azure Static Web Apps
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
repo_token: ${{ secrets.GITHUB_TOKEN }}
action: "upload"
app_location: "/"
api_location: ""
output_location: "dist"
close_pull_request_job:
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: ubuntu-latest
name: Close Pull Request
steps:
- name: Close Pull Request
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
action: "close"Step 3: Static Web App Configuration
Create staticwebapp.config.json in your project root:
{
"routes": [
{
"route": "/*",
"allowedRoles": ["authenticated"]
}
],
"navigationFallback": {
"rewrite": "/index.html",
"exclude": ["/assets/*", "*.{css,js,png,jpg,svg,ico}"]
},
"auth": {
"identityProviders": {
"azureActiveDirectory": {
"registration": {
"openIdIssuer": "https://login.microsoftonline.com/a83ae943-0a50-49cc-83c3-479b7a44b7fb/v2.0",
"clientIdSettingName": "AAD_CLIENT_ID",
"clientSecretSettingName": "AAD_CLIENT_SECRET"
}
}
}
},
"globalHeaders": {
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"X-XSS-Protection": "1; mode=block",
"Referrer-Policy": "strict-origin-when-cross-origin"
},
"platform": {
"apiRuntime": "node:18"
}
}Step 4: Configure Authentication Settings
# Add Entra ID credentials to Static Web App
az staticwebapp appsettings set \
--name ps-prod-swa-yourappname \
--setting-names \
AAD_CLIENT_ID="your-client-id" \
AAD_CLIENT_SECRET="your-client-secret"4. Azure App Service
Use for: Full-stack apps, Python/Node APIs, Streamlit dashboards
Architecture
+--------------------------------------------------------------------------+
| APP SERVICE DEPLOYMENT FLOW |
+--------------------------------------------------------------------------+
| |
| DEVELOPER GIT AZURE |
| ========================================================================|
| |
| +----------+ |
| | Local | |
| | Dev | |
| | | |
| | - Code | |
| | - Test | |
| | - Data | |
| +----+-----+ |
| | |
| | git push origin main +--------------+ |
| +------------------------->| GitHub | (Backup) |
| | | Repository | |
| | +--------------+ |
| | |
| | workflow_dispatch/main +--------------+ +----------------+ |
| +------------------------->| GitHub |---->| az webapp | |
| | (SCM) | | Engine | |
| +--------------+ | | |
| | - pip install | |
| | - npm install | |
| | - Build | |
| +-------+--------+ |
| | |
| v |
| +----------------+ |
| | App Service | |
| | | |
| | +------------+ | |
| | | Web App | | |
| | | Container | | |
| | +------------+ | |
| | | |
| | +------------+ | |
| | | Entra ID | | |
| | | EasyAuth | | |
| | +------------+ | |
| | | |
| | +------------+ | |
| | | Key Vault | | |
| | | (Secrets) | | |
| | +------------+ | |
| +-------+--------+ |
| | |
| +----------+ | |
| | User |<-------------- HTTPS --------------------------+ |
| | Browser | |
| +----------+ |
| |
+--------------------------------------------------------------------------+
Step 1: Create App Service Plan and Web App
# Create App Service Plan (Linux, B1 tier for dev/small apps)
az appservice plan create \
--name ps-yourapp-plan \
--resource-group PS-WEBAPPS \
--sku B1 \
--is-linux
# Create Web App
az webapp create \
--name ps-yourapp-dashboard \
--resource-group PS-WEBAPPS \
--plan ps-yourapp-plan \
--runtime "PYTHON|3.11"
# For Node: --runtime "NODE:24-lts"
# For .NET: --runtime "DOTNETCORE|8.0"Step 2: Configure CI/CD Deployment (GitHub Actions)
# Ensure publishing creds stay disabled (PSI production baseline)
az rest --method put \
--uri "https://management.azure.com/subscriptions/<SUB_ID>/resourceGroups/PS-WEBAPPS/providers/Microsoft.Web/sites/ps-yourapp-dashboard/basicPublishingCredentialsPolicies/scm?api-version=2022-03-01" \
--body '{"properties":{"allow":false}}'
az rest --method put \
--uri "https://management.azure.com/subscriptions/<SUB_ID>/resourceGroups/PS-WEBAPPS/providers/Microsoft.Web/sites/ps-yourapp-dashboard/basicPublishingCredentialsPolicies/ftp?api-version=2022-03-01" \
--body '{"properties":{"allow":false}}'
# Use GitHub Actions + identity-based deploy (no publish profile)
GH_HOST=progressivesurface.ghe.com gh workflow run deploy.yml \
-R ProgressiveSurface/your-repo --ref mainExample deploy step in workflow:
- name: Azure Login (Managed Identity)
run: az login --identity
- name: Deploy package (async + poll)
run: |
set -euo pipefail
# --async true: az hands the package to OneDeploy and returns. Do NOT
# use --async false (or the deprecated `webapp deployment source
# config-zip`) for anything but a tiny app — see the warning below.
az webapp deploy \
--name ps-yourapp-dashboard --resource-group PS-WEBAPPS \
--src-path server-deploy.zip --type zip --async true
TOKEN=$(az account get-access-token --resource https://management.azure.com --query accessToken -o tsv)
SCM="https://ps-yourapp-dashboard.scm.azurewebsites.net/api/deployments/latest"
for i in $(seq 1 90); do
sleep 10
RESP=$(curl -s --max-time 30 -H "Authorization: Bearer $TOKEN" "$SCM" || echo '{}')
STATUS=$(echo "$RESP" | python3 -c "import sys,json;print(json.load(sys.stdin).get('status','?'))" 2>/dev/null || echo '?')
COMPLETE=$(echo "$RESP" | python3 -c "import sys,json;print(json.load(sys.stdin).get('complete',False))" 2>/dev/null || echo '?')
[ "$COMPLETE" = "True" ] && [ "$STATUS" = "4" ] && { echo "Deployed."; exit 0; }
[ "$COMPLETE" = "True" ] && [ "$STATUS" = "3" ] && { echo "::error::Kudu deploy failed"; exit 1; }
done
echo "::error::Deploy did not complete in 15 min"; exit 1
- name: Verify site is reachable
# Be generous: after Kudu reports deployed, App Service still swaps and
# cold-starts the container. 12 minutes covers the swap.
run: |
for i in $(seq 1 48); do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 https://yourapp.progressivesurface.com/api/health || echo "000")
case "$STATUS" in 200|401|403) echo "Reachable ($STATUS)"; exit 0;; esac
sleep 15
done
echo "Health gate failed"; exit 1
timeout-minutes: 14Do not deploy synchronously — the 4-minute 504
az webapp deploy --async false(and the deprecatedwebapp deployment source config-zip) hold one synchronous HTTP call open to*.scm.azurewebsites.netfor the entire Kudu extraction + container restart. Azure Front Door caps that call at ~4 minutes — any app whose deploy runs longer gets a504 GatewayTimeout, and worse, the deploy is left half-applied. PSI DataSync hit this hard on 2026-05-18: three synchronous deploys in quick succession all 504’d, one left Kudu mid-extraction, and the site crash-looped for ~18 minutes.Always use
--async trueand poll the Kudu deployment record (/api/deployments/latest:status 4= success,3= failed) as shown above. The slow phase then runs server-side with nothing waiting synchronously through it.
Step 3: Trigger Deployment and Watch Run
# Trigger manually when needed (supports one-app-at-a-time validation)
GH_HOST=progressivesurface.ghe.com gh workflow run deploy.yml \
-R ProgressiveSurface/your-repo --ref main
# Follow the latest run
GH_HOST=progressivesurface.ghe.com gh run list \
-R ProgressiveSurface/your-repo --workflow deploy.yml --limit 1
GH_HOST=progressivesurface.ghe.com gh run watch <run-id> -R ProgressiveSurface/your-repoStep 4: Configure Startup Command
For Python/Streamlit apps:
az webapp config set \
--name ps-yourapp-dashboard \
--resource-group PS-WEBAPPS \
--startup-file "python -m streamlit run app.py \
--server.port 8000 \
--server.address 0.0.0.0 \
--server.headless true \
--browser.gatherUsageStats false"For Node.js apps:
az webapp config set \
--name ps-yourapp-dashboard \
--resource-group PS-WEBAPPS \
--startup-file "npm start"Step 5: Configure Application Settings
az webapp config appsettings set \
--name ps-yourapp-dashboard \
--resource-group PS-WEBAPPS \
--settings \
ADMIN_PASSWORD="@Microsoft.KeyVault(VaultName=ps-certificates-kv;SecretName=YourApp--AdminPassword)" \
AZURE_STORAGE_CONNECTION_STRING="@Microsoft.KeyVault(VaultName=ps-certificates-kv;SecretName=YourApp--StorageConnectionString)" \
DEV_USER_EMAIL="localdev@progressivesurface.com" \
WEBSITES_PORT=80004b. Deployment Slots (Blue-Green Deploys)
PSI-specific: When an App Service uses
publicNetworkAccess=Disabled(i.e. all PSI internal apps following the private-endpoint pattern in §5), adding a deployment slot requires three additional setup steps that the slot does not inherit from the main slot. PRGJSMES hit all three the first time it added astagingslot — this section captures them so future apps don’t have to.
Why use slots
Slots enable blue-green deploys via slot swap: build → deploy to staging slot → smoke test → atomic swap to production. Rollback = re-swap (the previous code is still alive in the slot). Zero-downtime. Required tier: Standard or higher (Premium recommended for prod). See CD for the full pattern in action.
What a slot does NOT inherit from the main app
When you create a slot with --configuration-source <main-app>, you get a copy of app settings + connection strings only. You do not get:
| Resource | Inherited? | Why it matters |
|---|---|---|
Custom domains (psmes.progressivesurface.com) | ❌ No | Slot only has <app>-<slot>.azurewebsites.net |
| Private endpoint | ❌ No | Slot is unreachable from inside VNet without its own PE |
| Privatelink DNS records | ❌ No (auto-registered for the new PE only if zone group attached) | Without records, runner can’t resolve <app>-<slot>.scm.azurewebsites.net |
| DNS records on PS-AZ-DC01 / PS-GR-DC02 | ❌ No (manual step) | Without DC records, runner queries fail (DCs are the actual resolver, not Azure DNS) |
| System-assigned Managed Identity | ❌ No — slot has its own MI | Slot’s MI starts with zero DB access, app crashes 503 with CanConnectAsync failure |
| App Service basic publishing creds policy | Yes (inherits) | — |
| VNet integration | Yes (inherits from plan) | — |
Setup checklist for a new slot
When publicNetworkAccess=Disabled, run these in order. Each is required.
1. Create the slot
az webapp deployment slot create \
-g PS-WEBAPPS -n <app-name> \
--slot staging \
--configuration-source <app-name>2. Create a private endpoint for the slot
The slot’s Web (and SCM) endpoints are a separate sub-resource of the App Service, so they need a separate PE targeting the sites-<slotname> group ID. Use the next sequentially available IP in the PS-ProdData subnet (see Private Endpoint Subnet Allocation).
SITE_ID=$(az webapp show -g PS-WEBAPPS -n <app-name> --query id -o tsv)
SUBNET_ID="/subscriptions/<sub-id>/resourceGroups/PS-RG-01/providers/Microsoft.Network/virtualNetworks/PS-VNMAIN/subnets/PS-ProdData"
az network private-endpoint create \
--resource-group PS-WEBAPPS \
--name <app-name>-staging-pe \
--subnet "$SUBNET_ID" \
--private-connection-resource-id "$SITE_ID" \
--group-id sites-staging \
--connection-name <app-name>-staging-connGroup ID for slots is
sites-<slotname>, notsites. This is the most-missed step.
3. Link the PE to the privatelink DNS zone
The Azure private DNS zone is automatically populated when you attach a zone group:
DNS_ZONE_ID="/subscriptions/<sub-id>/resourceGroups/PS-RG-01/providers/Microsoft.Network/privateDnsZones/privatelink.azurewebsites.net"
az network private-endpoint dns-zone-group create \
--resource-group PS-WEBAPPS \
--endpoint-name <app-name>-staging-pe \
--name default \
--private-dns-zone "$DNS_ZONE_ID" \
--zone-name privatelink.azurewebsites.net4. Add DNS records on the AD DCs (PS-AZ-DC01 + PS-GR-DC02)
Slots without a custom domain need two DNS layers on the DC, depending on who’s connecting:
4a. Privatelink zone records — for traffic from inside the VNet
VNet clients (CI runner, App Service, other Azure resources) resolve <app-name>-<slot>.azurewebsites.net by following the public CNAME chain that ends at <app-name>-<slot>.privatelink.azurewebsites.net. The DC’s privatelink.azurewebsites.net primary zone provides the final A record.
# Run on PS-GR-DC02 (or PS-AZ-DC01 — AD-integrated zone replicates)
Add-DnsServerResourceRecordA `
-ZoneName "privatelink.azurewebsites.net" `
-Name "<app-name>-<slot>" `
-IPv4Address "<PE-IP>" `
-TimeToLive 01:00:00
Add-DnsServerResourceRecordA `
-ZoneName "privatelink.azurewebsites.net" `
-Name "<app-name>-<slot>.scm" `
-IPv4Address "<PE-IP>" `
-TimeToLive 01:00:004b. Per-app primary zone — for browser access from outside the VNet (PSI office / VPN)
The privatelink CNAME chain only resolves correctly when public DNS sees the privatelink.* CNAME on the slot. For deployment slots (no custom domain bound), Azure’s public CNAME chain does not always end at privatelink — meaning a query from your laptop via the PSI VPN gets the public IP and hits a 403 Forbidden from publicNetworkAccess=Disabled.
Fix: create a per-app primary zone on the DC that short-circuits the lookup directly to the private IP. This matches the existing PSI pattern used for ps-redbook-dashboard.azurewebsites.net (verify with Get-DnsServerZone -Name "ps-redbook-dashboard.azurewebsites.net").
# Run on PS-GR-DC02 (replicates to PS-AZ-DC01)
# Apex zone for the slot's web endpoint
Add-DnsServerPrimaryZone `
-Name "<app-name>-<slot>.azurewebsites.net" `
-ReplicationScope Domain
Add-DnsServerResourceRecordA `
-ZoneName "<app-name>-<slot>.azurewebsites.net" `
-Name "@" `
-IPv4Address "<PE-IP>" `
-TimeToLive 01:00:00
# Apex zone for the slot's SCM (Kudu / deploy) endpoint
Add-DnsServerPrimaryZone `
-Name "<app-name>-<slot>.scm.azurewebsites.net" `
-ReplicationScope Domain
Add-DnsServerResourceRecordA `
-ZoneName "<app-name>-<slot>.scm.azurewebsites.net" `
-Name "@" `
-IPv4Address "<PE-IP>" `
-TimeToLive 01:00:00Verify both layers
Wait for AD replication (15 min within site, up to 3 hours cross-site) or run repadmin /syncall /AeP to force. Verify from any VNet machine and from a PSI VPN-connected laptop:
Resolve-DnsName -Name "<app-name>-<slot>.azurewebsites.net" -Server "10.160.0.5"
# Should return <PE-IP>, NOT a public Azure IP
Resolve-DnsName -Name "<app-name>-<slot>.scm.azurewebsites.net" -Server "10.160.0.5"
# Should return <PE-IP>If the per-app primary zone exists, it takes precedence and the privatelink CNAME chain is bypassed entirely. The privatelink zone records remain useful for VNet-internal CNAME resolution if the per-app zone is ever removed. Both layers can coexist without conflict.
Workstation gotcha: Cisco Umbrella intercepts DNS
Verifying slot DNS from a PSI workstation can mislead you — Cisco Umbrella intercepts the query (even with -Server) and hands back the public chain while the DC has the right answer. Verify from the DC or a VNet machine instead. Full explanation + fix options: workstation-gotcha-cisco-umbrella.
5. Enable system-assigned Managed Identity on the slot
az webapp identity assign \
-g PS-WEBAPPS -n <app-name> --slot staging
# Capture the principalId from the output6. Grant the slot’s MI access to dependencies (SQL, Key Vault, etc.)
For Azure SQL with Authentication=Active Directory Default:
-- Run against your DB as a db_owner / Entra admin
CREATE USER [<app-name>/slots/staging] FROM EXTERNAL PROVIDER;
ALTER ROLE db_ddladmin ADD MEMBER [<app-name>/slots/staging];
ALTER ROLE db_datareader ADD MEMBER [<app-name>/slots/staging];
ALTER ROLE db_datawriter ADD MEMBER [<app-name>/slots/staging];The user name format is literal:
<appname>/slots/<slotname>. This is how Azure exposes a slot’s MI to Azure SQL.
For Key Vault, grant the slot’s principalId Reader / Secret User access at the appropriate scope.
7. Verify end-to-end
After all 6 steps, run a deploy via the slot-swap workflow. The deploy should:
- Reach the staging SCM endpoint over the VNet (proves DNS + PE)
- Land the build (proves slot-level publishing perms)
- Smoke test against
<app-name>-staging.azurewebsites.net/api/healthand get 200 (proves slot’s MI can reach SQL) - Swap into production (proves swap mechanics)
Symptoms when a step is missing
| Step missed | Failure mode |
|---|---|
| 2 (PE) | Deploy fails immediately: 403 Forbidden from SCM (slot is unreachable) |
| 3 (DNS zone group) | Records missing in Azure private DNS zone — runner sees Name or service not known |
| 4a (DC privatelink records) | Same Name or service not known — runner can’t resolve via CNAME chain |
| 4b (DC per-app primary zone) | CI deploys work, but browser access from PSI office / VPN gets 403 x-ms-forbidden-ip — your machine hit Azure’s public IP because public DNS didn’t return a privatelink CNAME |
| 5 (MI) | App boots but health endpoint returns 503 (CanConnectAsync fails) |
| 6 (SQL grant) | App boots but health returns 503 (MI exists but can’t auth to SQL) |
If you see a 503 from a freshly-deployed slot, check the slot’s MI exists and has the right SQL/KV grants before going deeper.
Reference: PRGJSMES staging slot setup
| Resource | Value |
|---|---|
| Main slot PE | prgjsmes-prod-pe → 10.160.140.11 |
| Staging slot PE | prgjsmes-prod-staging-pe → 10.160.140.18 |
| Main slot MI | 54c0f248-7538-431b-b743-20bbd9f58a1a |
| Staging slot MI | bb2cc61f-587c-4589-a8c9-9c02c973095e |
| SQL user for staging | prgjsmes-prod/slots/staging (db_ddladmin + db_datareader + db_datawriter) |
| Privatelink zone records (DC layer 4a) | prgjsmes-prod-staging + .scm → 10.160.140.18 in privatelink.azurewebsites.net zone |
| Per-app primary zones (DC layer 4b) | prgjsmes-prod-staging.azurewebsites.net (apex @ → 10.160.140.18) and prgjsmes-prod-staging.scm.azurewebsites.net (apex @ → 10.160.140.18) |
| Azure private DNS zone records | Auto-registered by the PE’s DNS zone group in ps-rg-01/privatelink.azurewebsites.net |
5. PSI Network Architecture (Private Endpoints)
PSI-specific: This section applies to internal-only web applications accessed through the PSI VPN. All production internal apps should use private endpoints to prevent public internet exposure.
Architecture Overview
Internal Users (VPN) --> DNS (10.160.0.5) --> Private Endpoint IP --> Azure App Service
|
VNet: PS-VNMAIN
Subnet: PS-SERVERS (private endpoints)
Subnet: PS-WebApps (VNet integration)
+-----------------------------------------------------------------------------+
| PSI NETWORK ARCHITECTURE |
+-----------------------------------------------------------------------------+
| |
| INTERNET AZURE CLOUD |
| ======================================================================== |
| |
| +--------------+ +-------------------------------------+ |
| | External | HTTPS/443 | Azure Front Door / App Gateway | |
| | Users |---------------->| (WAF, DDoS Protection, SSL) | |
| | (Browser) | +----------------+--------------------+ |
| +--------------+ | |
| | v |
| | +-------------------------------------+ |
| | | Azure App Services / Static Web | |
| | | +---------+ +---------+ +-------+ | |
| v | | Wiki | | Redbook | |Project| | |
| +--------------+ | | SWA | | AppSvc | |Explr | | |
| | Microsoft |<----------------| +---------+ +---------+ +-------+ | |
| | Entra ID | Auth callback +----------------+--------------------+ |
| | (SSO) | | |
| +--------------+ | Managed Identity |
| v |
| +-------------------------------------+ |
| | Azure Key Vault | |
| | (Secrets, Certificates, Keys) | |
| +-------------------------------------+ |
| | |
| ======================================================================== |
| HYBRID CONNECTION (Site-to-Site VPN / ExpressRoute) |
| ======================================================================== |
| | |
| ON-PREMISE (Progressive Surface HQ) | |
| +---------------------------------------------+---+---------------------+ |
| | | |
| | +--------------+ +--------------+ +--------------+ | |
| | | Firewall | | Domain | | File Shares | | |
| | | (Cisco/ |<---->| Controller |<---->| (DFS) | | |
| | | Fortinet) | | (AD Sync) | | | | |
| | +--------------+ +--------------+ +--------------+ | |
| | | | | | |
| | v v v | |
| | +--------------+ +--------------+ +--------------+ | |
| | | AFTEC | | PDM Vault | | SQL Server | | |
| | | (UniData ERP)| | (SolidWorks) | | (On-Prem DB) | | |
| | | | | | | | | |
| | +------+-------+ +------+-------+ +--------------+ | |
| | | | | |
| | | Nightly Export | CAD Metadata | |
| | v v | |
| | +--------------------------------------------+ | |
| | | Data Pipeline Server | | |
| | | (Python scripts, scheduled tasks) | | |
| | | - XML/CSV exports -> Azure Blob | | |
| | | - ETL transformations | | |
| | | - AI enrichment (Azure OpenAI) | | |
| | +--------------------------------------------+ | |
| | | | |
| +------------------------+----------------------------------------------+ |
| | |
| v Upload (HTTPS) |
| +-------------------------------------+ |
| | Azure Blob Storage | |
| | (Processed data for web apps) | |
| +-------------------------------------+ |
| |
+------------------------------------------------------------------------------+
DATA FLOW LEGEND:
------------------
--> HTTPS (encrypted web traffic)
<--> Bidirectional sync
| Internal network connection
Data Sources and Systems
| System | Type | Data | Access Method |
|---|---|---|---|
| AFTEC | ERP (UniData) | Orders, Projects, Hours | Nightly XML export |
| PDM Vault | SolidWorks | CAD metadata, BOMs | Python API / Export |
| Redbook | Manufacturing | Quality issues, NCNs | CSV export + AI pipeline |
| Active Directory | Identity | Users, Groups | Azure AD Sync |
Prerequisites
- Azure CLI authenticated (
az login) - GitHub CLI authenticated (
gh auth login -h progressivesurface.ghe.com) - Access to PS-WEBAPPS resource group
- Access to ps-rg-01 resource group (DNS, VNet)
Deployment Checklist
| Step | Description | Required |
|---|---|---|
| 1 | Create App Service | Yes |
| 2 | Configure VNet Integration | Yes |
| 3 | Create Private Endpoint | Yes |
| 3b | Add Privatelink DNS Records (Azure Private DNS zone and DC primary zone) | Yes |
| 4 | Disable Public Access | Yes |
| 5 | Add Custom Domain | Yes |
| 6 | Add DNS Verification TXT | Yes |
| 7 | Create DNS A Record (internal IP) | Yes |
| 8 | Enable HTTPS Only | Yes |
| 9 | Bind Wildcard SSL Certificate | Yes |
| 10 | Configure API CORS | Yes (if calling API) |
| 11 | Update Wiki Documentation | Yes |
Private Endpoint Subnet Allocation
Private endpoints are allocated from the PS-ProdData subnet (10.160.140.0/24). IPs were assigned sequentially as endpoints were created rather than by service type. The current allocation is:
| IP Address | Service | Type |
|---|---|---|
| 10.160.140.4 | procserv-proddata (SQL) | SQL Server |
| 10.160.140.5 | ps-buildvsbuy 🚧 | App Service (Web) |
| 10.160.140.6 | psi-tap-bridge | Function App |
| 10.160.140.7 | PSTestHub | IoT Hub |
| 10.160.140.9 | erp-migration-api | App Service (API) |
| 10.160.140.10 | ps-project-explorer | App Service (Web) |
| 10.160.140.11 | prgjsmes-prod | App Service (Web) |
| 10.160.140.12 | redbook-web | App Service (Web) |
| 10.160.140.13 | prgjsmes-prod (⚠️ duplicate PE — see azure-resources) | App Service (Web) |
| 10.160.140.14 | ps-redbook-dashboard | App Service (Web) |
| 10.160.140.15 | ps-progressive-view | App Service (Web) |
| 10.160.140.16 | ps-argo-analytics | App Service (Web) |
| 10.160.140.17 | ps-winget-source | App Service (Web) |
| 10.160.140.18 | prgjsmes-prod (staging slot) | App Service slot |
| 10.160.140.21 | psi-zebra-tracking-sql | SQL Server |
| 10.160.140.22 | ps-dispatch | App Service (Web) |
| 10.160.140.23 | ps-certificates-kv | Key Vault (PE) |
| 10.160.140.24 | psi-service | App Service (Web) |
| 10.160.140.25+ | Available for new endpoints | — |
Convention: Assign the next available IP sequentially — the next free address is 10.160.140.25. ⚠ The allocation is not webapp-only:
.23is theps-certificates-kvprivate endpoint (in theprivatelink.vaultcore.azure.netzone, so it isn’t visible from theprivatelink.azurewebsites.netrecords) — always check live NIC allocations across the whole subnet, not just the webapp DNS zone, before pinning an IP. (psi-service hit this on 2026-06-29:.23looked free in the azurewebsites zone but was already taken by the KV PE.) The legacy PS-SERVERS subnet (10.160.0.0/23) also hosts older private endpoints (psi-portal.6,bom-explorer-web.17,psi-datasync.19,psargostorage.11) — new endpoints should use PS-ProdData. Verified against live Azure 2026-06-29.
Current Private Endpoint Assignments
| Service | Private Endpoint Name | IP Address | Subnet |
|---|---|---|---|
| psi-portal | psi-portal-pe | 10.160.0.6 | PS-SERVERS |
| shippingappfunctions | — | 10.160.0.16 | PS-SERVERS |
| bom-explorer-web | bom-explorer-web-pe | 10.160.0.17 | PS-SERVERS |
| psi-tap-bridge | psi-tap-bridge-pe | 10.160.140.6 | PS-ProdData |
| erp-migration-api | ERPMigrationAPIEndpoint | 10.160.140.9 | PS-ProdData |
| ps-project-explorer | ps-project-explorer-pe | 10.160.140.10 | PS-ProdData |
| prgjsmes-prod | prgjsmes-prod-pe | 10.160.140.11 | PS-ProdData |
| redbook-web | redbook-web-pe | 10.160.140.12 | PS-ProdData |
| ps-redbook-dashboard | ps-redbook-dashboard-pe | 10.160.140.14 | PS-ProdData |
| ps-progressive-view | ps-progressive-view-pe | 10.160.140.15 | PS-ProdData |
| ps-argo-analytics | ps-argo-analytics-pe | 10.160.140.16 | PS-ProdData |
| psi-service | psi-service-pe | 10.160.140.24 | PS-ProdData |
Note: Future private endpoints should use PS-ProdData subnet. When adding a new endpoint, also add privatelink DNS records — see Step 4b Add Privatelink DNS Records and DNS Configuration for Private Endpoints.
Step 1: Create App Service (if not exists)
# Create App Service Plan (if needed)
az appservice plan create \
--name asp-your-app \
--resource-group PS-WEBAPPS \
--sku B1 \
--is-linux
# Create Web App
az webapp create \
--name your-app-name \
--resource-group PS-WEBAPPS \
--plan asp-your-app \
--runtime "NODE:24-lts"Step 2: Configure VNet Integration
# Add VNet integration (outbound traffic)
az webapp vnet-integration add \
--name your-app-name \
--resource-group PS-WEBAPPS \
--vnet PS-VNMAIN \
--subnet PS-WebAppsFQDN Required: When your App Service needs to reach on-premises servers (e.g., PS-PROXY, AFTEC), you must use the fully-qualified domain name (
ps-proxy.ad.ptihome.com) — not the short hostname (ps-proxy). App Service Linux containers don’t have thead.ptihome.comDNS search domain. See PSI-Specific Issues for details.
Step 3: Create Private Endpoint
# Get App Service resource ID
APP_ID=$(az webapp show -n your-app-name -g PS-WEBAPPS --query "id" -o tsv)
# Create private endpoint in PS-SERVERS subnet
# Note: PS-WebApps subnet is delegated and cannot host private endpoints
MSYS_NO_PATHCONV=1 az network private-endpoint create \
--name your-app-name-pe \
--resource-group PS-WEBAPPS \
--subnet "/subscriptions/1f3a4b35-1cf1-4fea-af63-b3fc0d11acdf/resourceGroups/PS-RG-01/providers/Microsoft.Network/virtualNetworks/PS-VNMAIN/subnets/PS-SERVERS" \
--private-connection-resource-id "$APP_ID" \
--group-id sites \
--connection-name your-app-name-connection \
--location "North Central US"Note the private endpoint IP from the output (customDnsConfigs[0].ipAddresses[0]).
Step 4: Disable Public Network Access
Two methods exist for blocking public access:
Method 1: publicNetworkAccess=Disabled (recommended for private-endpoint apps)
Fully blocks public network at the resource level. No exceptions possible.
MSYS_NO_PATHCONV=1 az resource update \
--ids "$APP_ID" \
--set properties.publicNetworkAccess=DisabledMethod 2: Access restriction rules (when you need selective access) Uses access restriction rules — blocks by default but allows adding exceptions (e.g., specific IPs).
az webapp config access-restriction set --name your-app-name \
--resource-group PS-WEBAPPS --default-action DenyUse Method 1 for all private-endpoint apps. Use Method 2 only when you need to allow specific public IPs (e.g., GitHub Actions runners) while blocking general access.
Step 4b: Add Privatelink DNS Records
Required when using
publicNetworkAccess=Disabledwith self-hosted GitHub Actions runners. Without these records, the runner cannot resolveyour-app-name.scm.azurewebsites.netto deploy.
The privatelink.azurewebsites.net private DNS zone (in ps-rg-01) is linked to PS-VNMAIN. When a machine on the VNet resolves your-app-name.azurewebsites.net, Azure DNS appends privatelink and checks this zone. If no record exists, the name resolves to the public IP — which is blocked.
Create two A records: one for the app, one for the SCM (deployment) endpoint:
# App record
az network private-dns record-set a create \
-g ps-rg-01 -z privatelink.azurewebsites.net \
-n your-app-name --ttl 3600
az network private-dns record-set a add-record \
-g ps-rg-01 -z privatelink.azurewebsites.net \
-n your-app-name -a "10.160.0.XX" # Use your private endpoint IP
# SCM (deployment) record — REQUIRED for GitHub Actions deploys
az network private-dns record-set a create \
-g ps-rg-01 -z privatelink.azurewebsites.net \
-n your-app-name.scm --ttl 3600
az network private-dns record-set a add-record \
-g ps-rg-01 -z privatelink.azurewebsites.net \
-n your-app-name.scm -a "10.160.0.XX" # Same PE IPCurrent Privatelink DNS Records
| Record | IP | Zone |
|---|---|---|
bom-explorer-web | 10.160.0.17 | privatelink.azurewebsites.net |
bom-explorer-web.scm | 10.160.0.17 | privatelink.azurewebsites.net |
psi-portal | 10.160.0.6 | privatelink.azurewebsites.net |
psi-portal.scm | 10.160.0.6 | privatelink.azurewebsites.net |
ps-project-explorer | 10.160.140.10 | privatelink.azurewebsites.net |
ps-project-explorer.scm | 10.160.140.10 | privatelink.azurewebsites.net |
erp-migration-api | 10.160.140.9 | privatelink.azurewebsites.net |
erp-migration-api.scm | 10.160.140.9 | privatelink.azurewebsites.net |
prgjsmes-prod | 10.160.140.11 | privatelink.azurewebsites.net |
prgjsmes-prod.scm | 10.160.140.11 | privatelink.azurewebsites.net |
redbook-web | 10.160.140.12 | privatelink.azurewebsites.net |
redbook-web.scm | 10.160.140.12 | privatelink.azurewebsites.net |
ps-redbook-dashboard | 10.160.140.14 | privatelink.azurewebsites.net |
ps-redbook-dashboard.scm | 10.160.140.14 | privatelink.azurewebsites.net |
ps-argo-analytics | 10.160.140.16 | privatelink.azurewebsites.net |
ps-argo-analytics.scm | 10.160.140.16 | privatelink.azurewebsites.net |
prgjsmes-prod-staging | 10.160.140.18 | privatelink.azurewebsites.net |
prgjsmes-prod-staging.scm | 10.160.140.18 | privatelink.azurewebsites.net |
psi-service | 10.160.140.24 | privatelink.azurewebsites.net |
psi-service.scm | 10.160.140.24 | privatelink.azurewebsites.net |
⚠ Gap:
ps-progressive-view(10.160.140.15) — Azure Private DNS zone records added (2026-03-24). DC primary zone records still needed before runner can deploy.Note: The PSI Explorer repo was renamed from
bom-explorer-webtopsi-explorer-web, but the Azure App Service resource name remainsbom-explorer-web. All DNS and PE records correctly usebom-explorer-webto match the Azure resource name.
Verify:
nslookup your-app-name.azurewebsites.net 10.160.0.5should return the private endpoint IP when resolved from the VNet.
Step 5: Add Custom Domain
# Get domain verification ID
VERIFY_ID=$(az webapp show -n your-app-name -g PS-WEBAPPS --query "customDomainVerificationId" -o tsv)
# Create verification TXT record
az network dns record-set txt create \
-g ps-rg-01 -z progressivesurface.com \
-n "asuid.yourapp" --ttl 3600
az network dns record-set txt add-record \
-g ps-rg-01 -z progressivesurface.com \
-n "asuid.yourapp" -v "$VERIFY_ID"
# Add custom domain to App Service
az webapp config hostname add \
--webapp-name your-app-name \
--resource-group PS-WEBAPPS \
--hostname yourapp.progressivesurface.comStep 6: Create DNS A Record (Internal IP)
# Create A record pointing to private endpoint IP
az network dns record-set a create \
-g ps-rg-01 -z progressivesurface.com \
-n "yourapp" --ttl 3600
az network dns record-set a add-record \
-g ps-rg-01 -z progressivesurface.com \
-n "yourapp" -a "10.160.0.XX" # Use your private endpoint IPStep 7: Enable HTTPS Only
az webapp update \
--name your-app-name \
--resource-group PS-WEBAPPS \
--https-only trueStep 8: Bind Wildcard SSL Certificate (from Key Vault)
# Enable managed identity
az webapp identity assign --name your-app-name --resource-group PS-WEBAPPS
# Get principal ID and grant Key Vault access
PRINCIPAL_ID=$(az webapp show -n your-app-name -g PS-WEBAPPS --query "identity.principalId" -o tsv)
az keyvault set-policy --name ps-certificates-kv --object-id $PRINCIPAL_ID --secret-permissions get --certificate-permissions get
# Import certificate from Key Vault
az webapp config ssl import \
--name your-app-name \
--resource-group PS-WEBAPPS \
--key-vault ps-certificates-kv \
--key-vault-certificate-name wildcard-progressivesurface
# Bind the certificate (SNI)
az webapp config ssl bind \
--name your-app-name \
--resource-group PS-WEBAPPS \
--certificate-thumbprint 8ECD7C39FA4BD44E10D3D89A80EF33F3922A291A \
--ssl-type SNINote: The certificate thumbprint remains the same whether imported from Key Vault or uploaded directly.
Verification Commands
After deployment, verify the configuration:
# Check VNet integration
az webapp vnet-integration list -n your-app-name -g PS-WEBAPPS -o table
# Check hostnames
az webapp config hostname list --webapp-name your-app-name -g PS-WEBAPPS -o table
# Check SSL bindings
az webapp show -n your-app-name -g PS-WEBAPPS --query "hostNameSslStates" -o table
# Test DNS resolution (from internal network)
nslookup yourapp.progressivesurface.com 10.160.0.5Infrastructure Summary
VNet: PS-VNMAIN
| Subnet | CIDR | Purpose |
|---|---|---|
| PS-SERVERS | 10.160.0.0/23 | Private endpoints, VMs |
| PS-WebApps | 10.160.150.0/24 | App Service VNet integration (delegated) |
| PS-ProdData | 10.160.140.0/24 | Private endpoints (preferred) |
| PS-VMXSBNT | 10.160.50.0/24 | VMs |
DNS
| Server | IP | Purpose |
|---|---|---|
| ps-az-dc01.ad.ptihome.com | 10.160.0.5 | Primary DNS (Azure DC) |
| 192.9.200.110 | 192.9.200.110 | Secondary DNS (on-prem DC) |
| 168.63.129.16 | 168.63.129.16 | Azure DNS wireserver (privatelink resolution) |
Self-Hosted Runner: ps-cicd-runner
| Setting | Value |
|---|---|
| VM Name | ps-cicd-runner |
| IP | 10.160.0.9 |
| Subnet | PS-SERVERS |
| OS | Ubuntu Linux |
| Runner Labels | [self-hosted, psi-internal] |
| Auth | Azure Managed Identity |
Used by: ERP Migration Tool, PSI Explorer, Project Explorer, PRGJSMES, PSI.UniData.API
Administering the runner (no SSH key)
The runner has no SSH key on file — admin access uses Azure RunCommand, which authenticates via your az login identity. No password, no key.
# Run any shell command as root
MSYS_NO_PATHCONV=1 az vm run-command invoke \
-g PS-RG-01 -n ps-cicd-runner \
--command-id RunShellScript \
--scripts "<your command>"Example (one-time install of Playwright Chromium system deps for E2E tests, done 2026-05-11). The list below is the complete canonical set — the initial install missed libasound2t64 (ALSA, required even in headless) and several transitively-loaded libs, which caused the first E2E run on PR #69 to fail with libasound.so.2: cannot open shared object file:
MSYS_NO_PATHCONV=1 az vm run-command invoke \
-g PS-RG-01 -n ps-cicd-runner \
--command-id RunShellScript \
--scripts "DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y -qq libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libgbm1 libgtk-3-0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libxss1 libxkbcommon0 libasound2t64 libnss3 libnspr4 libdbus-1-3 libatspi2.0-0t64 libxext6 libxcb1 libx11-6 libpango-1.0-0 libcairo2 fonts-liberation"To verify after install, the chrome-headless-shell binary under /home/runner/.cache/ms-playwright/ should run --version cleanly and ldd <binary> | grep 'not found' should return nothing.
Runner DNS Configuration
The runner uses systemd-resolved with two drop-in configs in /etc/systemd/resolved.conf.d/: ad-domain.conf (pins ad.ptihome.com / ptihome.com to the DCs) and privatelink.conf (routes privatelink.* to Azure DNS, 168.63.129.16).
Privatelink resolution has two paths, and a new app needs records in both zones to cover both (see DNS Configuration for Private Endpoints below):
- The deploy runner resolves through its
privatelink.confdrop-in → Azure DNS (168.63.129.16), so it relies on the Azure Private DNS zone record. This is what unblocks deploys. - Other VNet/VPN clients (Windows machines, etc. — no drop-in) resolve the
azurewebsites.nethostname via the DCs, so they rely on the DC AD-integrated primary-zone record.
Verified 2026-06-25: with only the Azure-zone record present, the runner resolved
ps-buildvsbuy.scm.azurewebsites.net→ 10.160.140.5 correctly while the DCs still returnedNXDOMAIN(DC record pending replication). So the runner does not depend on the DC record — but other clients do. Create both.
AD domain fallback issue: Some public DNS records (e.g., api.progressivesurface.com) use CNAMEs pointing to internal AD hostnames (e.g., ps-proxy.ad.ptihome.com). When the DC DNS is slow or briefly unreachable, systemd-resolved falls back to Azure DNS (168.63.129.16), which resolves ad.ptihome.com names to incorrect public IPs. This causes intermittent TLS failures.
Fix: A drop-in file pins AD domain queries to the DCs:
# /etc/systemd/resolved.conf.d/ad-domain.conf
# Route AD domain queries to internal DCs — never fall back to Azure DNS
# Prevents ps-proxy.ad.ptihome.com from resolving to public IPs
[Resolve]
DNS=10.160.0.5 192.9.200.110
Domains=~ad.ptihome.com ~ptihome.comAfter changes: sudo systemctl restart systemd-resolved
DNS Resolution Chain (example)
api.progressivesurface.com (public zone)
→ CNAME: ps-proxy.ad.ptihome.com
→ ~ad.ptihome.com routes to DC (10.160.0.5)
→ A: 192.9.201.217 (PS-PROXY internal IP via VPN) ✓
bom-explorer-web.scm.azurewebsites.net
→ CNAME: psi-explorer-web.scm.privatelink.azurewebsites.net
→ DC checks local authoritative zone for privatelink.azurewebsites.net
→ A: 10.160.0.17 (private endpoint IP) ✓
Wildcard Certificate (Key Vault)
The wildcard SSL certificate is stored in Azure Key Vault for centralized management.
| Property | Value |
|---|---|
| Key Vault | ps-certificates-kv |
| Certificate Name | wildcard-progressivesurface |
| Subject | *.progressivesurface.com |
| Thumbprint | 8ECD7C39FA4BD44E10D3D89A80EF33F3922A291A |
| Expires | 2027-02-03 |
| Vault URI | https://ps-certificates-kv.vault.azure.net/ |
Key Vault Access Policies
| Principal | Permissions |
|---|---|
| Azure Web Sites (abfa0a7c-a6b6-4736-8310-5855508787cd) | Get secrets, Get certificates |
| App Service Managed Identities | Get secrets, Get certificates |
Reference Deployment: PSI Explorer Web
Completed 2026-02-04:
| Setting | Value |
|---|---|
| App Service | psi-explorer-web |
| Custom Domain | explorer.progressivesurface.com |
| Private Endpoint | bom-explorer-web-pe |
| Internal IP | 10.160.0.17 |
| Subnet | PS-SERVERS |
| SSL | Wildcard cert (SNI) |
| Public Access | Disabled |
6. Authentication with Entra ID
Authentication Flow
+--------------------------------------------------------------------------+
| ENTRA ID AUTHENTICATION FLOW |
+--------------------------------------------------------------------------+
| |
| +----------+ +--------------+ |
| | User | | Microsoft | |
| | Browser | | Entra ID | |
| +----+-----+ +------+-------+ |
| | | |
| | 1. Request protected page | |
| | GET /dashboard | |
| v | |
| +-----------------+ | |
| | Web App | | |
| | (EasyAuth) | | |
| +--------+--------+ | |
| | | |
| | 2. No session found | |
| | 302 Redirect to Entra ID | |
| | | |
| +---+------------------------------------------------------> |
| | | |
| | 3. User authenticates at Microsoft login | |
| | (Username + Password + MFA) | |
| | | |
| |<-------------------------------------------------------+ |
| | 4. Redirect back with auth code | |
| | 302 to /callback?code=xxx | |
| | | |
| v | |
| +-----------------+ | |
| | Web App |-----------------------------------------------> |
| | (EasyAuth) | 5. Exchange code for tokens | |
| | |<----------------------------------------------+ |
| | | 6. Return access + ID tokens | |
| +--------+--------+ | |
| | | |
| | 7. Create session, set cookie | |
| | | |
| <---+ | |
| | | |
| | 8. Return protected content | |
| | 200 OK /dashboard | |
| v | |
| +----------+ | |
| | User | Session valid for 8 hours | |
| | Browser | (cookie-based) | |
| +----------+ | |
| |
+--------------------------------------------------------------------------+
App Registration Checklist
Before enabling authentication, create an App Registration in Azure Portal:
- Go to Azure Portal → Microsoft Entra ID → App registrations
- Click New registration
- Name:
PSI [AppName] Dashboard - Supported account types: Accounts in this organizational directory only
- Redirect URI:
- Static Web App:
https://your-app.azurewebsites.net/.auth/login/aad/callback - App Service:
https://your-app.azurewebsites.net/.auth/login/aad/callback
- Static Web App:
- Create a Client Secret (copy immediately - shown only once)
- Note the Application (client) ID and Directory (tenant) ID
PSI Tenant Information
Tenant ID: a83ae943-0a50-49cc-83c3-479b7a44b7fb
Domain: progressivesurface.com
Sign-in: https://login.microsoftonline.com/a83ae943-0a50-49cc-83c3-479b7a44b7fb
Enable EasyAuth on App Service
Option A: CLI Commands (New Apps)
If the app has never had auth configured, the CLI commands work:
# Enable Entra ID authentication
az webapp auth microsoft update \
--name your-app-name \
--resource-group PS-WEBAPPS \
--client-id "your-client-id" \
--client-secret "your-client-secret" \
--issuer "https://login.microsoftonline.com/a83ae943-0a50-49cc-83c3-479b7a44b7fb/v2.0" \
--allowed-audiences "api://your-client-id"
# Require authentication for all requests
az webapp auth update \
--name your-app-name \
--resource-group PS-WEBAPPS \
--enabled true \
--action LoginWithAzureActiveDirectoryOption B: REST API (Recommended — Works Always)
Important: The CLI commands above fail with
Cannot use auth v2 commands when the app is using auth v1if the app has any existing v1 auth config (even if disabled). Use the REST API to bypass this:
# Replace <SUB_ID>, <APP_NAME>, and <CLIENT_ID> with your values
az rest --method PUT \
--url "/subscriptions/<SUB_ID>/resourceGroups/PS-WEBAPPS/providers/Microsoft.Web/sites/<APP_NAME>/config/authsettingsV2?api-version=2021-02-01" \
--body '{
"properties": {
"platform": { "enabled": true },
"globalValidation": {
"unauthenticatedClientAction": "RedirectToLoginPage",
"redirectToProvider": "azureactivedirectory"
},
"identityProviders": {
"azureActiveDirectory": {
"enabled": true,
"registration": {
"openIdIssuer": "https://login.microsoftonline.com/a83ae943-0a50-49cc-83c3-479b7a44b7fb/v2.0",
"clientId": "<CLIENT_ID>",
"clientSecretSettingName": "MICROSOFT_PROVIDER_AUTHENTICATION_SECRET"
},
"validation": {
"allowedAudiences": ["api://<CLIENT_ID>"]
}
}
},
"login": {
"tokenStore": { "enabled": true }
}
}
}'
# Then set the client secret as an app setting
az webapp config appsettings set \
--name <APP_NAME> \
--resource-group PS-WEBAPPS \
--settings "MICROSOFT_PROVIDER_AUTHENTICATION_SECRET=<CLIENT_SECRET>"EasyAuth Headers
After authentication, Azure injects these headers into every request reaching your app:
| Header | Content | Example |
|---|---|---|
X-MS-CLIENT-PRINCIPAL-NAME | User email (UPN) | jdoe@progressivesurface.com |
X-MS-CLIENT-PRINCIPAL-ID | Azure Object ID | a1b2c3d4-... |
X-MS-CLIENT-PRINCIPAL | Base64-encoded JSON with full claims | (see below) |
The X-MS-CLIENT-PRINCIPAL header decodes to:
{
"auth_typ": "aad",
"claims": [
{ "typ": "name", "val": "John Doe" },
{ "typ": "preferred_username", "val": "jdoe@progressivesurface.com" },
{ "typ": "oid", "val": "a1b2c3d4-..." }
]
}EasyAuth Pattern: Express.js (Recommended for App Service)
For full-stack Node apps, EasyAuth is the simplest approach — no MSAL library needed. Add middleware to read the injected headers:
// Auth middleware — reads EasyAuth headers (Azure) or defaults for local dev
app.use((req, res, next) => {
const principalName = req.headers["x-ms-client-principal-name"]
if (principalName) {
req.user = {
email: principalName,
name: principalName,
id: req.headers["x-ms-client-principal-id"] || null,
}
const principalHeader = req.headers["x-ms-client-principal"]
if (principalHeader) {
try {
const decoded = JSON.parse(Buffer.from(principalHeader, "base64").toString("utf8"))
const claims = decoded.claims || []
const nameClaim = claims.find((c) => c.typ === "name") || {}
req.user.name = nameClaim.val || principalName
} catch {}
}
} else {
// Local dev — no EasyAuth headers present
req.user = { email: "dev@progressivesurface.com", name: "Local Developer", id: null }
}
next()
})
// Endpoint for the React client to fetch current user
app.get("/api/auth/me", (req, res) => res.json(req.user))On the React side, fetch /api/auth/me — no MSAL, no token management:
// contexts/UserContext.jsx
import { createContext, useContext, useState, useEffect } from "react"
const UserContext = createContext(null)
export function UserProvider({ children }) {
const [user, setUser] = useState(null)
useEffect(() => {
fetch("/api/auth/me")
.then((r) => r.json())
.then(setUser)
.catch(() => setUser({ email: "unknown", name: "Unknown" }))
}, [])
return <UserContext.Provider value={user}>{children}</UserContext.Provider>
}
export const useUser = () => useContext(UserContext)Note: The ERP Migration Tool originally used EasyAuth (Feb 2026) but later migrated to MSAL.js for more control over the auth flow. See the ERP Migration Tool auth docs for the MSAL pattern with global fetch override.
MSAL Configuration (For Pure SPAs Without a Backend)
If your app is a pure SPA (Static Web App, no Express backend), use MSAL instead:
// src/auth/msalConfig.ts
import { PublicClientApplication } from "@azure/msal-browser"
export const msalConfig = {
auth: {
clientId: import.meta.env.VITE_CLIENT_ID,
authority: `https://login.microsoftonline.com/${import.meta.env.VITE_TENANT_ID}`,
redirectUri: window.location.origin,
},
cache: {
cacheLocation: "sessionStorage",
storeAuthStateInCookie: false,
},
}
export const pca = new PublicClientApplication(msalConfig)
export const loginRequest = {
scopes: ["User.Read"],
}EasyAuth vs MSAL — When to Use Which
| Scenario | Use | Why |
|---|---|---|
| App Service with simple backend, no SPA | EasyAuth | No library needed, auth at platform level |
| App Service with SPA + API (e.g., React + Express) | MSAL | More control, global fetch override pattern, avoids EasyAuth config issues |
| Static Web App (pure SPA) | MSAL | No server to read headers from |
| Need to call Microsoft Graph from client | MSAL | Need access tokens in the browser |
EasyAuth Session Defaults
| Setting | Default Value |
|---|---|
| Cookie expiration | 8 hours |
| Token refresh window | 72 hours |
| Token store | Enabled (file system) |
| Require HTTPS | Yes |
7. Secrets Management with Key Vault
Key Vault Integration Architecture
+--------------------------------------------------------------------------+
| KEY VAULT INTEGRATION |
+--------------------------------------------------------------------------+
| |
| +-------------------------------------------------------------------+ |
| | AZURE RESOURCES | |
| | | |
| | +-------------+ +-------------------------+ | |
| | | App Service | | Azure Key Vault | | |
| | | | | | | |
| | | Managed |---- RBAC Access ------>| Secrets: | | |
| | | Identity | (Key Vault | +-- RedBook--AdminPwd | | |
| | | (System) | Secrets User) | +-- RedBook--Storage | | |
| | | | | +-- Wiki--ClientSecret | | |
| | +-------------+ | +-- ... | | |
| | | | | | |
| | | +-------------------------+ | |
| | | | | |
| | | App Settings Reference | Audit Logs | |
| | | @Microsoft.KeyVault(...) | | |
| | v v | |
| | +-------------+ +-------------------------+ | |
| | | Runtime | | Log Analytics | | |
| | | Environment | | Workspace | | |
| | | Variables | | (Audit queries) | | |
| | +-------------+ +-------------------------+ | |
| | | |
| +-------------------------------------------------------------------+ |
| |
| SECRET REFERENCE SYNTAX: |
| ====================================================================== |
| |
| @Microsoft.KeyVault(VaultName=ps-certificates-kv;SecretName=RedBook--AdminPwd) |
| |
| Components: |
| - VaultName = Key Vault name (ps-certificates-kv) |
| - SecretName = Secret name using [App]--[Name] pattern |
| |
+--------------------------------------------------------------------------+
Setup Key Vault
# Create Key Vault with RBAC
az keyvault create \
--name ps-certificates-kv \
--resource-group PS-WEBAPPS \
--location eastus \
--sku standard \
--enable-rbac-authorization true
# Enable Managed Identity on App Service
az webapp identity assign \
--name ps-yourapp-dashboard \
--resource-group PS-WEBAPPS
# Get the Managed Identity principal ID
PRINCIPAL_ID=$(az webapp identity show \
--name ps-yourapp-dashboard \
--resource-group PS-WEBAPPS \
--query principalId -o tsv)
# Grant "Key Vault Secrets User" role (least privilege)
az role assignment create \
--role "Key Vault Secrets User" \
--assignee $PRINCIPAL_ID \
--scope /subscriptions/YOUR_SUB_ID/resourceGroups/PS-WEBAPPS/providers/Microsoft.KeyVault/vaults/ps-certificates-kvSecret Naming Convention
[AppName]--[SecretName]
Examples:
- RedBook--AdminPassword
- RedBook--StorageConnectionString
- Wiki--ClientSecret
- ProjectExplorer--ApiKey
Store Secrets
# Add secrets to Key Vault
az keyvault secret set \
--vault-name ps-certificates-kv \
--name "RedBook--AdminPassword" \
--value "your-secure-password"
az keyvault secret set \
--vault-name ps-certificates-kv \
--name "RedBook--StorageConnectionString" \
--value "DefaultEndpointsProtocol=https;AccountName=..."Reference Secrets in App Settings
# Use Key Vault references (auto-resolved at runtime)
az webapp config appsettings set \
--name ps-yourapp-dashboard \
--resource-group PS-WEBAPPS \
--settings \
ADMIN_PASSWORD="@Microsoft.KeyVault(VaultName=ps-certificates-kv;SecretName=RedBook--AdminPassword)" \
STORAGE_CONNECTION="@Microsoft.KeyVault(VaultName=ps-certificates-kv;SecretName=RedBook--StorageConnectionString)"8. API CORS Configuration
Critical: If your web app calls the PSI.UniData.API, you must add the new origin to the API’s CORS configuration, or the browser will block requests.
Symptoms of Missing CORS
- Browser console shows:
Access to fetch has been blocked by CORS policy - App shows “Failed to fetch” or similar network errors
- API works fine when called directly (curl, Postman) but fails from the browser
Adding CORS Origin
Edit appsettings.json in the PSI.UniData.API repo (C:\GIT\PSI.UniData.API\src\PSI.UniData.API\appsettings.json):
"Cors": {
"AllowedOrigins": [
"http://localhost:5173",
"http://localhost:3000",
"https://bom-explorer-web.azurewebsites.net",
"https://explorer.progressivesurface.com",
"https://redbook-web.azurewebsites.net",
"https://redbook.progressivesurface.com",
"https://yourapp.progressivesurface.com"
]
}Commit and push to trigger auto-deployment:
cd C:\GIT\PSI.UniData.API
git add src/PSI.UniData.API/appsettings.json
git commit -m "Add CORS origin for yourapp.progressivesurface.com"
git push origin masterThe GitHub Actions runner on PS-PROXY will automatically deploy the change.
Verifying CORS
# Check if CORS headers are returned for your origin
curl -s -H "Origin: https://yourapp.progressivesurface.com" \
"https://api.progressivesurface.com/api/health" -I | grep -i access-control
# Should return:
# Access-Control-Allow-Credentials: true
# Access-Control-Allow-Origin: https://yourapp.progressivesurface.com9. CI/CD Pipeline
Pipeline Flow
+--------------------------------------------------------------------------+
| CI/CD PIPELINE FLOW |
+--------------------------------------------------------------------------+
| |
| DEVELOPER GITHUB BUILD AZURE |
| ========================================================================|
| |
| +----------+ |
| | Feature | |
| | Branch | |
| +----+-----+ |
| | |
| | git push |
| v |
| +----------+ +--------------+ |
| | Pull |----->| GitHub | |
| | Request | | Actions | |
| +----------+ +------+-------+ |
| | |
| | Triggers on PR |
| v |
| +--------------+ |
| | CI Pipeline | |
| | | |
| | 1. Checkout | |
| | 2. Install | |
| | 3. Lint | |
| | 4. Test | |
| | 5. Build | |
| | 6. Scan deps | |
| +------+-------+ |
| | |
| | All checks pass |
| v |
| +----------+ +--------------+ |
| | Code |<-----| PR Preview | (Static Web Apps Standard) |
| | Review | | Environment | |
| +----+-----+ +--------------+ |
| | |
| | Approve & Merge |
| v |
| +----------+ +--------------+ +----------------+ |
| | main |----->| CD Pipeline |----->| Production | |
| | branch | | | | Deployment | |
| +----------+ | 1. Build | | | |
| | 2. Deploy | | +------------+ | |
| | 3. Verify | | | Azure SWA | | |
| | | | | or App Svc | | |
| +--------------+ | +------------+ | |
| +----------------+ |
| |
+--------------------------------------------------------------------------+
App Service deployment hardening baseline (April 2026)
- No local-git or publish-profile deploys for production App Services.
- SCM/FTP basic publishing credentials disabled (
scm=false,ftp=false) unless a time-bound exception is approved. - Identity-based deploy only (self-hosted runner managed identity or Entra federated credential).
- Manual
workflow_dispatchsupported for one-app-at-a-time controlled rollouts and validation. - Health gates for protected endpoints must treat
200,401, and403as reachable depending on endpoint auth posture.
Auto-Versioning
PSI web apps should auto-increment their version number on every deploy so users always know which build they’re running. The pattern used by ERP Migration Tool (the reference implementation):
How It Works
Push to main → Tests pass → npm version patch → Vite injects version → Deploy → Commit version bump back
-
CI bumps the patch version before building:
- name: Bump patch version run: | cd client npm version patch --no-git-tag-version echo "APP_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV -
Build metadata captured (commit SHA + timestamp):
- name: Stamp build info run: | echo "BUILD_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV echo "BUILD_TIME=$(date -u +%Y%m%d-%H%M%S)" >> $GITHUB_ENV -
Vite injects the version at build time via
vite.config:import { readFileSync } from "fs" const pkg = JSON.parse(readFileSync("./package.json", "utf-8")) export default defineConfig({ define: { __APP_VERSION__: JSON.stringify(pkg.version), }, }) -
UI reads the global constant (no imports needed):
<footer>v{__APP_VERSION__}</footer>Add a TypeScript declaration so the compiler doesn’t complain:
// vite-env.d.ts declare const __APP_VERSION__: string -
Bot commits the bump back to the repo after successful deploy:
- name: Commit version bump run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.progressivesurface.ghe.com" git add package.json package-lock.json git commit -m "v${APP_VERSION} [skip ci]" || echo "No version change to commit" git pushThe
[skip ci]in the commit message prevents an infinite deploy loop.
Adoption Status
| App | Auto-Version | Notes |
|---|---|---|
| ERP Migration Tool | Yes | Reference implementation (v1.27.x) |
| PSI Explorer | No | Hardcoded v1.2 in App.tsx — needs migration |
| Redbook Dashboard | No | — |
| PSI Portal | No | — |
| PSI Wiki | N/A | Static site, no version displayed |
Adding Auto-Versioning to an Existing App
- Sync
package.jsonversion to the current UI version (e.g.1.2.0) - Add
define: { __APP_VERSION__: ... }tovite.config.ts - Add
declare const __APP_VERSION__: stringtovite-env.d.ts - Replace hardcoded version string in the UI with
__APP_VERSION__ - Add the “Bump patch version” + “Commit version bump” steps to the GitHub Actions workflow
- Ensure the workflow has
permissions: contents: writefor the commit-back step
Dependabot Configuration
Create .github/dependabot.yml:
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "automated"
commit-message:
prefix: "chore(deps):"
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "automated"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
labels:
- "ci"
- "automated"10. Self-Hosted GitHub Actions Runner
Why
GitHub-hosted runners cannot reach apps behind private endpoints (public network access disabled). A self-hosted runner on the PSI internal network can deploy directly.
Setup
The self-hosted GitHub Actions runner is an Azure Linux VM (ps-cicd-runner) on the PS-SERVERS subnet with managed identity.
| Property | Value |
|---|---|
| VM Name | ps-cicd-runner |
| Resource Group | PS-RG-01 |
| Subnet | PS-SERVERS (10.160.0.9) |
| OS | Ubuntu Linux |
| Auth | Azure Managed Identity |
| Runner Labels | [self-hosted, psi-internal] |
Which Apps Use It
| App | Why |
|---|---|
| UniData API | Deployed to App Service with private endpoint; needs internal network for az webapp deploy |
| Project Explorer | Private endpoint on PS-ProdData subnet |
| PRGJSMES (MES) | Private endpoint on PS-ProdData subnet |
| PSI Explorer | Private endpoint, publicNetworkAccess=Disabled |
| Redbook Web | Private endpoint on PS-ProdData subnet, publicNetworkAccess=Disabled |
| Redbook Dashboard | Private endpoint on PS-ProdData subnet, publicNetworkAccess=Disabled |
| ERP Migration Tool | Private endpoint on PS-ProdData subnet, publicNetworkAccess=Disabled |
| Progressive Data View | Private endpoint on PS-ProdData subnet, publicNetworkAccess=Disabled |
| ARGO Analytics | Private endpoint on PS-ProdData subnet, publicNetworkAccess=Disabled |
DNS Configuration for Private Endpoints
Canonical DNS rules + new-app checklist live in dns-standards. This section is the command-level how-to and PSI-specific background; dns-standards is the authoritative “what records, which zone, and why.”
Critical: When
publicNetworkAccess=Disabled, the SCM deployment endpoint is only reachable through the private endpoint. DNS must resolve*.privatelink.azurewebsites.netto private IPs, not public ones.
Why (short): a conditional forwarder for privatelink.azurewebsites.net does not work — Windows DNS trusts the complete public CNAME chain returned for app.azurewebsites.net and never consults the forwarder. Fix (implemented 2026-02-17): AD-integrated primary zones for the privatelink.* domains on the DCs, so the DC is authoritative and resolves the CNAME against its own A records. (Full reasoning + the runner-reads-Azure-zone vs. other-clients-read-DC-zone model is in dns-standards.)
Zones configured on PS-AZ-DC01 (Forest-replicated):
| Zone | Records |
|---|---|
privatelink.azurewebsites.net | All App Service + SCM A records (see table above) |
privatelink.database.windows.net | procserv-proddata → 10.160.140.4 |
privatelink.servicebus.windows.net | pste → 10.160.140.7, IoT Hub NS → 10.160.140.8 |
privatelink.azure-devices.net | pstesthub → 10.160.140.7, IoT Hub NS → 10.160.140.8 |
Resolution chain:
bom-explorer-web.scm.azurewebsites.net
→ CNAME: psi-explorer-web.scm.privatelink.azurewebsites.net
→ DC checks local authoritative zone for privatelink.azurewebsites.net
→ A: 10.160.0.17 (private endpoint IP) ✓
⚠ Maintenance — both zones must be updated: When adding a new App Service private endpoint, add A records in two places — each serves a different resolver path:
- Azure Private DNS zone (
privatelink.azurewebsites.netin PS-RG-01) — via Azure CLI or Portal. The deploy runner resolves via this (itsprivatelink.confdrop-in routesprivatelink.*to Azure DNS); missing it = deployNXDOMAIN/403.- DC primary zone on PS-AZ-DC01 (Forest-replicated) — via DNS Manager or
Add-DnsServerResourceRecordA. Other VNet/VPN clients (no drop-in) resolve theazurewebsites.nethostname via the DCs and rely on this.Add both the app name record (
your-app-name) and the SCM record (your-app-name.scm), pointing to the same private endpoint IP. Missing the SCM record is the most common cause of deploy failures.
Adding Records to the DC Primary Zone
# Run on PS-AZ-DC01 or via remote PowerShell
Add-DnsServerResourceRecordA -ZoneName "privatelink.azurewebsites.net" `
-Name "your-app-name" -IPv4Address "10.160.140.XX"
Add-DnsServerResourceRecordA -ZoneName "privatelink.azurewebsites.net" `
-Name "your-app-name.scm" -IPv4Address "10.160.140.XX"Adding a New Privatelink Zone Type
If you create a private endpoint for a new Azure service type (e.g., Blob Storage → privatelink.blob.core.windows.net, Key Vault → privatelink.vaultcore.azure.net), you need to:
- Create the Azure Private DNS zone in PS-RG-01 and link it to PS-VNMAIN
- Create a matching AD-integrated primary zone on PS-AZ-DC01 (Forest-replicated)
- Add A records to both zones for each endpoint
Without the DC primary zone, the same Windows DNS CNAME chain problem applies — the DC will follow the public DNS chain instead of resolving to the private IP. See DNS Configuration for Private Endpoints for background on the root cause.
Workflow Usage
runs-on: [self-hosted, psi-internal]For detailed setup and maintenance instructions, see the UniData API docs (deployment section).
11. Security Compliance Checklist
Pre-Deployment Requirements (S-REQ)
| ID | Requirement | How to Verify | Commands |
|---|---|---|---|
| S-REQ-01 | Authentication via Entra ID | Check auth config | az webapp auth show --name APP --resource-group RG |
| S-REQ-02 | Secrets in Key Vault | No secrets in code | git log -p | grep -i "password|secret|key" |
| S-REQ-03 | HTTPS Only | Check HTTPS redirect | az webapp show --name APP --resource-group RG --query httpsOnly |
| S-REQ-04 | Input Validation | Code review | Manual review of form handlers |
| S-REQ-05 | Dependency Scanning | Run audit | npm audit or pip-audit |
| S-REQ-06 | Least Privilege | Check RBAC roles | az role assignment list --assignee PRINCIPAL_ID |
| S-REQ-07 | SCM/FTP basic auth disabled | Check publishing policies | az rest --method get --uri ".../basicPublishingCredentialsPolicies/scm?api-version=2022-03-01" |
Full Checklist
## Security
- [ ] No secrets in source code (use Key Vault references)
- [ ] Authentication implemented (Entra ID)
- [ ] HTTPS enforced (`httpsOnly: true`)
- [ ] SCM/FTP basic publishing credentials disabled (`scm=false`, `ftp=false`)
- [ ] Input validation on all user inputs
- [ ] Dependency vulnerability scan passed (`npm audit` / `pip-audit`)
- [ ] Error messages don't expose internal details
- [ ] Logging doesn't capture sensitive data (passwords, tokens)
## Architecture
- [ ] Code in approved repository (GitHub/Azure DevOps)
- [ ] README with setup and deploy instructions
- [ ] Entry in App Registry
- [ ] All external connections documented
## Azure Resources
- [ ] App Service/Static Web App created
- [ ] Application settings configured
- [ ] Entra ID redirect URIs updated
- [ ] Custom domain configured (if needed)
- [ ] SSL certificate in place
- [ ] Deployment pipeline working
- [ ] Application Insights enabled
## Functional
- [ ] Authentication flow tested on production URL
- [ ] Database/storage connectivity verified
- [ ] All environment variables set correctly
- [ ] Performance acceptable under expected load
- [ ] Error handling tested
- [ ] Rollback procedure documentedVerification Commands
# Check HTTPS enforcement
az webapp show \
--name ps-yourapp-dashboard \
--resource-group PS-WEBAPPS \
--query "httpsOnly"
# Check authentication status
az webapp auth show \
--name ps-yourapp-dashboard \
--resource-group PS-WEBAPPS
# List app settings (verify no plain-text secrets)
az webapp config appsettings list \
--name ps-yourapp-dashboard \
--resource-group PS-WEBAPPS \
--output table
# Run dependency audit (npm)
npm audit --audit-level=high
# Run dependency audit (Python)
pip-audit --strict12. Troubleshooting
Common Issues
| Issue | Cause | Solution |
|---|---|---|
| 502 Bad Gateway | Startup command failing | Check startup logs: az webapp log tail |
| 401 Unauthorized | Endpoint is auth-protected by design | Verify expected auth model before treating as outage |
| Key Vault access denied | Missing RBAC | Grant “Key Vault Secrets User” role |
| Deployment fails | Runner identity or private DNS issue | Verify runner MI permissions and privatelink DNS resolution |
| App not starting | Missing dependencies | Check requirements.txt / package.json |
| Static assets 404 | Wrong output path | Verify output_location in workflow |
| CORS errors in browser | Missing API origin | Add origin to appsettings.json CORS config |
ENOTFOUND / fetch failed to on-prem server | Short hostname in env var | Use FQDN: server.ad.ptihome.com not server |
Diagnostic Commands
# View live logs
az webapp log tail \
--name ps-yourapp-dashboard \
--resource-group PS-WEBAPPS
# Download all logs
az webapp log download \
--name ps-yourapp-dashboard \
--resource-group PS-WEBAPPS \
--log-file logs.zip
# Check app status
az webapp show \
--name ps-yourapp-dashboard \
--resource-group PS-WEBAPPS \
--query "state"
# Restart app
az webapp restart \
--name ps-yourapp-dashboard \
--resource-group PS-WEBAPPS
# SSH into container (Linux App Service)
az webapp ssh \
--name ps-yourapp-dashboard \
--resource-group PS-WEBAPPS
# Check deployment status
az webapp deployment list-publishing-profiles \
--name ps-yourapp-dashboard \
--resource-group PS-WEBAPPSAuthentication Troubleshooting
# View auth config
az webapp auth show \
--name ps-yourapp-dashboard \
--resource-group PS-WEBAPPS
# Common auth issues:
# 1. Redirect URI mismatch - must match exactly including trailing slash
# 2. Client secret expired - regenerate in Azure Portal
# 3. Wrong tenant ID - verify in app settings
# 4. Token audience mismatch - check allowed-audiences settingPSI-Specific Issues
“PrivateEndpointCreationNotAllowedAsSubnetIsDelegated” The PS-WebApps subnet is delegated for VNet integration. Use PS-SERVERS or PS-ProdData for private endpoints.
“Bad Request” when adding custom domain
Add the verification TXT record first: asuid.{subdomain} with the App Service’s customDomainVerificationId.
“Conflict” when importing certificate from Key Vault A certificate with the same thumbprint already exists in the resource group. This happens if the cert was previously uploaded directly. The existing binding will work; Key Vault import is for new deployments.
Path conversion issues in Git Bash
Prefix commands with MSYS_NO_PATHCONV=1 when using resource IDs starting with /subscriptions/.
App works locally but not in production
- Check that
.env.productionhas the correct API URL - Verify the API URL is accessible from the user’s network (VPN required)
- Check browser console for CORS errors
- Verify DNS resolves correctly:
nslookup yourapp.progressivesurface.com 10.160.0.5
“Failed to fetch” / CORS errors in browser
The web app can reach the API, but the browser blocks the response due to missing CORS headers. Solution: Add your app’s origin to the API’s Cors:AllowedOrigins in appsettings.json. See 8. API CORS Configuration above.
Deploy 403 when publicNetworkAccess=Disabled
az webapp deploy hits the SCM endpoint (*.scm.azurewebsites.net) which is blocked when DNS resolves to the public IP. Root cause: Windows DNS returns the complete CNAME chain from public DNS, bypassing privatelink resolution. Solution: See DNS Configuration for Private Endpoints in Section 10. AD-integrated primary zones on PS-AZ-DC01 provide authoritative privatelink A records for all VNet machines. If adding a new app, remember to add both app and app.scm A records to the DC zone.
App Service cannot reach on-premises servers by short hostname
Azure Linux App Services do not have the ad.ptihome.com DNS search domain configured. Short hostnames like ps-proxy will fail to resolve — even though the VNet integration, route table, and VPN tunnel are all working correctly. The symptom is fetch failed or ENOTFOUND errors when the app tries to connect to on-prem services.
Root cause: On a domain-joined Windows machine (or the self-hosted runner with systemd-resolved configured), DNS automatically appends ad.ptihome.com to unqualified hostnames. App Service containers don’t have this search domain, so bare hostnames like ps-proxy go to public DNS and fail.
Solution: Always use the fully-qualified domain name (FQDN) in App Service environment variables:
| Wrong | Correct |
|---|---|
http://ps-proxy:3100/mcp | http://ps-proxy.ad.ptihome.com:3100/mcp |
http://aftec-server:5000 | http://aftec-server.ad.ptihome.com:5000 |
This applies to any App Service configuration that references on-premises servers: environment variables, connection strings, API endpoints, etc. Local development and the self-hosted runner can use short hostnames (they have the search domain), but Azure App Service always requires the FQDN.
Discovered: PSI Explorer “Ask the Fleet” chat was connecting to
ps-proxy:3100locally but failing on Azure App Service. ChangingMCP_SERVER_URLtohttp://ps-proxy.ad.ptihome.com:3100/mcpin the App Service Configuration resolved the issue immediately.
13. Templates
GitHub Actions Template (Static Web Apps)
# .github/workflows/azure-static-web-apps.yml
name: Azure Static Web Apps CI/CD
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened, closed]
branches: [main]
jobs:
build_and_deploy:
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
env:
VITE_CLIENT_ID: ${{ secrets.VITE_CLIENT_ID }}
VITE_TENANT_ID: ${{ secrets.VITE_TENANT_ID }}
- name: Deploy
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
repo_token: ${{ secrets.GITHUB_TOKEN }}
action: "upload"
app_location: "/"
output_location: "dist"
close_pull_request:
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- name: Close Pull Request
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
action: "close"Static Web App Config Template
{
"routes": [
{
"route": "/*",
"allowedRoles": ["authenticated"]
}
],
"navigationFallback": {
"rewrite": "/index.html",
"exclude": ["/assets/*", "*.{css,js,png,jpg,svg,ico}"]
},
"auth": {
"identityProviders": {
"azureActiveDirectory": {
"registration": {
"openIdIssuer": "https://login.microsoftonline.com/a83ae943-0a50-49cc-83c3-479b7a44b7fb/v2.0",
"clientIdSettingName": "AAD_CLIENT_ID",
"clientSecretSettingName": "AAD_CLIENT_SECRET"
}
}
}
},
"globalHeaders": {
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"X-XSS-Protection": "1; mode=block",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"
}
}Quick Reference Card
+------------------------------------------------------------+
| PSI DEPLOYMENT QUICK REFERENCE |
+------------------------------------------------------------+
| |
| NAMING: ps-[env]-[type]-[name] |
| TENANT: a83ae943-0a50-49cc-83c3-479b7a44b7fb |
| RESOURCE GROUP: PS-WEBAPPS |
| |
| STATIC WEB APP: |
| az staticwebapp create --name NAME --resource-group RG |
| |
| APP SERVICE: |
| az appservice plan create --name PLAN --sku B1 --is-linux|
| az webapp create --name NAME --plan PLAN --runtime PYTHON|
| |
| PRIVATE ENDPOINT: |
| az network private-endpoint create --name NAME-pe ... |
| az resource update --set publicNetworkAccess=Disabled |
| |
| DEPLOY: |
| gh workflow run deploy.yml --ref main |
| |
| LOGS: |
| az webapp log tail --name NAME --resource-group RG |
| |
| AUTH: |
| az webapp auth show --name NAME --resource-group RG |
| |
| KEY VAULT: |
| @Microsoft.KeyVault(VaultName=KV;SecretName=SECRET) |
| |
| DNS (internal): |
| nslookup app.progressivesurface.com 10.160.0.5 |
| |
| WILDCARD CERT: |
| Key Vault: ps-certificates-kv |
| Cert: wildcard-progressivesurface |
| Thumbprint: 8ECD7C39FA4BD44E10D3D89A80EF33F3922A291A |
| |
+------------------------------------------------------------+
Related Pages
- PSI Web App Compliance Standard — Canonical implementation and compliance baseline
- Azure Resource Map — Repo-to-resource mapping, app registrations, secrets, runners
- PSI Data & Systems Overview
- Developer Tools Setup
- AI Agent Access
Last updated: 2026-06-25 (refreshed private-endpoint allocation against live Azure; added dns-standards pointer)