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.scmrecords 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
- Network shares use DFS, never server names.
\\ad.ptihome.com\DFS\…, never\\fs1\…or\\PS-GR-FS01\…. - 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.”
- Privatelink resolution needs records in two zones — the Azure Private DNS zone and an AD-integrated primary zone on the DCs. Add both the
apprecord and theapp.scmrecord. Missing.scmis the #1 cause of deploy 403s. - 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 thead.ptihome.comsearch domain. - Cisco Umbrella intercepts workstation DNS even when you pass
-Server. A workstationnslookupis not a reliable internal-DNS diagnostic — test from a DC or VNet machine.
The resolvers and zones
DNS servers
| Server | IP | Role |
|---|---|---|
| PS-AZ-DC01 | 10.160.0.5 | Primary DNS + Azure domain controller; authoritative for the privatelink primary zones |
| On-prem DC | 192.9.200.110 | Secondary DNS (PSI HQ) |
| Azure wireserver | 168.63.129.16 | Azure-internal resolver — avoid pinning apps to it; it can’t see internal AD zones and resolves ad.ptihome.com to wrong public IPs |
| Cisco Umbrella | endpoint client | Intercepts DNS on PSI workstations below the Windows resolver (see Umbrella gotcha) |
Zones
| Zone | Hosted on | Purpose |
|---|---|---|
progressivesurface.com | Azure 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 DCs | Internal 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.net | Azure Private DNS (PS-RG-01) and AD primary zone (PS-AZ-DC01) | App Service / Function App private endpoints |
privatelink.database.windows.net | both | Azure SQL private endpoints |
privatelink.vaultcore.azure.net | both | Key Vault private endpoints |
privatelink.servicebus.windows.net / privatelink.azure-devices.net | both | Service Bus / IoT Hub |
DFS namespace \\ad.ptihome.com\DFS\* | AD | All 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 asystemd-resolveddrop-in that routesprivatelink.*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 returnedNXDOMAIN.)- Every other VNet/VPN client (Windows machines, App Proxy connector VMs — no drop-in) resolves the
app.azurewebsites.nethostname 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 forprivatelink.azurewebsites.netis 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-sop→sop.progressivesurface.com,ps-dispatch→dispatch,ps-bom-coverage→bom-coverage. Records 1–2 use the custom-domain label; records 3–5 use the App Service name, because privatelink resolution keys off<app>.azurewebsites.netand never sees the custom domain. Getting this backwards writes theasuidTXT 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 whatNew-WebAppDnsRecords.ps1did on its first live run — fixed in psi-azure-admin#10, which added-CustomDomain.)A few apps break the pattern outright (
bom-explorer-web→bomexplorer,ps-project-explorer→projects), so confirm the intended hostname rather than deriving it.
| # | Record | Zone | Where |
|---|---|---|---|
| 1 | asuid.myapp TXT = customDomainVerificationId | progressivesurface.com | Azure DNS |
| 2 | myapp A → 10.160.140.X | progressivesurface.com | Azure DNS |
| 3 | ps-myapp A → 10.160.140.X and ps-myapp.scm A → 10.160.140.X | privatelink.azurewebsites.net | Azure Private DNS (PS-RG-01) |
| 4 | ps-myapp A → 10.160.140.X and ps-myapp.scm A → 10.160.140.X | privatelink.azurewebsites.net | DC primary zone (PS-AZ-DC01, forest-replicated) |
| 5 | (deployment slots only) per-app primary zones ps-myapp-<slot>.azurewebsites.net + .scm apex → slot PE IP | new primary zone | DC |
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.comA record →170.249.213.26(TTL 1800).myapp.progressivesurface.comtherefore 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 anslookupthat returns something is not evidence the record exists. Check the returned IP, not merely that resolution succeeded. The explicitmyappA 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.
| Zone | Who can write | How a dev gets a record |
|---|---|---|
progressivesurface.com (Azure DNS) | Subscription Owner | Request |
privatelink.azurewebsites.net (Azure Private DNS) | Subscription Owner | Request |
privatelink.azurewebsites.net (DC, AD-integrated) | Domain Admins only | Request |
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.42The public hostname defaults to the app name minus ps-. When the request asks for something else (bom-explorer-web → bomexplorer), pass it explicitly:
./scripts/New-WebAppDnsRecords.ps1 -AppName bom-explorer-web -PrivateEndpointIp 10.160.140.42 -CustomDomain bomexplorerAdd -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.XVerify (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.
Adding a new privatelink service type
If you create the first private endpoint for a new Azure service (Blob → privatelink.blob.core.windows.net, Key Vault → privatelink.vaultcore.azure.net, …):
- Create the Azure Private DNS zone in PS-RG-01 and link it to
PS-VNMAIN. - Create a matching AD-integrated primary zone on PS-AZ-DC01 (forest-replicated).
- Add A records to both 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).
| Wrong | Correct |
|---|---|
http://ps-proxy:3100/mcp | http://ps-proxy.ad.ptihome.com:3100/mcp |
http://aftec-server:5000 | http://aftec-server.ad.ptihome.com:5000 |
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.
Runner gotcha: privatelink drop-in does NOT split-route (verified 2026-07-28)
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.
DNS-related failure → cause
| Symptom | Likely DNS cause |
|---|---|
Deploy 403 Forbidden / NXDOMAIN from *.scm.azurewebsites.net | Missing 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 / VPN | Public 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-prem | Short hostname in an env var — use the FQDN |
nslookup differs on workstation vs DC | Cisco Umbrella interception on the workstation |
| Intermittent TLS failures on the runner | systemd-resolved fell back to Azure DNS for ad.ptihome.com — re-pin to the DCs |
| Internal name resolves to a hostile external host | An 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.comwas a CNAME tops-proxy.ad.ptihome.com. Because PSI no longer ownsptihome.compublicly, the name leaked to a hostile wildcard. Fixed by replacing the CNAME with a directArecord (→ 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.scmin 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).
Related Pages
- webapp-compliance-standard — the canonical web-app baseline (Networking requirement points here)
- deploy-to-azure — command-level DNS how-to (§5 private endpoints, §10 runner + privatelink zones, §4b slots)
- azure-resources — private-endpoint IP allocation, zones, DNS servers
- azure-security — current network/auth posture and exceptions
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).