DGX Spark to Azure Private Endpoint: Overview and Architecture
Reach a private-only Microsoft Foundry account from a DGX Spark at home for about $14 a month: a WireGuard VM in the VNet, split DNS on the DGX, and a probe that shows every DNS answer in Grafana.
Your Foundry account has public network access switched off, and your DGX Spark sits on a home network. This lab connects the two for about $14 a month, without a VPN Gateway. By the end of this part you’ll know what gets built, what it costs, and which way a DNS query and a request travel before an SDK call from the DGX gets a 200.
What You Will Build
A call from the DGX to a private endpoint needs two things, and missing either one gets you the same 403 Public access is disabled:
- A network path into the VNet. A WireGuard tunnel from the DGX to a small Linux VM in the VNet. The VM forwards the traffic and NATs it to the private endpoint.
- A private DNS answer on the DGX. The account’s public name is a CNAME into a
privatelink.*zone. Only resolvers inside a VNet linked to that zone get the private IP. The DGX sends just the three Azure AI domains through the tunnel to dnsmasq on the VM (split DNS). Everything else keeps using your home router.
The lab builds a complete, disposable copy of that setup in its own resource group: a VNet, a Foundry (AIServices) account with public access disabled, its private endpoint and DNS zones, and the WireGuard VM. Everything is created with the az CLI. On the DGX it adds one WireGuard interface, wg0, and three resolvectl routing domains.
So you can see each step take effect, a small probe on the DGX calls the account’s three names every 10 seconds. It reports the DNS answer, the path (private or public) and the HTTP status to a local Grafana as metrics, logs and traces. You make one change at a time and watch the dashboard react.
1
2
3
cd private-connectivity-labs/azure-private-endpoint
make azure-up # about 10 minutes: everything on the Azure side
make probe-up # local Grafana on http://localhost:3012 and the probe
TODO: The lab code lives in
azure-private-endpoint/of theprivate-connectivity-labsrepo (github.com/cloudutsukpilot/private-connectivity-labs), which is private for now. Once it’s public, addrepo.urlto_data/labs/azure-private-endpoint-lab.ymland a clone command here.
Prerequisites
| Tool | Version this lab was run with | Check |
|---|---|---|
| Azure CLI, logged in | 2.77.0 | az account show |
| Docker Engine + Compose | 29.2.1, Compose 5.0.2 | docker compose version |
| GNU Make | 4.3 | make --version |
Python 3 with cryptography | 3.12.3 | python3 -c "import cryptography" |
| systemd-resolved | systemd 255 (Ubuntu 24.04 on the DGX) | resolvectl status |
| wireguard-tools | 1.0.20210914 (installed by make dgx-install) | wg --version |
| sudo on the DGX | sudo -v |
Azure access. You need to be able to create a resource group, a VNet, a VM, a public IP, a private endpoint, private DNS zones and a Cognitive Services account in one subscription. Check your VM quota before you start. On the subscription I used, the newer B-series families (Basv2, Bpsv2) had a quota of 0 cores, so the lab defaults to Standard_B1s:
1
az vm list-usage -l eastus --query "[?contains(name.value,'standardB')].{family:name.value,limit:limit}" -o table
Local resources. Grafana LGTM runs in Docker on the DGX on port 3012 (and OTLP on 14318), and needs about 1 GiB of memory. The probe uses almost nothing.
Cost. Retail East US prices, as of October 2026:
| Piece | Price | Per month |
|---|---|---|
VM Standard_B1s (1 vCPU, 1 GiB) | $0.0104/h | $7.59 |
| Standard static public IPv4 | $0.005/h | $3.65 |
| 32 GB Standard SSD (E4) | $2.40 | |
| Connectivity total | about $13.64 | |
| Private endpoint (you may already have one) | $0.01/h + $0.01/GB | $7.30 |
| Three private DNS zones | $0.50 each | $1.50 |
While the whole lab is up it costs about $0.75 a day. make down deletes all of it. If your subscription has Bpsv2 or Basv2 quota, VM_SIZE=Standard_B2pts_v2 (Arm64, $0.0084/h) or Standard_B2ats_v2 ($0.0094/h) brings the connectivity cost down to about $12.20 to $12.90.
Why not a VPN Gateway?
I priced the managed options before choosing a VM:
| Option | Per month | Notes |
|---|---|---|
| WireGuard VM (this lab) | about $12 to $14 | You patch one small VM |
| Same VM, deallocated when idle | about $6 | IP and disk only; the tunnel comes back on its own (Experiment 6) |
| Tailscale subnet router on the same VM | same as the VM | No inbound port, but the VM still needs outbound internet (a public IP or a NAT Gateway) |
VPN Gateway VpnGw1AZ, point-to-site | about $157 ($0.21/h + IP) | Fully managed, and you still have to solve DNS |
VPN Gateway Basic | about $26 | Needs a Basic public IP, a SKU Azure has retired; a dead end for new builds |
| DNS Private Resolver (inbound endpoint) | $180 | The managed replacement for the lab’s free dnsmasq |
/etc/hosts entries on the DGX | $0 | Works until the endpoint is recreated and its IPs change |
For one machine at home, a VM is about a tenth of the price of the gateway, and the DNS part is free.
Architecture
The diagram opens one layer deep: open the DGX, the VNet and its subnets with the level buttons or a click, and hover an arrow for its protocol and why it exists. The tabs at the top add the security, observability and provisioning paths. Pick a use case on the left, such as Resolve the name privately or Call through the private endpoint, and press play to follow the packet:
Laying out the diagram…
DGX Spark to Azure Private Endpoint
- You (on the DGX)
- DGX Spark (home network)
- pe-probe (App / SDK)
- Host networking (split DNS + tunnel): systemd-resolved, wg0
- Grafana LGTM (Docker, grafana/otel-lgtm): OTLP receiver, Grafana
- Home router DNS (every other name)
- Azure (rg for the lab, East US)
- Azure Resource Manager (az CLI)
- VNet (10.60.0.0/16): snet-vpn, snet-pe
- Azure DNS (168.63.129.16)
- Private DNS zones (linked to the VNet): cognitiveservices zone, openai zone, services.ai zone
- Foundry account (AIServices): Network rules, Data plane
Call over the internet: Experiment 1: no tunnel, no split DNS. The name resolves publicly and the account says 403.
- Look up the name: The SDK needs an IP first. glibc asks systemd-resolved on 127.0.0.53.
- Ask the home router: Without split DNS no link claims the name, so the router's DNS answers: a CNAME through privatelink.* to a public IP.
- Call the public endpoint: DNS worked and the TCP and TLS handshakes succeed. Hover the arrow for both.
- Refused: The account's network rules reject anything that doesn't arrive through its private endpoint.
Resolve the name privately: Experiment 3: split DNS sends only the Azure AI names through the tunnel to Azure DNS.
- Look up the name: The same lookup as before; this time a routing domain on wg0 matches it.
- Route it to wg0: Only the three Azure AI domains are routed to wg0. github.com still goes to the home router.
- Through the tunnel: WireGuard wraps the query in UDP to the VM's public IP; the NSG accepts it from your home IP only.
- dnsmasq picks it up: dnsmasq listens on the VM's tunnel address only.
- Forward to Azure DNS: Azure DNS answers only from inside Azure, and only it sees the zones linked to the VNet.
- The private zone answers: The public CNAME points into the privatelink zone; because it is linked, the answer is private.
- The answer comes back: The answer retraces the path through the tunnel to the probe.
Call through the private endpoint: With 10.60.1.5 in hand, the HTTPS request takes the tunnel and comes back 200.
- Into wg0: wg-quick routed 10.60.0.0/16 via wg0 (the AllowedIPs); nothing else uses the tunnel.
- Encrypted to the VM: Same tunnel, same UDP port. The VM decrypts a TCP SYN from 10.200.0.2 to 10.60.1.5:443.
- Forward and NAT: MASQUERADE rewrites the source, so the VNet needs no route back to 10.200.0.0/24.
- Into the private endpoint: The private endpoint has one IP per public name: .4 cognitiveservices, .5 openai, .6 services.ai.
- The account answers: Traffic through the private endpoint is allowed even with public network access disabled.
- 200 back to the DGX: The reply goes to 10.60.2.4, the VM un-NATs it and sends it back through the tunnel.
VM deallocated: Experiment 5: with the VM off the Azure AI names fail instead of falling back to public.
- Look up the name: wg0 is still up on the DGX, so the routing domain still matches.
- Still sent to wg0: systemd-resolved doesn't fall back to the router for a name a link claims.
- Nobody answers: The query goes into the tunnel to a VM that is off and nothing comes back: DNS TIMEOUT or SERVFAIL for all three names.
Watch it in Grafana: Every probe round becomes metrics, logs and traces on the local dashboard.
- Probe exports: Each round's DNS answer, path, HTTP status and spans are exported to grafana/otel-lgtm.
- Open the dashboard: No login: the container runs Grafana with anonymous admin, on the DGX only.
- Query the signals: Name resolves to, HTTP status, the tunnel's health and the probe log.
Build the lab: make azure-up creates every Azure resource with the az CLI in about 10 minutes.
- Run make azure-up: One resource group holds everything, so make down can delete it all.
- Network and VM: cloud-init on the VM sets up WireGuard, IP forwarding, MASQUERADE and dnsmasq.
- The account: From here on, only traffic through the private endpoint is let in.
- Zones and links: A zone with no VNet link is never consulted by Azure DNS.
- Records written: If the endpoint is ever recreated with new IPs, the records follow.
- Done: About 10 minutes end to end. make probe-up then starts the local Grafana and the probe.
| Component | Role | Where it runs |
|---|---|---|
App / SDK, pe-probe | Calls the account by its public name | DGX |
| systemd-resolved | Sends ~cognitiveservices.azure.com, ~openai.azure.com and ~services.ai.azure.com to 10.200.0.1, and everything else to the router | DGX, 127.0.0.53 |
wg0 | WireGuard interface. Routes only 10.200.0.1/32 and 10.60.0.0/16 | DGX, 10.200.0.2 |
vm-wg-pe-lab | WireGuard peer, IP forwarding and MASQUERADE, dnsmasq | Azure, snet-vpn 10.60.2.4, public IP |
NSG nsg-vpn | Allows UDP 51820 and SSH from your home IP only | Azure, on snet-vpn |
| Azure DNS | The resolver that knows the VNet’s linked private zones | 168.63.129.16, reachable only from inside Azure |
| Private DNS zones | privatelink.cognitiveservices/openai/services.ai.azure.com, linked to the VNet | Azure (global) |
| Private endpoint | One NIC in snet-pe with three IPs: .4 cognitiveservices, .5 openai, .6 services.ai | Azure, 10.60.1.4 to .6 |
| Foundry account | AIServices, publicNetworkAccess: Disabled | Azure, East US |
| Grafana LGTM | Receives the probe’s metrics, logs and traces | DGX, Docker, :3012 |
Building Blocks
Private endpoint and Private DNS
A private endpoint is a NIC in your VNet that leads to one Azure resource. Turning off public access on the resource means only traffic arriving through such an endpoint is let in. The catch is the name: clients still call <account>.openai.azure.com, and something has to resolve that name to the endpoint’s IP. Azure does it with a CNAME to <account>.privatelink.openai.azure.com plus a private DNS zone of that name, linked to the VNet. Background: Azure - Private Endpoints.
Microsoft Foundry account (AIServices)
The account behind Foundry projects and Azure OpenAI deployments. One account answers on three public names (cognitiveservices.azure.com, openai.azure.com, services.ai.azure.com), and each has its own privatelink zone. The lab creates one with public access disabled and calls GET /openai/models on it, which needs no model deployment and costs no tokens.
WireGuard
A VPN built into the Linux kernel: one UDP port, a key pair per peer, and a list of AllowedIPs that decides both what is routed into the tunnel and what is accepted out of it. There’s no handshake daemon and no certificates. Here it’s a split tunnel: only the VNet goes through it. Background on the managed alternative: Azure - Virtual Network Gateway.
dnsmasq
A small DNS forwarder. On the VM it listens on the tunnel address only and forwards every query to Azure DNS. It does the same job as a DNS Private Resolver inbound endpoint, for free.
systemd-resolved split DNS
Ubuntu’s resolver keeps DNS settings per network link. A domain prefixed with ~ is a routing domain: queries for names under it go to that link’s DNS server and nowhere else. That is how the DGX sends three domains to Azure and everything else to the router.
Azure VNet
The private network the endpoint lives in: 10.60.0.0/16 with snet-pe for the endpoint and a /28 snet-vpn for the VM. Background: Azure - Virtual Networks.
OpenTelemetry and Grafana LGTM
The probe uses the OpenTelemetry Python SDK to push metrics, logs and traces over OTLP to grafana/otel-lgtm, a single container with Grafana, Prometheus, Loki and Tempo. It’s only there so you can see each change. The connectivity works without it.
What You Will Learn
- Why a private-only account answers
403from the internet even though its public name already points atprivatelink(Experiment 1). - That routing to the endpoint’s IP isn’t enough while DNS still returns the public IP (Experiment 2).
- How split DNS with
resolvectlsends only the Azure AI names to Azure DNS (Experiment 3). - Why a Foundry endpoint needs three private DNS zones, not one, and how long the fix takes to reach the DGX (Experiment 4).
- What the DGX sees when you stop the VM to save money, and how it recovers on its own (Experiments 5 and 6).
Check Your Understanding
The DGX can reach 10.60.1.5 over the tunnel. Why does an SDK call still fail?
The SDK connects to whatever the name resolves to. Until the DGX asks a resolver that sees the linked private zone, <account>.openai.azure.com resolves to the public IP, the call goes over the internet, and the account’s firewall rejects it with 403. Routing and DNS are two separate halves.
Why can't the DGX use 168.63.129.16 directly through the tunnel?
168.63.129.16 is a virtual IP that answers only for traffic coming from an Azure VM’s own NIC. A packet that arrives from your home network through WireGuard isn’t from an Azure host, so the lab puts a forwarder (dnsmasq) on the VM, which does ask from inside Azure.
What would a VPN Gateway save you, and what wouldn't it?
It saves you patching and running a VM: Azure runs the gateway, with SLAs and zone redundancy. It doesn’t solve DNS. The DGX would still need a resolver inside the VNet, which means a DNS Private Resolver ($180 a month) or a forwarder VM anyway. For one machine that’s about $157 plus DNS against about $14.
Why is the tunnel "split", and what would change with AllowedIPs = 0.0.0.0/0?
With AllowedIPs = 10.200.0.1/32, 10.60.0.0/16, only VNet traffic goes through Azure. Everything else (model downloads, apt, GitHub) uses your home connection at full speed. With 0.0.0.0/0, everything would go through a 1-vCPU VM and leave Azure as billable egress, and you’d need extra rules to keep the tunnel’s own UDP packets off the tunnel.
