DocumentationAzure private VM walkthrough
Docs/Azure private VM walkthrough

Azure gateway to a private VM

This walkthrough builds a dedicated Tunnex gateway in Azure and proves that a connected desktop device can reach Nginx on a VM with no public IP. It uses a separate gateway subnet and workload subnet so Azure can apply the return route only to workloads.

Read Plan your deployment first. This guide assumes the control plane is already healthy at a stable public FQDN such as https://vpn.example.com.

ComponentExample valueTraffic it must accept
Control planevpn.example.comHTTPS on TCP 443; raw gateway control on TCP 8443
Azure VNet10.42.0.0/16Must not overlap any Tunnex or connected-network range
Gateway subnet10.42.0.0/24Dedicated to the routing VM in this walkthrough
Workload subnet10.42.10.0/24Contains the private Nginx VM
Gateway private IP10.42.0.4Azure UDR next hop
Gateway public IPAzure Standard static IPv4WireGuard on UDP 51820
Tunnex device pool10.99.0.0/24Example installation default; verify the live value before routing it

The packet path is:

  1. The desktop client dials the gateway’s static public IP on UDP 51820.
  2. The gateway forwards the request from the device pool to the workload VM.
  3. A UDR on the workload subnet sends replies for the device pool to the gateway’s private IP.
  4. The gateway returns the reply through WireGuard.

The control plane is not in the application data path. TCP 8443 is a raw, mutually authenticated gateway-control service; it is not an HTTP route and must not be placed behind an ordinary layer-7 proxy.

Use Azure CLI in Bash. Select the intended subscription explicitly and keep the same shell open for the rest of the walkthrough.

Terminal window
az login
az account list --query '[].{name:name,id:id,isDefault:isDefault}' -o table
export AZURE_SUBSCRIPTION="replace-with-subscription-id-or-name"
az account set --subscription "$AZURE_SUBSCRIPTION"
export SUBSCRIPTION_ID="$(az account show --query id -o tsv)"
export LOCATION="eastus2"
az account show --query '{subscription:name,id:id,tenant:tenantId}' -o table
az account list-locations \
--query "[?name=='${LOCATION}'].{name:name,displayName:displayName}" -o table
az vm list-usage --location "$LOCATION" -o table
az network list-usages --location "$LOCATION" -o table
az vm list-skus --location "$LOCATION" --size Standard_B2s --all \
--query "[?name=='Standard_B2s'].{name:name,restrictions:restrictions}" -o jsonc

Confirm there is room for two Standard_B2s VMs and their vCPUs, two NICs, one Standard public IPv4 address, one VNet, and one route table. A SKU result with a non-empty location or zone restriction is not deployable as written; choose an available size or location before creating resources.

Also verify the public control-plane endpoints from the current shell. Step 5 repeats the raw-control check from the gateway itself:

Terminal window
export CP_HOST="vpn.example.com"
getent ahosts "$CP_HOST"
curl -fsS "https://${CP_HOST}/healthz"
nc -vz "$CP_HOST" 8443

The HTTPS check and the TCP 8443 check prove different paths. Do not replace the second check with curl; port 8443 is not an HTTP endpoint.

Set names and example ranges. Replace ADMIN_CIDR with the public IPv4 /32 from which you will SSH. Azure will reject the documentation placeholder if you forget to replace it.

Terminal window
export RG="tunnex-azure-walkthrough-rg"
export VNET="tunnex-workload-vnet"
export VNET_CIDR="10.42.0.0/16"
export GW_SUBNET="tunnex-gateway-subnet"
export GW_SUBNET_CIDR="10.42.0.0/24"
export WORKLOAD_SUBNET="private-workload-subnet"
export WORKLOAD_SUBNET_CIDR="10.42.10.0/24"
export DNS_SUBNET="private-dns-resolver-inbound"
export DNS_SUBNET_CIDR="10.42.20.0/28"
export GW_VM="tunnex-gateway-01"
export GW_NIC="tunnex-gateway-01-nic"
export GW_NSG="tunnex-gateway-01-nsg"
export GW_PIP="tunnex-gateway-01-pip"
export GW_PRIVATE_IP="10.42.0.4"
export APP_VM="dev-private-01"
export APP_NIC="dev-private-01-nic"
export APP_NSG="dev-private-01-nsg"
export DEVICE_POOL_CIDR="10.99.0.0/24"
export RETURN_RT="tunnex-device-return-rt"
export PRIVATE_ZONE="internal.example.com"
export DNS_RESOLVER="tunnex-private-dns-resolver"
export DNS_INBOUND="tunnex-private-dns-inbound"
export ADMIN_USER="azureuser"
export ADMIN_CIDR="replace-with-your-public-ipv4/32"

