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

NeedSolutionExample
Static SPA (React, Vue)PSI-Wiki-Site
Full-stack app with APIRedbook Dashboard
Internal-only app (VPN)PSI Explorer
Serverless functionsAzure FunctionsAPI endpoints
Database + APIApp Service + SQLFuture ERP apps

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

ApplicationURLRepositoryStatus
PSI Portalportal.progressivesurface.comProgressiveSurface/psi-portalLive
PSI Explorerexplorer.progressivesurface.comProgressiveSurface/psi-explorer-webLive
Redbook Dashboardredbook.progressivesurface.comProgressiveSurface/redbook-webLive
Quality Analyticsquality.progressivesurface.comProgressiveSurface/redbook-dashboardLive
ERP Migration Tooldmt.progressivesurface.comProgressiveSurface/erp-migration-toolLive
Project Explorerprojects.progressivesurface.comProgressiveSurface/ps-project-explorerLive
MESpsmes.progressivesurface.comProgressiveSurface/PRGJSMESLive
UniData APIapi.progressivesurface.comProgressiveSurface/PSI.UniData.APILive
PSI Wikiwiki.progressivesurface.comProgressiveSurface/PSI-Wiki-SiteLive
Prociselyprocisely.comProgressiveSurface/procisely-redirectLive

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.

TokenHexUsage
--ps-green-500027A54Primary — actions, headers, buttons (brand green)
--ps-ink1A1D1FPrimary text
--ps-bgF6F7F5Page background
--ps-surfaceFFFFFFCards, 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” #284b63 palette — 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.

AppEntra App ID
PSI Explorerdb5621e9-f3db-495b-ae14-f11d18ba8ad6
PSI Portal7f929c7f-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

FeatureStatic Web AppsApp Service
Best forSPAs, static sitesFull-stack, APIs
RuntimeStatic files + FunctionsPython, Node, .NET, Java
CostFree - $9/mo$13 - $138/mo
CI/CDGitHub Actions (auto)Git push / Actions
AuthBuilt-in Entra IDEasyAuth / MSAL
Custom domainYesYes
WebSocketsNoYes
SSLFree (auto)Free (managed) or Key Vault wildcard
Private endpointsNoYes

PSI Examples

AppTypeAzure ServiceURL
PSI-Wiki-SiteStatic (Quartz)Static Web Appswiki.progressivesurface.com
PSI PortalReact SPA (internal)App Service + Private Endpointportal.progressivesurface.com
PSI ExplorerReact SPA (internal)App Service + Private Endpointexplorer.progressivesurface.com
Redbook DashboardPython + StreamlitApp Serviceredbook.progressivesurface.com
Project ExplorerReact SPA (internal)App Service + Private Endpointprojects.progressivesurface.com
MES.NET (Windows, internal)App Service + Private Endpointpsmes.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

ResourceName
Resource GroupPS-WEBAPPS
App Service Planps-redbook-plan
App Serviceps-redbook-dashboard
Static Web Appps-wiki-site
Key Vaultps-certificates-kv
Key Vault (certs)ps-certificates-kv
Storage Accountpsiredbookfeedback
Application Insightsps-redbook-insights
Private Endpointbom-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 repo

Step 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 main

Example 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: 14

Do not deploy synchronously — the 4-minute 504

az webapp deploy --async false (and the deprecated webapp deployment source config-zip) hold one synchronous HTTP call open to *.scm.azurewebsites.net for the entire Kudu extraction + container restart. Azure Front Door caps that call at ~4 minutes — any app whose deploy runs longer gets a 504 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 true and 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-repo

Step 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=8000

4b. 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 a staging slot — 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:

