top of page
  • 1 day ago
  • 10 min read
Azure Monitor Private Link workshop showing an AMA-enabled private VM, AMPLS, collection rules and endpoints, Log Analytics, and blocked public access.

Azure Monitor Private Link is more than creating a private endpoint. DNS, resource-level network restrictions, data collection rules and authentication all have to agree. This workshop builds that path, tests it from two separate networks, deliberately breaks DNS, and proves recovery using commands and real text evidence.



Everything below is code-driven: PowerShell 7, Azure CLI, az rest, Python guest probes and KQL. There are no Azure Portal walkthroughs or screenshots. The downloadable bundle contains the reusable scripts, queries and sanitized live-test report.


Timing, level and cost warning


Level: intermediate to advanced. Allow approximately 90–150 minutes, including Azure deployment, role propagation and ingestion latency. Familiarity with VNets, managed identities and Log Analytics is helpful.


Cost warning: this lab creates two small Ubuntu VMs and disks, two NAT Gateways and public IPs, an Azure Monitor private endpoint, private DNS zones and a pay-as-you-go Log Analytics workspace. NAT and endpoint charges continue even when no test is running. Log ingestion also costs money. Use an authorized disposable subscription and finish with the cleanup and residual-resource checks. Never mistake a workspace daily cap for a subscription spending limit.


What the lab must prove


  • Both networks initially send and query telemetry using exactly the same probe identity.

  • The connected network continues working after public ingestion/query access is disabled.

  • The outside network receives explicit network-access denials, not an authentication failure or a generic timeout.

  • Private endpoint DNS and actual HTTPS socket destinations agree.

  • AMA produces fresh Heartbeat and Syslog records.

  • Removing the connected network's Monitor DNS link breaks the expected path; restoring it recovers ingestion and query access.


The published run passed all required live tests. The evidence section records the observed results; teaching examples elsewhere describe what your own run should produce.


Architecture and the important distinctions


Two unpeered VNets contain one VM each. Neither VM has a public IP or inbound SSH. Each VNet has explicit NAT egress so the outside VM can still contact public Azure Monitor endpoints. A shared user-assigned identity gives both probes the same authorization. Only the inside VNet is linked to the lab's private DNS zones and AMPLS private endpoint.


Inside VM ── private DNS ── AMPLS private endpoint ── Log Analytics / DCE
   │                                                        ▲
   └─ AMA configuration via DCE; Syslog/Heartbeat to workspace │
                                                            │
Outside VM ── public DNS + working NAT egress ── network denial

Both probe VMs: same managed identity, unchanged scoped RBAC
Local PowerShell: Azure control plane through CLI / az rest
Guest Python: ingestion + query data plane using IMDS tokens

AMPLS access modes and workspace network flags are separate controls. In particular, Log Analytics workspace-specific ingestion endpoints do not follow AMPLS modes in the same way as shared endpoints. This workshop therefore disables workspace public ingestion and query access and DCE public access, while setting both AMPLS access modes to PrivateOnly. AMPLS alone is not a universal exfiltration firewall. Private-link design guidance


The agent needs its configuration-DCE association as well as its collection-rule association. For this Log Analytics scenario, AMA configuration uses the DCE; AMA log delivery uses the workspace endpoint. The custom REST probe separately uses the DCE ingestion endpoint. Do not conflate those paths. AMA private-link configuration


Stage 1 — Read-only preflight


Extract the ZIP into outputs/azure-monitor-private-link-deep-dive and open PowerShell 7 in that directory. Private state is kept outside the public bundle, under the sibling work directory.


az login
$subscriptionId = '<your-subscription-id>'
az account set --subscription $subscriptionId
./Start-AzureMonitorPrivateLinkWorkshop.ps1 `
    -SubscriptionId $subscriptionId -Stage Preflight

Preflight reads authentication, ownership, provider/API availability, the Ubuntu image, VM-size restrictions, compute quota, public-IP quota and permissions. It does not register providers or create Azure resources. This implementation conservatively requires subscription Owner control-plane permission; it does not infer the effectiveness of arbitrary custom roles.


Useful underlying checks, with intentionally limited output:


az account show --query '{name:name,state:state}' -o json
az group exists --name rg-ampls-deepdive-we
az vm list-skus --location westeurope --size Standard_B1s --all `
    --query "[?name=='Standard_B1s'].{name:name,restrictions:restrictions}" -o json