In the Tunnex console, open Routed ranges and Settings. Confirm the live organization device pool, approved site ranges, pending ranges, and Kubernetes ranges. Change DEVICE_POOL_CIDR to the live device-pool value if it is not 10.99.0.0/24.

Create a dedicated SSH key, or reuse it only when both halves already exist:

Terminal window
export SSH_KEY="$HOME/.ssh/tunnex-azure-walkthrough"
export SSH_PUBLIC_KEY="${SSH_KEY}.pub"
if test -e "$SSH_KEY" || test -e "$SSH_PUBLIC_KEY"; then
test -f "$SSH_KEY" && test -f "$SSH_PUBLIC_KEY" || {
echo 'Only one key file exists; choose a new SSH_KEY path.' >&2
return 1 2>/dev/null || exit 1
}
echo "Reusing $SSH_PUBLIC_KEY"
else
install -d -m 700 "$HOME/.ssh"
ssh-keygen -t ed25519 -a 64 -f "$SSH_KEY" -C "tunnex-azure-walkthrough"
fi
chmod 600 "$SSH_KEY"
chmod 644 "$SSH_PUBLIC_KEY"

Protect the private key with a passphrase. If either test fails, choose a new path or intentionally reuse the existing public key; do not overwrite it.

The resource group in this example contains only disposable walkthrough resources. Do not reuse the control plane’s resource group if you expect to use the one-command teardown later.

Terminal window
az group create --name "$RG" --location "$LOCATION" --output table
az network vnet create \
--resource-group "$RG" \
--location "$LOCATION" \
--name "$VNET" \
--address-prefixes "$VNET_CIDR" \
--subnet-name "$GW_SUBNET" \
--subnet-prefixes "$GW_SUBNET_CIDR" \
--output table
az network vnet subnet create \
--resource-group "$RG" \
--vnet-name "$VNET" \
--name "$WORKLOAD_SUBNET" \
--address-prefixes "$WORKLOAD_SUBNET_CIDR" \
--output table
az network vnet subnet list \
--resource-group "$RG" \
--vnet-name "$VNET" \
--query '[].{name:name,prefix:addressPrefix,routeTable:routeTable.id}' -o table
az network vnet check-ip-address \
--resource-group "$RG" \
--name "$VNET" \
--ip-address "$GW_PRIVATE_IP" \
--output jsonc

The address check must report available: true. Choose another fixed IP inside the gateway subnet if it is already allocated.

For an existing VNet, inventory all peerings, VPN/ExpressRoute routes, subnet NSGs, and route-table associations before adapting this section. Do not replace or detach an existing route table without merging its required routes.

Create a Standard static public IP, a gateway NSG, and a NIC with a fixed private IP. UDP 51820 is public because devices must be able to dial it. SSH is restricted to ADMIN_CIDR.