ResourceInherited?Why it matters
Custom domains (psmes.progressivesurface.com)❌ NoSlot only has <app>-<slot>.azurewebsites.net
Private endpoint❌ NoSlot 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 IdentityNo — slot has its own MISlot’s MI starts with zero DB access, app crashes 503 with CanConnectAsync failure
App Service basic publishing creds policyYes (inherits)
VNet integrationYes (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-conn

Group ID for slots is sites-<slotname>, not sites. This is the most-missed step.

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.net

4. 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:

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:00
4b. 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:00
Verify 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 output

6. 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/health and get 200 (proves slot’s MI can reach SQL)
  • Swap into production (proves swap mechanics)

Symptoms when a step is missing

Step missedFailure 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

ResourceValue
Main slot PEprgjsmes-prod-pe → 10.160.140.11
Staging slot PEprgjsmes-prod-staging-pe → 10.160.140.18
Main slot MI54c0f248-7538-431b-b743-20bbd9f58a1a
Staging slot MIbb2cc61f-587c-4589-a8c9-9c02c973095e
SQL user for stagingprgjsmes-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 recordsAuto-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

SystemTypeDataAccess Method
AFTECERP (UniData)Orders, Projects, HoursNightly XML export
PDM VaultSolidWorksCAD metadata, BOMsPython API / Export
RedbookManufacturingQuality issues, NCNsCSV export + AI pipeline
Active DirectoryIdentityUsers, GroupsAzure 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

StepDescriptionRequired
1Create App ServiceYes
2Configure VNet IntegrationYes
3Create Private EndpointYes
3bAdd Privatelink DNS Records (Azure Private DNS zone and DC primary zone)Yes
4Disable Public AccessYes
5Add Custom DomainYes
6Add DNS Verification TXTYes
7Create DNS A Record (internal IP)Yes
8Enable HTTPS OnlyYes
9Bind Wildcard SSL CertificateYes
10Configure API CORSYes (if calling API)
11Update Wiki DocumentationYes

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 AddressServiceType
10.160.140.4procserv-proddata (SQL)SQL Server
10.160.140.5ps-buildvsbuy 🚧App Service (Web)
10.160.140.6psi-tap-bridgeFunction App
10.160.140.7PSTestHubIoT Hub
10.160.140.9erp-migration-apiApp Service (API)
10.160.140.10ps-project-explorerApp Service (Web)
10.160.140.11prgjsmes-prodApp Service (Web)
10.160.140.12redbook-webApp Service (Web)
10.160.140.13prgjsmes-prod (⚠️ duplicate PE — see azure-resources)App Service (Web)
10.160.140.14ps-redbook-dashboardApp Service (Web)
10.160.140.15ps-progressive-viewApp Service (Web)
10.160.140.16ps-argo-analyticsApp Service (Web)
10.160.140.17ps-winget-sourceApp Service (Web)
10.160.140.18prgjsmes-prod (staging slot)App Service slot
10.160.140.21psi-zebra-tracking-sqlSQL Server
10.160.140.22ps-dispatchApp Service (Web)
10.160.140.23ps-certificates-kvKey Vault (PE)
10.160.140.24psi-serviceApp 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: .23 is the ps-certificates-kv private endpoint (in the privatelink.vaultcore.azure.net zone, so it isn’t visible from the privatelink.azurewebsites.net records) — 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: .23 looked 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

ServicePrivate Endpoint NameIP AddressSubnet
psi-portalpsi-portal-pe10.160.0.6PS-SERVERS
shippingappfunctions10.160.0.16PS-SERVERS
bom-explorer-webbom-explorer-web-pe10.160.0.17PS-SERVERS
psi-tap-bridgepsi-tap-bridge-pe10.160.140.6PS-ProdData
erp-migration-apiERPMigrationAPIEndpoint10.160.140.9PS-ProdData
ps-project-explorerps-project-explorer-pe10.160.140.10PS-ProdData
prgjsmes-prodprgjsmes-prod-pe10.160.140.11PS-ProdData
redbook-webredbook-web-pe10.160.140.12PS-ProdData
ps-redbook-dashboardps-redbook-dashboard-pe10.160.140.14PS-ProdData
ps-progressive-viewps-progressive-view-pe10.160.140.15PS-ProdData
ps-argo-analyticsps-argo-analytics-pe10.160.140.16PS-ProdData
psi-servicepsi-service-pe10.160.140.24PS-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-WebApps

FQDN 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 the ad.ptihome.com DNS 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=Disabled

Method 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 Deny

Use 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.

Required when using publicNetworkAccess=Disabled with self-hosted GitHub Actions runners. Without these records, the runner cannot resolve your-app-name.scm.azurewebsites.net to 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 IP
RecordIPZone
bom-explorer-web10.160.0.17privatelink.azurewebsites.net
bom-explorer-web.scm10.160.0.17privatelink.azurewebsites.net
psi-portal10.160.0.6privatelink.azurewebsites.net
psi-portal.scm10.160.0.6privatelink.azurewebsites.net
ps-project-explorer10.160.140.10privatelink.azurewebsites.net
ps-project-explorer.scm10.160.140.10privatelink.azurewebsites.net
erp-migration-api10.160.140.9privatelink.azurewebsites.net
erp-migration-api.scm10.160.140.9privatelink.azurewebsites.net
prgjsmes-prod10.160.140.11privatelink.azurewebsites.net
prgjsmes-prod.scm10.160.140.11privatelink.azurewebsites.net
redbook-web10.160.140.12privatelink.azurewebsites.net
redbook-web.scm10.160.140.12privatelink.azurewebsites.net
ps-redbook-dashboard10.160.140.14privatelink.azurewebsites.net
ps-redbook-dashboard.scm10.160.140.14privatelink.azurewebsites.net
ps-argo-analytics10.160.140.16privatelink.azurewebsites.net
ps-argo-analytics.scm10.160.140.16privatelink.azurewebsites.net
prgjsmes-prod-staging10.160.140.18privatelink.azurewebsites.net
prgjsmes-prod-staging.scm10.160.140.18privatelink.azurewebsites.net
psi-service10.160.140.24privatelink.azurewebsites.net
psi-service.scm10.160.140.24privatelink.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-web to psi-explorer-web, but the Azure App Service resource name remains bom-explorer-web. All DNS and PE records correctly use bom-explorer-web to match the Azure resource name.

Verify: nslookup your-app-name.azurewebsites.net 10.160.0.5 should 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.com

Step 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 IP

Step 7: Enable HTTPS Only

az webapp update \
  --name your-app-name \
  --resource-group PS-WEBAPPS \
  --https-only true

Step 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 SNI

Note: 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.5

Infrastructure Summary

VNet: PS-VNMAIN

SubnetCIDRPurpose
PS-SERVERS10.160.0.0/23Private endpoints, VMs
PS-WebApps10.160.150.0/24App Service VNet integration (delegated)
PS-ProdData10.160.140.0/24Private endpoints (preferred)
PS-VMXSBNT10.160.50.0/24VMs

DNS

ServerIPPurpose
ps-az-dc01.ad.ptihome.com10.160.0.5Primary DNS (Azure DC)
192.9.200.110192.9.200.110Secondary DNS (on-prem DC)
168.63.129.16168.63.129.16Azure DNS wireserver (privatelink resolution)

Self-Hosted Runner: ps-cicd-runner

SettingValue
VM Nameps-cicd-runner
IP10.160.0.9
SubnetPS-SERVERS
OSUbuntu Linux
Runner Labels[self-hosted, psi-internal]
AuthAzure 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.conf drop-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.net hostname 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 returned NXDOMAIN (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.com

After 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.

PropertyValue
Key Vaultps-certificates-kv
Certificate Namewildcard-progressivesurface
Subject*.progressivesurface.com
Thumbprint8ECD7C39FA4BD44E10D3D89A80EF33F3922A291A
Expires2027-02-03
Vault URIhttps://ps-certificates-kv.vault.azure.net/

Key Vault Access Policies

PrincipalPermissions
Azure Web Sites (abfa0a7c-a6b6-4736-8310-5855508787cd)Get secrets, Get certificates
App Service Managed IdentitiesGet secrets, Get certificates

Reference Deployment: PSI Explorer Web

Completed 2026-02-04:

SettingValue
App Servicepsi-explorer-web
Custom Domainexplorer.progressivesurface.com
Private Endpointbom-explorer-web-pe
Internal IP10.160.0.17
SubnetPS-SERVERS
SSLWildcard cert (SNI)
Public AccessDisabled

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
  • 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 LoginWithAzureActiveDirectory

Important: The CLI commands above fail with Cannot use auth v2 commands when the app is using auth v1 if 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:

HeaderContentExample
X-MS-CLIENT-PRINCIPAL-NAMEUser email (UPN)jdoe@progressivesurface.com
X-MS-CLIENT-PRINCIPAL-IDAzure Object IDa1b2c3d4-...
X-MS-CLIENT-PRINCIPALBase64-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-..." }
  ]
}

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