az provider show --namespace Microsoft.Insights `
    --query '{state:registrationState}' -o json

An unexpected existing group is a stop condition. The scripts never adopt resources merely because their names look correct.


Stage 2 — Foundation, identities and the probe stream


./Start-AzureMonitorPrivateLinkWorkshop.ps1 -Stage Foundation

Foundation creates the two isolated networks, explicit NAT routes, VMs, workspace, DCE and custom ingestion rule. Subnets explicitly set defaultOutboundAccess=false. Azure VM Run Command provides administration without opening SSH.


The shared probe identity receives Monitoring Metrics Publisher on the probe DCR and Log Analytics Reader on the workspace. These roles remain unchanged throughout the before/after experiment. The inside VM also has a system-assigned identity for AMA.


The custom table's schema is intentionally small:


{
  "columns": [
    { "name": "TimeGenerated", "type": "datetime" },
    { "name": "RunKey", "type": "string" },
    { "name": "Stage", "type": "string" },
    { "name": "Source", "type": "string" },
    { "name": "Marker", "type": "string" }
  ]
}

Create the table before the DCR. Its Custom-PrivateLinkProof_CL input stream declares these columns, routes them through transformKql: source, and sends them to the workspace's PrivateLinkProof_CL table. The workspace, DCE and DCR are in the same region. Logs Ingestion API


Stage 3 — Establish the public baseline


./Start-AzureMonitorPrivateLinkWorkshop.ps1 -Stage PublicBaseline

The orchestrator invokes the same guest probe on both VMs. Python obtains tokens from IMDS in guest memory and submits a unique marker. Tokens are never printed or returned to the local orchestrator.


The ingestion request uses the DCR immutable ID, not its ARM resource ID:


POST <dce-logs-ingestion-endpoint>/dataCollectionRules/<immutable-dcr-id>/streams/Custom-PrivateLinkProof_CL?api-version=2023-01-01
Content-Type: application/json
Authorization: Bearer <in-memory-managed-identity-token>

An HTTP 204 means the request was accepted, not that a query can already find the row. The script separately polls for the marker:


PrivateLinkProof_CL
| where RunKey == '<session-run-key>'
| where Marker == '<unique-marker>'
| summarize Records=count() by Source, Stage, Marker

Expected baseline: ingestion HTTP 204 and query HTTP 200 from both VMs, with both markers materialized. Without this baseline, a later denial cannot establish that network configuration caused the difference.


Stage 4 — AMPLS, private endpoint and DNS


./Start-AzureMonitorPrivateLinkWorkshop.ps1 -Stage PrivateLink

The script adds the workspace and DCE as AMPLS scoped resources, creates an approved private endpoint using subresource azuremonitor, then associates the five DNS zones with the private endpoint. Only the inside VNet receives DNS links. Avoid attaching these lab zones to production networks: Azure Monitor has shared endpoints, so conflicting DNS arrangements can affect unrelated workspaces. Configure Azure Monitor Private Link


For the 2023-06-01-preview AMPLS API, scoped-resource associations also require properties.kind: Resource. The helper supplies it for both the workspace and DCE; omitting it produces a BadRequest response.


The actual private-link connection body uses this structure; the scripts populate the placeholders from their private state:


{
  "privateLinkServiceConnections": [
    {
      "name": "ampls",
      "properties": {
        "privateLinkServiceId": "<workshop-ampls-resource-id>",
        "groupIds": ["azuremonitor"]
      }
    }
  ]
}

DNS checks resolve the DCE ingestion endpoint, DCE configuration endpoint, query endpoint and workspace ingestion/control endpoints. The assertions compare each result against the private endpoint NIC's addresses. The Python probe also inspects the connected HTTPS socket's peer address, so a DNS result alone is not accepted as path proof.


This stage is a routing check, not the final delivery test: its report preserves the HTTP statuses actually seen while the resource access settings are still being configured. End-to-end private ingestion and marker visibility are mandatory in Stage 7, after all lockdown controls are applied. A private socket by itself does not prove that the service accepted a record.


Stage 5 — Disable public ingestion and query access


./Start-AzureMonitorPrivateLinkWorkshop.ps1 -Stage Lockdown

The underlying workspace request is a PATCH containing:


{
  "properties": {
    "publicNetworkAccessForIngestion": "Disabled",
    "publicNetworkAccessForQuery": "Disabled"
  }
}

The DCE receives networkAcls.publicNetworkAccess: Disabled. The AMPLS receives:


{
  "accessModeSettings": {
    "ingestionAccessMode": "PrivateOnly",
    "queryAccessMode": "PrivateOnly"
  }
}

The wrapper uses az rest for these changes. To see the complete request pattern, the following command applies the workspace restriction using the same private runtime state. Run it only after Stage 4 has passed:


. ./MonitorPrivateLink.Common.ps1
Initialize-Context (Get-Location).Path $subscriptionId 'westeurope' 'Standard_B1s' 30
Assert-Ownership
Require-Stage PrivateLink
# Preserve the no-reopen guard even if a later request fails.
$script:State.Locked = $true
Save-State
Write-PrivateJson 'workspace-lockdown.json' @{
    properties = @{
        publicNetworkAccessForIngestion = 'Disabled'
        publicNetworkAccessForQuery = 'Disabled'
    }
}
az rest --method PATCH `
    --url "https://management.azure.com$($script:State.WorkspaceId)?api-version=2023-09-01" `
    --headers 'Content-Type=application/json' `
    --body "@$(Join-Path $script:Runtime 'workspace-lockdown.json')" `
    --query '{ingestion:properties.publicNetworkAccessForIngestion,query:properties.publicNetworkAccessForQuery}'
# Complete the DCE and AMPLS restrictions and record the stage result.
./Start-AzureMonitorPrivateLinkWorkshop.ps1 -Stage Lockdown

For a read-only inspection after the stage, load the helper's private state without printing it:


. ./MonitorPrivateLink.Common.ps1
Initialize-Context (Get-Location).Path $subscriptionId 'westeurope' 'Standard_B1s' 30
az rest --method GET `
    --url "https://management.azure.com$($script:State.WorkspaceId)?api-version=2023-09-01" `
    --query '{ingestion:properties.publicNetworkAccessForIngestion,query:properties.publicNetworkAccessForQuery}'

Once lockdown starts, reruns cannot silently reopen public access. Repeating the initial public-baseline experiment requires cleanup and a fresh deployment.


Stage 6 — AMA, configuration access and syslog collection


./Start-AzureMonitorPrivateLinkWorkshop.ps1 -Stage AMA

The VM has two associations: configurationAccessEndpoint points at the DCE, and workshop-syslog points at the Linux syslog DCR. The agent extension uses the VM's system-assigned identity.


Inspect the installed extension and both associations without displaying runtime IDs:


az vm extension show --resource-group rg-ampls-deepdive-we `
    --vm-name vm-ampls-inside --name AzureMonitorLinuxAgent `
    --query '{name:name,state:provisioningState,version:typeHandlerVersion}'
$vmId = Resource-Id 'Microsoft.Compute/virtualMachines' 'vm-ampls-inside'
az rest --method GET `
    --url "https://management.azure.com$vmId/providers/Microsoft.Insights/dataCollectionRuleAssociations?api-version=2023-03-11" `
    --query 'value[].{name:name}'

The rule selects local0 syslog events at Notice or higher severity. A guest command emits a unique marker:


logger -p local0.notice -t ampls-workshop '<unique-syslog-marker>'

Query fresh records through the inside VM's data-plane probe:


Heartbeat
| where TimeGenerated > ago(15m)
| where Computer == 'vm-ampls-inside'
| summarize Records=count(), LastSeen=max(TimeGenerated)

Syslog
| where TimeGenerated > ago(30m)
| where SyslogMessage contains '<unique-syslog-marker>'
| summarize Records=count()

An installed extension is configuration evidence, not delivery evidence. This stage does not pass until both a fresh heartbeat and the unique syslog marker appear.


Stage 7 — Prove the network boundary


./Start-AzureMonitorPrivateLinkWorkshop.ps1 -Stage TestBoundary

The inside VM sends a fresh marker and queries it over verified private sockets. The outside VM uses the same identity and unchanged roles but resolves public service endpoints. It must receive explicit service-side network denials for ingestion and query.


The expected result is:


Connected network:  ingestion 204, query 200, marker found, private peer verified
Outside network:    ingestion denied, query denied, network-policy error verified
Identity and RBAC:   unchanged from the successful public baseline

A bare HTTP 403 is insufficient: it can also mean missing permissions. The scripts inspect the error body for a network-specific denial. HTTP 401, DNS failure and transport timeout cannot pass this test.


For example, the observed outside query returned an outer InsufficientAccessError with an inner NspValidationFailedError explicitly explaining that public-network access to the workspace was denied. The classifier requires that nested network-specific reason; the outer error alone is inconclusive. This service error name does not mean the lab created a Network Security Perimeter.


The experiment deliberately queries the Logs data-plane API from the guest. A control-plane query through Resource Manager would not validate the intended private-link path.


Stage 8 — Break DNS, restore it, prove recovery


./Start-AzureMonitorPrivateLinkWorkshop.ps1 -Stage TestDnsRecovery

Only the inside VNet's link to the workshop privatelink.monitor.azure.com zone is temporarily removed. Other DNS zones and existing networks remain untouched. The probe flushes the guest DNS cache and opens new HTTPS connections.


The test waits for the requests to leave the private path and encounter public-access enforcement. It then restores the link in a finally block, verifies private addresses again, sends a different marker and queries that marker successfully.


This tests a real failure and recovery, not an invented error message. If the process is forcibly terminated, the private state records that the drill was active. Restore the link and rerun the drill before accepting final validation.


Stage 9 — Validate and export sanitized evidence


./Start-AzureMonitorPrivateLinkWorkshop.ps1 -Stage Validate
./Get-AzureMonitorPrivateLinkEvidence.ps1
Get-Content ./evidence/verified.json

Final validation rechecks network settings and all required evidence. Public output is built from an allowlisted set of statuses and assertions rather than redacting entire deployment responses. Raw resource IDs, IP addresses, identities, credentials and HTTP bodies stay under work.


The live run completed final validation on September 6, 2026. These are observed results, not simulated responses:


Public baseline, same probe identity:
  Inside:  ingestion 204; query 200; marker found
  Outside: ingestion 204; query 200; marker found

After private-only enforcement:
  Inside:  ingestion 204; query 200; fresh marker found
  Outside: ingestion 403; PublicNetworkAccessDisabled
  Outside: query 403; InsufficientAccessError
           inner network reason: NspValidationFailedError

Final AMA verification:
  Fresh Heartbeat records: 6
  Matching Syslog records: 3

DNS drill:
  Link removed: public-path network denials observed
  Link restored: ingestion 204; query 200; new marker found

Final state:
  Five private DNS zones linked only to the connected VNet
  Private DNS answers and HTTPS peers match the private endpoint
  No public VM IPs; no VNet peering; explicit NAT egress retained
  Workspace and DCE public access disabled
  AMPLS ingestion and query modes: PrivateOnly

The initial private-routing check returned ingestion HTTP 403 with PrivateLinkIdNotAllowed even though private queries worked. It was retained as routing-only evidence, not accepted as an ingestion pass. The service later converged: the mandatory boundary test passed with HTTP 204 from the connected VM and PublicNetworkAccessDisabled from the outside VM. No RBAC changes or public-access reopening were used to obtain that result. This is why the scripts wait for observed data-plane behavior instead of treating a successful ARM update as immediate enforcement.


Counts and timing will differ in your run. The bundle's evidence/verified.json and live-test-report.md contain the per-stage timestamps and sanitized assertions. They intentionally omit actual addresses, identities, resource IDs and marker values.


Troubleshooting map


| Symptom | Check | | --- | --- | | Baseline ingestion is denied | DCR role scope, identity selection and RBAC propagation; do not proceed to lockdown | | Query returns 401 | Query token audience and token acquisition, not private DNS | | Query returns a generic 403 | Workspace RBAC; do not call this a successful network test | | Ingestion returns 204 but no row | DCR schema, output stream, transform and ingestion delay | | Private DNS looks right but the socket is public | Endpoint hostname, caches and new connections | | Private ingestion reports PrivateLinkIdNotAllowed | Exact DCE membership in the scope, approved endpoint, endpoint hostname and service-side propagation; do not accept private query success alone | | AMA extension is installed but no data arrives | Configuration-DCE association, syslog-DCR association, rsyslog and DCE AMPLS membership | | DNS drill cannot demonstrate denial | Wait for resolver convergence; restore the link regardless and report the incomplete proof | | Cleanup rejects the group | Inspect unexpected resources and ownership state; never bypass safety with a broad delete |


Production hardening


Design AMPLS centrally for each shared DNS environment. Use deliberate DNS forwarding for hybrid clients and avoid overlapping private DNS configurations. Scope telemetry sender and query identities independently in production; sharing a probe identity here is an experimental control, not a general recommendation.


Apply appropriate outbound firewall rules if preventing exfiltration to other monitoring resources is a requirement. Monitor agent health, DCR changes and private endpoint/DNS drift. Define workspace retention and cost controls around the workload rather than copying this small lab's settings.


Resource diagnostic settings follow a Microsoft service-to-service path and are not a substitute for this private-ingestion experiment. This workshop does not test Prometheus, Azure Monitor workspace private endpoints or total isolation from the internet.


Cleanup and residual-resource verification


./Remove-AzureMonitorPrivateLinkWorkshop.ps1 -WhatIf
./Remove-AzureMonitorPrivateLinkWorkshop.ps1 -Confirm:$false
az group exists --name rg-ampls-deepdive-we

Expected final group-existence result: false. Cleanup first removes the workspace/DCE scoped-resource associations from AMPLS, then deletes the exact session-owned group. The script refuses unexpected resources, waits for group deletion, and only then removes private runtime state. The reusable bundle remains available.


No existing regional Network Watcher, provider registrations or unrelated DNS infrastructure are removed. If deletion is still pending, retain private state and retry cleanup; never erase your ownership record early.


Final result


The verified outcome is a complete code-driven demonstration of public-baseline success, private-only enforcement, authenticated outside-network denial, fresh AMA delivery, and DNS failure/recovery. The live-test report is the authority for what passed in this run. Keep the same evidence gates when repeating the lab in your own subscription.


Microsoft references



Browse more hands-on labs at Azure Workshopz.


Comments


bottom of page