Terminal window
az network public-ip create \
--resource-group "$RG" \
--location "$LOCATION" \
--name "$GW_PIP" \
--version IPv4 \
--sku Standard \
--allocation-method Static \
--output table
az network nsg create \
--resource-group "$RG" \
--location "$LOCATION" \
--name "$GW_NSG" \
--output table
az network nsg rule create \
--resource-group "$RG" \
--nsg-name "$GW_NSG" \
--name AllowWireGuard51820 \
--priority 100 \
--direction Inbound \
--access Allow \
--protocol Udp \
--source-address-prefixes Internet \
--source-port-ranges '*' \
--destination-address-prefixes '*' \
--destination-port-ranges 51820 \
--output table
az network nsg rule create \
--resource-group "$RG" \
--nsg-name "$GW_NSG" \
--name AllowSshFromAdmin \
--priority 110 \
--direction Inbound \
--access Allow \
--protocol Tcp \
--source-address-prefixes "$ADMIN_CIDR" \
--source-port-ranges '*' \
--destination-address-prefixes '*' \
--destination-port-ranges 22 \
--output table
az network nic create \
--resource-group "$RG" \
--location "$LOCATION" \
--name "$GW_NIC" \
--vnet-name "$VNET" \
--subnet "$GW_SUBNET" \
--private-ip-address "$GW_PRIVATE_IP" \
--public-ip-address "$GW_PIP" \
--network-security-group "$GW_NSG" \
--ip-forwarding true \
--output table

The gateway initiates TCP 443 and 8443 connections to the control plane and pulls its image from GHCR. Azure’s default NSG outbound rule permits this. If your environment replaces that default with egress-deny rules, explicitly allow DNS, HTTPS/GHCR, NTP, and the control-plane FQDN/IP on TCP 8443. Do not create an inbound 8443 rule on the gateway; the control plane listens on that port.

Create the VM from the prebuilt NIC. It must run Linux with Docker, host networking, NET_ADMIN, /dev/net/tun, and persistent state.

Terminal window
az vm create \
--resource-group "$RG" \
--location "$LOCATION" \
--name "$GW_VM" \
--nics "$GW_NIC" \
--image Canonical:ubuntu-24_04-lts:server:latest \
--size Standard_B2s \
--admin-username "$ADMIN_USER" \
--ssh-key-values "$SSH_PUBLIC_KEY" \
--authentication-type ssh \
--os-disk-size-gb 30 \
--storage-sku Premium_LRS \
--output table
az vm run-command invoke \
--resource-group "$RG" \
--name "$GW_VM" \
--command-id RunShellScript \
--scripts \
'set -eu' \
'sudo apt-get update' \
'sudo apt-get install -y ca-certificates curl docker.io netcat-openbsd wireguard-tools' \
'sudo systemctl enable --now docker' \
'sudo modprobe tun' \
'test -c /dev/net/tun' \
"printf '%s\n' 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-tunnex-router.conf >/dev/null" \
'sudo sysctl --system' \
'test "$(sysctl -n net.ipv4.ip_forward)" = 1' \
'sudo docker version' \
--output jsonc
az network nic update \
--resource-group "$RG" \
--name "$GW_NIC" \
--ip-forwarding true \
--output table
az network nic show \
--resource-group "$RG" \
--name "$GW_NIC" \
--query '{privateIp:ipConfigurations[0].privateIPAddress,ipForwarding:enableIPForwarding}' \
-o table
export GW_PUBLIC_IP="$(az network public-ip show \
--resource-group "$RG" --name "$GW_PIP" --query ipAddress -o tsv)"
ssh -i "$SSH_KEY" "${ADMIN_USER}@${GW_PUBLIC_IP}" \
"getent ahosts '${CP_HOST}'; nc -vz '${CP_HOST}' 8443; test -c /dev/net/tun; sysctl net.ipv4.ip_forward"

Expected result: both Azure NIC forwarding and Linux net.ipv4.ip_forward are enabled, /dev/net/tun exists, Docker responds, and the gateway can establish TCP to the control plane on 8443.

If a host firewall is active, allow the chosen SSH source and UDP 51820 before enrollment. Keep the Azure NSG and host firewall aligned. Do not add blanket FORWARD or MASQUERADE rules: the Tunnex agent owns its forwarding and policy state.

Follow Gateways for the current form and health fields.

  1. Open Gateways and select Enroll gateway.
  2. Enter a stable name such as azure-eastus2-gateway.
  3. Enter the public endpoint as the value of GW_PUBLIC_IP followed by :51820.
  4. If the raw gateway-control endpoint differs from the public origin, open Customize and set it to the stable control-plane FQDN on port 8443.
  5. Select Generate join token, then Copy command.
  6. Open a temporary no-history shell on the gateway and paste that command exactly once.

Azure gateway enrollment fields before issuing the one-time command

