PSI DNS Standards

MANDATORY before publishing a web app to Azure — agents and engineers

If you are publishing, deploying, or changing DNS / networking for any web app on Azure, you MUST read this page first. Do not improvise DNS or “figure it out as you go” — DNS is the #1 cause of failed PSI deploys. Every internal app needs app + app.scm records in both zones (Azure Private DNS and the DCs); follow the new-app DNS checklist and verify before you deploy.

Canonical reference for DNS at PSI. DNS is the single biggest cause of failed deploys and “works-locally-but-not-in-prod” incidents in the web fleet. Read this before adding or changing DNS for any app. The command-level walkthroughs live in deploy-to-azure §5 and §10; this page is the rules and the checklist.


TL;DR — the five rules that bite people

  1. Network shares use DFS, never server names. \\ad.ptihome.com\DFS\…, never \\fs1\… or \\PS-GR-FS01\….
  2. Internal app A records go in two places — the Azure public DNS zone and the AD domain controllers. Skipping one half is the classic “resolves for some people, not others.”
  3. Privatelink resolution needs records in two zones — the Azure Private DNS zone and an AD-integrated primary zone on the DCs. Add both the app record and the app.scm record. Missing .scm is the #1 cause of deploy 403s.
  4. App Service → on-prem must use the FQDN (ps-proxy.ad.ptihome.com), never the short hostname (ps-proxy). Linux App Service containers don’t have the ad.ptihome.com search domain.
  5. Cisco Umbrella intercepts workstation DNS even when you pass -Server. A workstation nslookup is not a reliable internal-DNS diagnostic — test from a DC or VNet machine.

The resolvers and zones

DNS servers

ServerIPRole
PS-AZ-DC0110.160.0.5Primary DNS + Azure domain controller; authoritative for the privatelink primary zones
On-prem DC192.9.200.110Secondary DNS (PSI HQ)
Azure wireserver168.63.129.16Azure-internal resolver — avoid pinning apps to it; it can’t see internal AD zones and resolves ad.ptihome.com to wrong public IPs
Cisco Umbrellaendpoint clientIntercepts DNS on PSI workstations below the Windows resolver (see Umbrella gotcha)

Zones

ZoneHosted onPurpose
progressivesurface.comAzure DNS (RG PS-RG-01)Public/authoritative records: app A records (→ private endpoint IPs), asuid.* domain-verification TXT, api → PS-PROXY
ad.ptihome.com (+ legacy ptihome.com)AD DCsInternal AD domain. PSI no longer owns ptihome.com publicly — never let an internal name leak to public DNS (see the api.progressivesurface.com CNAME incident below)
privatelink.azurewebsites.netAzure Private DNS (PS-RG-01) and AD primary zone (PS-AZ-DC01)App Service / Function App private endpoints
privatelink.database.windows.netbothAzure SQL private endpoints
privatelink.vaultcore.azure.netbothKey Vault private endpoints
privatelink.servicebus.windows.net / privatelink.azure-devices.netbothService Bus / IoT Hub
DFS namespace \\ad.ptihome.com\DFS\*ADAll file shares — never use \\fs1, \\PS-GR-FS01, etc.

Why every privatelink zone exists twice (Azure + DC) — and who uses which. The two zones serve two different resolver paths, so a new app needs the records in both; neither is redundant:

  • The deploy runner (ps-cicd-runner) resolves through a systemd-resolved drop-in that routes privatelink.* to Azure DNS (168.63.129.16) → it reads the Azure Private DNS zone. This is the record that unblocks deploys. (Verified 2026-06-25: the runner resolved a brand-new app from the Azure zone alone, while the DCs still returned NXDOMAIN.)
  • Every other VNet/VPN client (Windows machines, App Proxy connector VMs — no drop-in) resolves the app.azurewebsites.net hostname via the DCs → it reads the DC AD-integrated primary zone.