ScenarioUseWhy
App Service with simple backend, no SPAEasyAuthNo library needed, auth at platform level
App Service with SPA + API (e.g., React + Express)MSALMore control, global fetch override pattern, avoids EasyAuth config issues
Static Web App (pure SPA)MSALNo server to read headers from
Need to call Microsoft Graph from clientMSALNeed access tokens in the browser

EasyAuth Session Defaults

SettingDefault Value
Cookie expiration8 hours
Token refresh window72 hours
Token storeEnabled (file system)
Require HTTPSYes

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-kv

Secret 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 master

The 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.com

9. 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)

  1. No local-git or publish-profile deploys for production App Services.
  2. SCM/FTP basic publishing credentials disabled (scm=false, ftp=false) unless a time-bound exception is approved.
  3. Identity-based deploy only (self-hosted runner managed identity or Entra federated credential).
  4. Manual workflow_dispatch supported for one-app-at-a-time controlled rollouts and validation.
  5. Health gates for protected endpoints must treat 200, 401, and 403 as 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
  1. 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
  2. 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
  3. 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),
      },
    })
  4. 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
  5. 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 push

    The [skip ci] in the commit message prevents an infinite deploy loop.

Adoption Status

AppAuto-VersionNotes
ERP Migration ToolYesReference implementation (v1.27.x)
PSI ExplorerNoHardcoded v1.2 in App.tsx — needs migration
Redbook DashboardNo
PSI PortalNo
PSI WikiN/AStatic site, no version displayed