Enter the stable public WireGuard endpoint here. Open Customize only when the raw control endpoint differs from the public control-plane origin.

Terminal window
ssh -t -i "$SSH_KEY" "${ADMIN_USER}@${GW_PUBLIC_IP}" \
'HISTFILE=/dev/null exec bash --noprofile --norc -i'
# Paste the one-time command copied directly from the Tunnex console here.

The release-generated command supplies the selected image, URLs, server name, host networking, NET_ADMIN, /dev/net/tun, and a persistent Docker volume. Do not translate it into a hand-written command. After redemption, close the one-time command dialog and verify:

Terminal window
sudo docker ps --filter name=tunnex-node
sudo docker logs --tail=200 tunnex-node
sudo wg show

Container names can differ; use the name shown by docker ps. In the console, the gateway must be active, Last seen must advance, and its runtime/version must be present. That proves control connectivity, not public UDP reachability; a real client handshake will prove the data plane later.

This proof VM has no public IP. Its NSG explicitly allows HTTP from the Tunnex device pool. Adapt the NSG to your real application ports and policy.

Terminal window
az network nsg create \
--resource-group "$RG" \
--location "$LOCATION" \
--name "$APP_NSG" \
--output table
az network nsg rule create \
--resource-group "$RG" \
--nsg-name "$APP_NSG" \
--name AllowHttpFromTunnexDevices \
--priority 100 \
--direction Inbound \
--access Allow \
--protocol Tcp \
--source-address-prefixes "$DEVICE_POOL_CIDR" \
--source-port-ranges '*' \
--destination-address-prefixes '*' \
--destination-port-ranges 80 \
--output table
az network nic create \
--resource-group "$RG" \
--location "$LOCATION" \
--name "$APP_NIC" \
--vnet-name "$VNET" \
--subnet "$WORKLOAD_SUBNET" \
--network-security-group "$APP_NSG" \
--output table
az vm create \
--resource-group "$RG" \
--location "$LOCATION" \
--name "$APP_VM" \
--nics "$APP_NIC" \
--image Canonical:ubuntu-24_04-lts:server:latest \
--size Standard_B2s \
--admin-username "$ADMIN_USER" \
--ssh-key-values "$SSH_PUBLIC_KEY" \
--authentication-type ssh \
--os-disk-size-gb 30 \
--storage-sku Premium_LRS \
--output table
az vm run-command invoke \
--resource-group "$RG" \
--name "$APP_VM" \
--command-id RunShellScript \
--scripts \
'set -eu' \
'sudo apt-get update' \
'sudo apt-get install -y nginx' \
"printf '%s\n' 'tunnex azure private path ok' | sudo tee /var/www/html/index.html >/dev/null" \
'sudo systemctl enable --now nginx' \
'curl -fsS http://127.0.0.1/' \
--output jsonc
export APP_PRIVATE_IP="$(az network nic show \
--resource-group "$RG" --name "$APP_NIC" \
--query 'ipConfigurations[0].privateIPAddress' -o tsv)"
az vm list-ip-addresses \
--resource-group "$RG" \
--name "$APP_VM" \
--query '[].virtualMachine.network.{private:privateIpAddresses,public:publicIpAddresses}' \
-o jsonc

Expected result: Nginx returns tunnex azure private path ok locally and publicIpAddresses is empty. Azure’s default AllowVNetInBound NSG rule still permits normal VNet-internal administration unless you add a higher-priority deny after all required allows; review the effective NSG instead of treating this single rule as complete workload hardening.

Follow Sites and LAN routing:

  1. Open Sites and select Route a LAN.
  2. Choose the new Azure gateway.
  3. Enter 10.42.10.0/24, the workload subnet—not the full VNet and not the gateway subnet.
  4. Name the site Azure eastus2 and select Route LAN.
  5. If using the explicit workflow, create the site, bind the gateway, advertise 10.42.10.0/24, then approve the pending range.
  6. Confirm the approved range appears under Routed ranges and wait for the gateway to apply the new revision.

Route a LAN form with gateway, CIDR, and optional Site name

Pending advertised subnet awaiting explicit approval