The DC must hold an authoritative primary zone, not a conditional forwarder: when a client resolves app.azurewebsites.net, public DNS returns the complete CNAME chain to a public IP in one answer and Windows DNS trusts it — a conditional forwarder for privatelink.azurewebsites.net is never consulted. Being authoritative, the DC resolves the privatelink CNAME against its own records instead of following the public chain.


✅ Adding DNS for a new internal app

For an internal App Service named ps-myapp, published at myapp.progressivesurface.com, with private-endpoint IP 10.160.140.X (next free IP is tracked in azure-resources / deploy-to-azure):

The App Service name and the public hostname are not the same string

PSI’s convention drops the ps- prefix on the public side: ps-sopsop.progressivesurface.com, ps-dispatchdispatch, ps-bom-coveragebom-coverage. Records 1–2 use the custom-domain label; records 3–5 use the App Service name, because privatelink resolution keys off <app>.azurewebsites.net and never sees the custom domain. Getting this backwards writes the asuid TXT under a label the custom-domain binding never reads, and the binding then fails verification for a reason that points nowhere near DNS. (This is exactly what New-WebAppDnsRecords.ps1 did on its first live run — fixed in psi-azure-admin#10, which added -CustomDomain.)

A few apps break the pattern outright (bom-explorer-webbomexplorer, ps-project-explorerprojects), so confirm the intended hostname rather than deriving it.

#RecordZoneWhere
1asuid.myapp TXT = customDomainVerificationIdprogressivesurface.comAzure DNS
2myapp A → 10.160.140.Xprogressivesurface.comAzure DNS
3ps-myapp A → 10.160.140.X and ps-myapp.scm A → 10.160.140.Xprivatelink.azurewebsites.netAzure Private DNS (PS-RG-01)
4ps-myapp A → 10.160.140.X and ps-myapp.scm A → 10.160.140.Xprivatelink.azurewebsites.netDC primary zone (PS-AZ-DC01, forest-replicated)
5(deployment slots only) per-app primary zones ps-myapp-<slot>.azurewebsites.net + .scm apex → slot PE IPnew primary zoneDC

Steps 3 and 4 are not redundant: step 3 (Azure zone) is what the deploy runner reads; step 4 (DC zone) is what every other VNet/VPN client reads. Do both. End users reach the app by its custom domain (steps 1–2), which is why a missing privatelink record breaks deploys/tooling but not the user-facing URL.

A new app's hostname already resolves before you create record 2

The zone carries a wildcard *.progressivesurface.com A record → 170.249.213.26 (TTL 1800). myapp.progressivesurface.com therefore answers with that address from the moment the name is first queried — it does not NXDOMAIN while the record is missing. Two consequences worth knowing: an early tester gets a response from an unrelated host and reads it as “the app is broken” rather than “DNS isn’t done”, and a nslookup that returns something is not evidence the record exists. Check the returned IP, not merely that resolution succeeded. The explicit myapp A record takes precedence over the wildcard once created.

Who provisions these records

DNS is centrally provisioned. Developers request it; they do not run it.

Members of PSI-WebApp-Deployers hold App Service, private-endpoint, subnet-join and Key Vault rights — enough to build everything except DNS. They hold no role on either Azure DNS zone and no rights on the DC.

ZoneWho can writeHow a dev gets a record
progressivesurface.com (Azure DNS)Subscription OwnerRequest
privatelink.azurewebsites.net (Azure Private DNS)Subscription OwnerRequest
privatelink.azurewebsites.net (DC, AD-integrated)Domain Admins onlyRequest

The DC zone is the reason this is a request rather than a grant: it is AD-integrated, so writing to it means Domain Admin. DnsAdmins is deliberately kept empty — membership in it is a well-documented privilege-escalation path to SYSTEM on a domain controller, so it is not a safe middle tier.

Developers: file the DNS request: new web app issue on psi-azure-admin once the App Service and its private endpoint exist. Everything except the deploy itself can proceed while the request is open.

Operators: fulfil the request with one command — it does all six records across all three zones, is idempotent, refuses to silently repoint an existing record, and verifies resolution against the DC before reporting success:

cd C:\git\psi-azure-admin
./scripts/New-WebAppDnsRecords.ps1 -AppName ps-myapp -PrivateEndpointIp 10.160.140.42

The public hostname defaults to the app name minus ps-. When the request asks for something else (bom-explorer-webbomexplorer), pass it explicitly:

./scripts/New-WebAppDnsRecords.ps1 -AppName bom-explorer-web -PrivateEndpointIp 10.160.140.42 -CustomDomain bomexplorer

Add -WhatIf to preview. Update the PE-IP allocation table in azure-resources afterwards.

# Step 4 — on PS-AZ-DC01 (or any DC; AD-integrated zone replicates)
Add-DnsServerResourceRecordA -ZoneName "privatelink.azurewebsites.net" -Name "myapp"     -IPv4Address "10.160.140.X"
Add-DnsServerResourceRecordA -ZoneName "privatelink.azurewebsites.net" -Name "myapp.scm" -IPv4Address "10.160.140.X"
# Step 3 — Azure Private DNS zone
az network private-dns record-set a add-record -g PS-RG-01 -z privatelink.azurewebsites.net -n myapp     -a 10.160.140.X
az network private-dns record-set a add-record -g PS-RG-01 -z privatelink.azurewebsites.net -n myapp.scm -a 10.160.140.X

Verify (from a VNet machine — not an Umbrella workstation)

nslookup ps-myapp.azurewebsites.net 10.160.0.5         # → 10.160.140.X (private), NOT a public Azure IP
nslookup ps-myapp.scm.azurewebsites.net 10.160.0.5     # → 10.160.140.X
nslookup myapp.progressivesurface.com 10.160.0.5       # → 10.160.140.X   (custom-domain label, no ps- prefix)

AD replication: ~15 min within a site, up to 3 h cross-site. Force with repadmin /syncall /AeP.

If you create the first private endpoint for a new Azure service (Blob → privatelink.blob.core.windows.net, Key Vault → privatelink.vaultcore.azure.net, …):

  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 for every endpoint.

Without step 2 the same Windows CNAME-chain problem applies and the DC follows the public chain.


Deployment slots need an extra DNS layer

A slot has no custom domain, so the public CNAME chain for myapp-staging.azurewebsites.net does not reliably end at privatelink. A laptop on the VPN then gets the public IP and hits 403 x-ms-forbidden-ip. Fix: a per-app primary zone on the DC that short-circuits straight to the private IP (apex @ record), in addition to the privatelink records. Full step-by-step in deploy-to-azure §4b.


App Service → on-prem: always use the FQDN

Azure Linux App Service containers don’t have the ad.ptihome.com DNS search domain, so a bare hostname resolves against public DNS and fails (ENOTFOUND / fetch failed).

WrongCorrect
http://ps-proxy:3100/mcphttp://ps-proxy.ad.ptihome.com:3100/mcp
http://aftec-server:5000http://aftec-server.ad.ptihome.com:5000

Applies to every App Service env var / connection string that names an on-prem server. Local dev and the self-hosted runner can use short names (they have the search domain).

The self-hosted runner has two systemd-resolved drop-ins in /etc/systemd/resolved.conf.d/: ad-domain.conf pins ad.ptihome.com / ptihome.com to the DCs (so AD names never fall back to Azure DNS and resolve to wrong public IPs), and privatelink.conf routes privatelink.* to Azure DNS (168.63.129.16) — which is why the runner reads the Azure private DNS zone, not the DC zone. See deploy-to-azure §10.


The runner’s /etc/systemd/resolved.conf.d/*.conf drop-ins declare DNS= and Domains=~privatelink.* in global [Resolve] sections. systemd-resolved merges global sections into one server pool — it does not route the privatelink domains to 168.63.129.16 specifically. Whichever server is “current” (usually the DC, 10.160.0.5) answers privatelink queries, and the DC is authoritative for privatelink.azurewebsites.net — so a brand-new app with no DC record gets a definitive NXDOMAIN and resolved never tries the wireserver (NXDOMAIN is a valid answer, not a failure).

Practical rule: add the DC records BEFORE the first deploy of a new app. The Azure-zone records alone are not sufficient on the runner (observed live 2026-07-28 with ps-iot-bridge: wireserver answered 10.160.140.27 directly while resolvectl query via the merged pool returned NXDOMAIN). The 2026-06-25 “runner reads the Azure zone alone” note likely caught a moment when the current server happened to be the wireserver — do not rely on it.

Re-measured 2026-08-05 with ps-sop — the outcome flips with whichever server is current, which is the whole point. With only the Azure-zone records present, resolvectl query ps-sop.scm.azurewebsites.net returned 10.160.140.31 reproducibly across cache flushes, while nslookup … 10.160.0.5 (the DC) returned NXDOMAIN; resolvectl status showed Current DNS Server: 168.63.129.16. That is the mirror image of the 2026-07-28 ps-iot-bridge observation, on the same runner, with the same config.

So both observations are correct and neither is the rule. The behaviour is non-deterministic — treat a successful resolvectl query as luck, not as evidence the DNS is done. The DC records are what make it deterministic. A deploy attempted without them may well succeed and then fail later for no apparent reason, which is a worse failure than a clean block.


Workstation gotcha: Cisco Umbrella

PSI workstations run the Cisco Umbrella endpoint client, which intercepts DNS below the Windows resolver — even queries that explicitly target an internal DNS server with -Server. Symptom: the same query returns the private IP from a DC but the public CNAME chain from a workstation.

  • Verify it’s Umbrella: stop the Umbrella service (Stop-Service "Umbrella RC"), re-run, get the right answer.
  • Fix (don’t blanket-disable): add an Umbrella internal-domain entry for the hostname, or test from a VNet machine (CI runner, App Service console, an Azure VM), or use the PE IP directly during testing.

Production traffic from operators / handhelds isn’t affected the same way — this is a diagnostic surprise, not a runtime issue.


SymptomLikely DNS cause
Deploy 403 Forbidden / NXDOMAIN from *.scm.azurewebsites.netMissing app.scm record in the Azure private DNS zone (the runner resolves via that zone through its drop-in)
Browser 403 x-ms-forbidden-ip from PSI office / VPNPublic DNS returned a public IP — missing per-app primary zone (slots) or missing public A/privatelink record
ENOTFOUND / fetch failed from App Service to on-premShort hostname in an env var — use the FQDN
nslookup differs on workstation vs DCCisco Umbrella interception on the workstation
Intermittent TLS failures on the runnersystemd-resolved fell back to Azure DNS for ad.ptihome.com — re-pin to the DCs
Internal name resolves to a hostile external hostAn internal CNAME leaked to public DNS (PSI no longer owns ptihome.com) — use a direct A record, not a CNAME to an *.ptihome.com name

Incident reference (2026-02-23): api.progressivesurface.com was a CNAME to ps-proxy.ad.ptihome.com. Because PSI no longer owns ptihome.com publicly, the name leaked to a hostile wildcard. Fixed by replacing the CNAME with a direct A record (→ 192.9.201.217). Lesson: don’t CNAME public records to *.ptihome.com.


Maintenance rules

  • Two zones, always. Every new App Service private endpoint = records in the Azure Private DNS zone (the deploy runner reads this) and the DC primary zone (all other VNet/VPN clients read this), and both app + app.scm in each.
  • Assign PE IPs sequentially from PS-ProdData (10.160.140.0/24). The live allocation + next-free IP is in Private Endpoint Subnet Allocation.
  • Public records live in Azure DNS (progressivesurface.com, PS-RG-01); privatelink + AD records live on the DCs.
  • After any DNS change, verify from a VNet machine, then from a VPN laptop (catches the Umbrella / slot-403 classes).


Last updated: 2026-06-25 (canonical DNS standards page; resolver-path model verified live on ps-cicd-runner — runner reads the Azure zone, other clients read the DC zone).