Adding Auto-Versioning to an Existing App

  1. Sync package.json version to the current UI version (e.g. 1.2.0)
  2. Add define: { __APP_VERSION__: ... } to vite.config.ts
  3. Add declare const __APP_VERSION__: string to vite-env.d.ts
  4. Replace hardcoded version string in the UI with __APP_VERSION__
  5. Add the “Bump patch version” + “Commit version bump” steps to the GitHub Actions workflow
  6. Ensure the workflow has permissions: contents: write for 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.

PropertyValue
VM Nameps-cicd-runner
Resource GroupPS-RG-01
SubnetPS-SERVERS (10.160.0.9)
OSUbuntu Linux
AuthAzure Managed Identity
Runner Labels[self-hosted, psi-internal]

Which Apps Use It

AppWhy
UniData APIDeployed to App Service with private endpoint; needs internal network for az webapp deploy
Project ExplorerPrivate endpoint on PS-ProdData subnet
PRGJSMES (MES)Private endpoint on PS-ProdData subnet
PSI ExplorerPrivate endpoint, publicNetworkAccess=Disabled
Redbook WebPrivate endpoint on PS-ProdData subnet, publicNetworkAccess=Disabled
Redbook DashboardPrivate endpoint on PS-ProdData subnet, publicNetworkAccess=Disabled
ERP Migration ToolPrivate endpoint on PS-ProdData subnet, publicNetworkAccess=Disabled
Progressive Data ViewPrivate endpoint on PS-ProdData subnet, publicNetworkAccess=Disabled
ARGO AnalyticsPrivate 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.net to 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):

ZoneRecords
privatelink.azurewebsites.netAll App Service + SCM A records (see table above)
privatelink.database.windows.netprocserv-proddata → 10.160.140.4
privatelink.servicebus.windows.netpste → 10.160.140.7, IoT Hub NS → 10.160.140.8
privatelink.azure-devices.netpstesthub → 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:

  1. Azure Private DNS zone (privatelink.azurewebsites.net in PS-RG-01) — via Azure CLI or Portal. The deploy runner resolves via this (its privatelink.conf drop-in routes privatelink.* to Azure DNS); missing it = deploy NXDOMAIN/403.
  2. DC primary zone on PS-AZ-DC01 (Forest-replicated) — via DNS Manager or Add-DnsServerResourceRecordA. Other VNet/VPN clients (no drop-in) resolve the azurewebsites.net hostname 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"

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:

  1. Create the Azure Private DNS zone in PS-RG-01 and link it to PS-VNMAIN
  2. Create a matching AD-integrated primary zone on PS-AZ-DC01 (Forest-replicated)
  3. 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)

IDRequirementHow to VerifyCommands
S-REQ-01Authentication via Entra IDCheck auth configaz webapp auth show --name APP --resource-group RG
S-REQ-02Secrets in Key VaultNo secrets in codegit log -p | grep -i "password|secret|key"
S-REQ-03HTTPS OnlyCheck HTTPS redirectaz webapp show --name APP --resource-group RG --query httpsOnly
S-REQ-04Input ValidationCode reviewManual review of form handlers
S-REQ-05Dependency ScanningRun auditnpm audit or pip-audit
S-REQ-06Least PrivilegeCheck RBAC rolesaz role assignment list --assignee PRINCIPAL_ID
S-REQ-07SCM/FTP basic auth disabledCheck publishing policiesaz 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 documented

Verification 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 --strict

12. Troubleshooting

Common Issues

IssueCauseSolution
502 Bad GatewayStartup command failingCheck startup logs: az webapp log tail
401 UnauthorizedEndpoint is auth-protected by designVerify expected auth model before treating as outage
Key Vault access deniedMissing RBACGrant “Key Vault Secrets User” role
Deployment failsRunner identity or private DNS issueVerify runner MI permissions and privatelink DNS resolution
App not startingMissing dependenciesCheck requirements.txt / package.json
Static assets 404Wrong output pathVerify output_location in workflow
CORS errors in browserMissing API originAdd origin to appsettings.json CORS config
ENOTFOUND / fetch failed to on-prem serverShort hostname in env varUse 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-WEBAPPS

Authentication 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 setting

PSI-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

  1. Check that .env.production has the correct API URL
  2. Verify the API URL is accessible from the user’s network (VPN required)
  3. Check browser console for CORS errors
  4. 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:

WrongCorrect
http://ps-proxy:3100/mcphttp://ps-proxy.ad.ptihome.com:3100/mcp
http://aftec-server:5000http://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:3100 locally but failing on Azure App Service. Changing MCP_SERVER_URL to http://ps-proxy.ad.ptihome.com:3100/mcp in 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     |
|                                                              |
+------------------------------------------------------------+


Last updated: 2026-06-25 (refreshed private-endpoint allocation against live Azure; added dns-standards pointer)