If policy enforcement is enabled, create the narrow resource and allow rule before testing—for example, the private VM /32, TCP, port 80, assigned only to the intended test group. Routing makes the destination reachable; it does not grant access through a default-deny policy.

Tunnex configures the gateway host, not Azure’s route tables. Without this UDR, the private VM sends a reply for a device such as 10.99.0.7 to Azure’s default route instead of back to the gateway.

Create a route for the exact live device pool, point it to the gateway’s private IP as a virtual appliance, and associate it only with the workload subnet:

Terminal window
az network route-table create \
--resource-group "$RG" \
--location "$LOCATION" \
--name "$RETURN_RT" \
--output table
az network route-table route create \
--resource-group "$RG" \
--route-table-name "$RETURN_RT" \
--name ReturnToTunnexDevices \
--address-prefix "$DEVICE_POOL_CIDR" \
--next-hop-type VirtualAppliance \
--next-hop-ip-address "$GW_PRIVATE_IP" \
--output table
az network vnet subnet update \
--resource-group "$RG" \
--vnet-name "$VNET" \
--name "$WORKLOAD_SUBNET" \
--route-table "$RETURN_RT" \
--output table

Do not point this route at the gateway public IP. A VirtualAppliance next hop must be the routing NIC’s private IP.

The private VM must be running for Azure to return its effective routes.

Terminal window
az network vnet subnet show \
--resource-group "$RG" \
--vnet-name "$VNET" \
--name "$WORKLOAD_SUBNET" \
--query '{prefix:addressPrefix,routeTable:routeTable.id}' -o jsonc
az network route-table route list \
--resource-group "$RG" \
--route-table-name "$RETURN_RT" \
--query '[].{name:name,prefix:addressPrefix,nextHop:nextHopType,nextHopIp:nextHopIpAddress}' \
-o table
az network nic show-effective-route-table \
--resource-group "$RG" \
--name "$APP_NIC" \
--output table
az network nic list-effective-nsg \
--resource-group "$RG" \
--name "$APP_NIC" \
--output jsonc

The effective-route output must contain the device-pool prefix with next-hop type VirtualAppliance and next-hop IP 10.42.0.4. If the row is absent, wait for Azure convergence, confirm the association, and check that the VM is running.

For multiple workload subnets, associate a reviewed return route table with each subnet that must reply to Tunnex devices. If a subnet already has a route table, add the device-pool route to that table instead of replacing it.

Enroll and connect a managed macOS or Windows device using the Desktop client. Wait for Connected and a recent handshake. Copy the APP_PRIVATE_IP reported by Azure, then run from a macOS terminal on that desktop:

Terminal window
export APP_PRIVATE_IP="10.42.10.4" # Replace if Azure reported another address.
curl --fail --show-error --max-time 10 "http://${APP_PRIVATE_IP}/"

Or from Windows PowerShell:

Terminal window
$AppPrivateIp = '10.42.10.4' # Replace if Azure reported another address.
curl.exe --fail --show-error --max-time 10 "http://$AppPrivateIp/"

Expected response:

tunnex azure private path ok

Do not use ping as the only proof. ICMP can be blocked while the application is healthy, and a WireGuard handshake alone does not prove the Azure return path. Verify all four layers:

  1. The desktop reports Connected and a recent handshake.
  2. The gateway’s sudo wg show reports a recent peer handshake and increasing transfer counters.
  3. curl returns the Nginx marker over the VM’s private IP.
  4. Nginx records the request:

Connected desktop client with tunnel address, handshake age, and traffic counters

Terminal window
az vm run-command invoke \
--resource-group "$RG" \
--name "$APP_VM" \
--command-id RunShellScript \
--scripts 'sudo tail -n 20 /var/log/nginx/access.log' \
--output jsonc

If the handshake works but curl times out, inspect in this order: Tunnex site approval and applied revision, access policy, gateway host forwarding, Azure NIC IP forwarding, workload NSG, host firewall, UDR association, and the private VM’s effective routes.

Keep the private-IP proof as the baseline. This section adds an Azure Private DNS zone and an Azure DNS Private Resolver inbound endpoint. The endpoint is what a connected Tunnex device queries through the gateway; Azure’s VNet-local resolver address is not directly usable by a remote device.

Azure DNS Private Resolver is a paid Azure resource. Its endpoint needs a dedicated, empty subnet delegated only to Microsoft.Network/dnsResolvers. Azure permits /28 through /24; this walkthrough uses /28. Review the current Azure DNS Private Resolver constraints and pricing before creating it.

Use a private subdomain you control, such as internal.example.com. Do not create a public A record containing the private VM address, and do not delegate this private zone from Cloudflare or another public DNS provider.

Terminal window
export VNET_ID="$(az network vnet show \
--resource-group "$RG" --name "$VNET" --query id -o tsv)"
az network private-dns zone create \
--resource-group "$RG" \
--name "$PRIVATE_ZONE" \
--output table
az network private-dns link vnet create \
--resource-group "$RG" \
--zone-name "$PRIVATE_ZONE" \
--name "link-${VNET}" \
--virtual-network "$VNET_ID" \
--registration-enabled false \
--output table
az network private-dns record-set a create \
--resource-group "$RG" \
--zone-name "$PRIVATE_ZONE" \
--name dev \
--ttl 60 \
--output table
az network private-dns record-set a add-record \
--resource-group "$RG" \
--zone-name "$PRIVATE_ZONE" \
--record-set-name dev \
--ipv4-address "$APP_PRIVATE_IP" \
--output table

The hostname in this example is dev.internal.example.com. Registration is disabled deliberately: only records explicitly created in the zone are published.

The resolver subnet also needs the device-pool return UDR because query replies for a client such as 10.99.0.7 must return through the gateway. Do not attach this route table to the dedicated gateway subnet.

Terminal window
az network vnet subnet create \
--resource-group "$RG" \
--vnet-name "$VNET" \
--name "$DNS_SUBNET" \
--address-prefixes "$DNS_SUBNET_CIDR" \
--delegations Microsoft.Network/dnsResolvers \
--route-table "$RETURN_RT" \
--output table
export DNS_SUBNET_ID="$(az network vnet subnet show \
--resource-group "$RG" --vnet-name "$VNET" --name "$DNS_SUBNET" \
--query id -o tsv)"
az extension add --name dns-resolver --upgrade
az dns-resolver create \
--resource-group "$RG" \
--name "$DNS_RESOLVER" \
--location "$LOCATION" \
--id "$VNET_ID" \
--output table
az dns-resolver inbound-endpoint create \
--resource-group "$RG" \
--dns-resolver-name "$DNS_RESOLVER" \
--name "$DNS_INBOUND" \
--location "$LOCATION" \
--ip-configurations \
"[{private-ip-allocation-method:Dynamic,id:${DNS_SUBNET_ID}}]" \
--output table
export DNS_INBOUND_IP="$(az dns-resolver inbound-endpoint show \
--resource-group "$RG" \
--dns-resolver-name "$DNS_RESOLVER" \
--name "$DNS_INBOUND" \
--query 'ipConfigurations[0].privateIpAddress' -o tsv)"
test -n "$DNS_INBOUND_IP"
printf 'Inbound DNS: %s\n' "$DNS_INBOUND_IP"

The dns-resolver command is an Azure CLI extension and currently requires Azure CLI 2.75 or newer. If your managed shell cannot install the extension, use Azure Portal or the documented ARM API instead of inventing a different DNS path.

Install dnsutils on the gateway if dig is absent, then test both transports and the application from the gateway:

Terminal window
dig @"$DNS_INBOUND_IP" dev."$PRIVATE_ZONE" A
dig +tcp @"$DNS_INBOUND_IP" dev."$PRIVATE_ZONE" A
curl --fail --show-error --max-time 10 "http://dev.${PRIVATE_ZONE}/"

Both DNS commands must return APP_PRIVATE_IP, and curl must return the same Nginx marker as the private-IP test. Stop here if either DNS transport fails.

Section titled “Advertise DNS and activate the Tunnex profile”
  1. Open Sites → your Azure Site → Advertise subnet.
  2. Enter the exact DNS_SUBNET_CIDR, select Advertise, open Pending approvals, and approve it.
  3. Open Access Policies → Resources → FQDN.
  4. Under Private DNS resolvers, select the Azure Site and gateway, then choose Edit profiles.
  5. Enter a profile name, choose Microsoft Azure, enter PRIVATE_ZONE as the suffix, and add DNS_INBOUND_IP twice: UDP 53 and TCP 53.
  6. Select Activate profiles.
  7. Select Create resource → Create FQDN resource. Enter the exact hostname dev.internal.example.com, choose TCP port 80, and confirm the displayed resolver inheritance before creating it.

Private resolver profile editor with the verified Azure inbound endpoint

Exact-hostname FQDN resource form with resolver inheritance

The inbound endpoint IP is stable for the lifetime of that endpoint but can change if the endpoint is deleted and recreated. Re-read it from Azure and activate a new Tunnex resolver-profile version after replacement.

Finish the policy and organization setting through FQDN access, end to end. Then prove the route, private resolver, client resolver projection, and application separately from the connected desktop. On macOS, both private destinations must use a Tunnex utun interface:

Terminal window
route -n get "$APP_PRIVATE_IP"
route -n get "$DNS_INBOUND_IP"
dig +short @"$DNS_INBOUND_IP" dev."$PRIVATE_ZONE" A
dig +tcp +short @"$DNS_INBOUND_IP" dev."$PRIVATE_ZONE" A
dscacheutil -q host -a name dev."$PRIVATE_ZONE"
curl --fail --show-error --max-time 10 http://dev.internal.example.com/

The first two DNS queries bypass operating-system resolver selection and prove the Azure endpoint over UDP and TCP. dscacheutil and curl use macOS native resolver selection and prove that Tunnex projected the suffix. Do not collapse those into one test: a successful direct query does not prove client-side DNS configuration.

The post-fix isolated Azure validation resolved dev.internal.tunnex.io to 172.16.0.4, routed that address over utun, and returned HTTP 200 by hostname. Tunnex Desktop before v0.1.1 can create the macOS resolver file with mode 0600; upgrade and reconnect so the helper installs it as readable mode 0644 instead of keeping a manual permission workaround.

DNS success does not grant traffic when policy enforcement is enabled. Create the narrow access rule for the FQDN resource separately.

Capture only after every view has finished loading, and redact subscription, tenant, usernames, and public IPs when publishing outside the operations team:

  • Azure VNet view showing the distinct gateway and workload subnet prefixes;
  • Azure gateway NIC showing static private IP and IP forwarding enabled;
  • Tunnex gateway details showing active lifecycle, last seen, and runtime;
  • Tunnex site/routed-range view showing the approved workload subnet;
  • Azure workload NIC effective routes showing the device-pool UDR;
  • desktop Connected state with recent handshake; and
  • terminal output showing the private-IP Nginx marker.

Never capture the generated enrollment command, join token, WireGuard private key, complete peer configuration, or unredacted diagnostic bundle.

If adding the UDR breaks workload connectivity, detach it first. This restores Azure’s system routes without deleting the VMs:

Terminal window
az network vnet subnet update \
--resource-group "$RG" \
--vnet-name "$VNET" \
--name "$WORKLOAD_SUBNET" \
--route-table null \
--output table
az network vnet subnet show \
--resource-group "$RG" \
--vnet-name "$VNET" \
--name "$WORKLOAD_SUBNET" \
--query routeTable -o jsonc

Expected result is null. If the subnet originally used another route table, reattach that original table instead of null.

For full teardown:

  1. Disconnect the test device.
  2. In Tunnex, remove the approved route/site binding, wait for reconciliation, move any homed devices, then revoke and remove the gateway.
  3. Confirm the resource group contains only the disposable resources from this guide.
  4. Delete the resource group:
Terminal window
az resource list --resource-group "$RG" --output table
az group delete --name "$RG" --yes --no-wait

Resource-group deletion is irreversible and also deletes the VNet, both VMs, disks, NICs, public IP, NSGs, and route table in this example. Never run it if the group contains the control plane, shared networking, or another workload.

Documentation

Search Tunnex docs

Screenshot preview