Skip to content

How-to: Set up private GPT-RAG access from your local Windows machine

Why use a VPN?

A network-isolated GPT-RAG deployment exposes its services through private endpoints. Your local machine needs a private network connection to configure those services and test the application.

The deployment guide normally uses an administration VM, also called a jumpbox, for this work. If you cannot sign in to that VM, or your organization requires you to work from a managed local machine, a Point-to-Site (P2S) VPN provides a connection from that machine to the Azure virtual network.

This guide explains how to set up that connection, configure private DNS, and prepare the latest stable GPT-RAG release to use the same network. You continue to sign in with your own Azure account. The VPN does not bypass Conditional Access, grant Azure permissions, or give the agent access to documents.

Scope. This guide covers private connectivity and deployment preparation. Check the deployment-host requirement before creating resources: VPN connectivity alone does not remove restrictions imposed by the deployment scripts.

Release requirement

GPT-RAG v3.8.5 adds the private-host checks used in steps 7-8. Select the latest stable release containing those checks as a complete package; do not replace release pins with a development branch or personal commit. The older v3.8.4 pre-deploy hook requires RUN_FROM_JUMPBOX=true. Do not set that flag on a local machine to work around the older gate.

1. Before you start

This procedure assumes Windows 11, Azure public cloud, and a dedicated VNet for the installation. If your organization already provides private connectivity and DNS, use that approved setup instead of creating duplicate infrastructure. Shared VNets and hub-and-spoke integration need a separate network review.

Where each resource goes

To keep this example simple, use one new, dedicated Azure resource group for both the VPN network and GPT-RAG. You choose its name; this guide uses rg-gpt-rag-private. A resource group is an Azure container for related resources in your subscription. It is not an existing corporate network group, a folder on your local machine, or the name of your azd environment.

Create the network first, in steps 4 and 5. In step 6, tell azd to provision GPT-RAG into that same group with azd env set AZURE_RESOURCE_GROUP $resourceGroup. Do not create a second group for GPT-RAG or move the VPN resources between steps.

Resource or setting Where it goes in this example When it is created or selected
Resource group, $resourceGroup Your chosen subscription; example rg-gpt-rag-private Step 4, once
VNet and its subnets Inside $resourceGroup VPN/DNS subnets in steps 4-5; workload subnets in step 7
VPN Gateway and its public IP Inside the same $resourceGroup; gateway uses GatewaySubnet in the VNet Step 4
DNS Private Resolver and inbound endpoint Inside the same $resourceGroup; inbound endpoint uses the dedicated DNS subnet Step 5
GPT-RAG services and private endpoints Inside the same $resourceGroup, using the existing VNet Step 7 and the hosted deployment phases in step 8
$environment, for example gpt-rag-vpn-dev Local azd configuration under .azure\<environment-name>\ in your checkout Step 2; this is not an Azure resource group

Using one group is a simplification, not an enterprise requirement. Separately owned network groups, shared VNets, and hub-and-spoke networking are outside this walkthrough. Use a platform-reviewed design for those scenarios.

Prepare:

  • A managed local Windows machine that meets your organization's access policies.
  • Azure VPN Client, Azure CLI, Azure Developer CLI (azd), Git, PowerShell 7.4+, and the Python version required by the GPT-RAG release.
  • An approved subscription, tenant, region, and sufficient service/model quotas.
  • Deployment permissions and the service data-plane permissions required by the deployment guide. Creating runtime role assignments also requires an authorized deployment identity; a VPN connection does not provide that permission.
  • Approval for VPN Gateway, DNS Private Resolver, and GPT-RAG resource costs. These resources can continue incurring charges after you disconnect.

The local machine and the Azure build environment both need their respective approved outbound access to dependencies. Do not bypass corporate package, proxy, or VPN policies.

Run the examples one section at a time

Use the same PowerShell 7.4+ session so variables remain available. Replace placeholders and review each block before running it. Commands marked Write create or change resources; Read commands inspect them. Do not paste the whole guide into a terminal or repeat a creation command after a timeout without checking the resource state.

Start with:

if ($PSVersionTable.PSVersion -lt [version]'7.4') {
    throw 'Use PowerShell 7.4 or later for this guide.'
}
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true

These process-local settings stop a block when a PowerShell command fails or az, azd, or git returns a nonzero exit code. They do not change persistent machine settings. If you reopen the terminal, restore these settings and the variables before continuing.

2. Get the latest stable GPT-RAG release

Open the latest stable release and copy its tag. Use that release as a complete package; do not select component versions individually or replace its manifest entries with development branches.

In a new working directory:

$release = '<latest-stable-release-tag>'
git clone --branch $release --recurse-submodules https://github.com/Azure/GPT-RAG.git gpt-rag
if ($LASTEXITCODE -ne 0) { throw 'Could not download the GPT-RAG release.' }
Set-Location .\gpt-rag
$repoRoot = (Get-Location).Path

Find your subscription, tenant, and region

Open the Azure Portal with your own account. In Subscriptions, select the approved subscription and copy its Subscription ID. In Microsoft Entra ID > Overview, copy the Tenant ID for the directory that owns that subscription. If you belong to multiple directories, check Directories + subscriptions before copying. See Microsoft's subscription and tenant ID instructions.

Choose the region with your administrator before creating anything. The Portal's resource creation Region selector shows display names, such as East US 2; the command variable uses the corresponding Azure location name, such as eastus2. This example is not a capacity or quota guarantee.

Variable What to enter Illustrative value, not your account
$tenant Directory's Tenant ID from the Portal 00000000-0000-0000-0000-000000000000
$subscription Approved Subscription ID from the Portal 11111111-1111-1111-1111-111111111111
$region Approved Azure location name eastus2
$environment A new local azd environment name you choose gpt-rag-vpn-dev

For example, a populated variable block has the following form. The two GUIDs are fictitious: replace them with your Portal values before running it. Also replace eastus2 if your approved region differs.

$tenant = '00000000-0000-0000-0000-000000000000'
$subscription = '11111111-1111-1111-1111-111111111111'
$region = 'eastus2'
$environment = 'gpt-rag-vpn-dev'

Use your actual values in the block below, then sign in and create the local environment. Run each command separately and stop if it fails.

$tenant = '<tenant-id>'
$subscription = '<subscription-id>'
$region = '<approved-region>'
$environment = '<new-environment-name>'

az login --tenant $tenant
az account set --subscription $subscription
az account show --query '{Subscription:name,Tenant:tenantId}' --output table
azd auth login --tenant-id $tenant
azd env new $environment

Azure CLI and azd have separate authentication contexts. Do not use the VM managed-identity login commands on your local machine.

The account table must show the intended subscription and tenant. azd env new selects $environment and creates local configuration under .azure\$environment; it does not create the resource group or the VPN. Keep that directory out of source control. If resuming the same installation, select its existing local environment instead of creating a new one: azd env select $environment.

Release boundary: verify the selected stable release includes the private-host checks introduced in v3.8.5. Do not use the steps below as a local deployment path for v3.8.4 or mix newer hook files into that release.

3. Plan non-overlapping network ranges

Check your local routes, including Wi-Fi, virtual adapters, and other VPNs:

Get-NetRoute -AddressFamily IPv4 |
    Select-Object DestinationPrefix, NextHop, InterfaceAlias |
    Sort-Object DestinationPrefix -Unique

Review the address plan with your network team as well. The local route table does not show every corporate or disconnected network.

The following ranges are examples, not required values. Use them only if they are available and approved:

Purpose Example range
GPT-RAG VNet 10.80.0.0/20
VPN gateway subnet, named exactly GatewaySubnet 10.80.15.0/27
DNS inbound subnet, for example snet-dns-inbound 10.80.15.32/28
P2S client address pool 10.81.0.0/24

The client pool is separate from the VNet: it is not a VNet subnet and must not overlap the VNet or other connected networks. Reserve enough space for the GPT-RAG workload subnets listed in step 6.

Set the names and approved ranges once. Names below are examples for a new, dedicated resource group shared by the VPN/DNS network and GPT-RAG. Keep $resourceGroup unchanged throughout this walkthrough; $environment remains the separate local configuration name from step 2:

$resourceGroup = 'rg-gpt-rag-private'
$vnetName = 'vnet-gpt-rag-private'
$publicIpName = 'pip-gpt-rag-vpn'
$gatewayName = 'vpngw-gpt-rag'
$resolverName = 'dnspr-gpt-rag'
$dnsSubnetName = 'snet-dns-inbound'
$inboundName = 'dns-inbound'

$vnetPrefix = '10.80.0.0/20'
$gatewaySubnetPrefix = '10.80.15.0/27'
$dnsSubnetPrefix = '10.80.15.32/28'
$dnsInboundIp = '10.80.15.36'
$p2sPool = '10.81.0.0/24'

If you choose different ranges, update both these variables and the workload subnet parameters in step 6. Keep the VNet, VPN gateway, and resolver in the same approved region for this example.

4. Create the VNet and P2S VPN

These steps create billable Azure resources. Obtain the required approval first. If completing the installation entirely from your local machine is essential, read step 8 before creating the gateway.

Create the resource group and network

First run only; Read, then Write if missing: this block checks the $resourceGroup name from step 3 in $subscription from step 2. It creates that group in $region only when absent, then requires it to be empty before starting a fresh network. An existing empty group reserved for this installation is also acceptable; an existing corporate or unrelated group is not.

$groupExists = az group exists --subscription $subscription --name $resourceGroup --output tsv
if ($groupExists -eq 'false') {
    az group create --subscription $subscription --name $resourceGroup --location $region --output table
} elseif ($groupExists -ne 'true') {
    throw 'Could not determine whether the resource group exists.'
}

$existingResources = @(az resource list --subscription $subscription --resource-group $resourceGroup --output json | ConvertFrom-Json)
if ($existingResources.Count -gt 0) {
    $existingResources | Select-Object name, type
    throw 'The resource group is not empty. Review it instead of rerunning fresh resource creation.'
}

For a new group, expect the creation command to return its name and location, then an empty resource list. Once the VPN or DNS resources exist, the group is expected to be nonempty. The check above is a first-run safeguard, not a recurring prerequisite. Do not delete resources to make it pass, and do not choose a new group merely because you are resuming.

Resume an interrupted setup safely

Restore the same variables and select the same local azd environment from step 2. Read only: inventory the existing group before retrying a step:

az resource list --subscription $subscription --resource-group $resourceGroup `
    --query '[].{name:name,type:type,location:location}' --output table

Identify the VNet, gateway, public IP, and DNS resources by the names you chose. Use the corresponding Read commands in steps 4 and 5 to verify their settings and provisioning state. A resource still provisioning after a timeout may only need its existing wait/status check, not another create. Resume from the failed or incomplete step once its prerequisites are verified. Do not blindly rerun all creation blocks, overwrite a differing resource, delete the group, or restart in a second group. Stop for owner review when the existing names, settings, or ownership do not match your intended installation.

Create the VNet

Write: create the VNet with its reserved GatewaySubnet:

az network vnet create --subscription $subscription `
    --resource-group $resourceGroup --name $vnetName --location $region `
    --address-prefixes $vnetPrefix `
    --subnet-name GatewaySubnet --subnet-prefixes $gatewaySubnetPrefix `
    --output none

$vnetId = az network vnet show --subscription $subscription `
    --resource-group $resourceGroup --name $vnetName --query id --output tsv
if (-not $vnetId) { throw 'The VNet resource ID was not returned.' }

Read: confirm the range, Succeeded state, and no NSG or route table on GatewaySubnet:

az network vnet show --subscription $subscription --resource-group $resourceGroup `
    --name $vnetName --query '{name:name,state:provisioningState,ranges:addressSpace.addressPrefixes}' --output json
az network vnet subnet show --subscription $subscription --resource-group $resourceGroup `
    --vnet-name $vnetName --name GatewaySubnet `
    --query '{prefix:addressPrefix,nsg:networkSecurityGroup.id,routeTable:routeTable.id}' --output json

Create the VPN gateway

Write: the example uses an availability-zone-capable region, a zone-redundant Standard public IP, and VpnGw1AZ. Confirm SKU/zone availability and costs for your region before running it; do not substitute Basic for Entra P2S.

az network public-ip create --subscription $subscription `
    --resource-group $resourceGroup --name $publicIpName --location $region `
    --sku Standard --allocation-method Static --version IPv4 --zone 1 2 3 `
    --output none

az network vnet-gateway create --subscription $subscription `
    --resource-group $resourceGroup --name $gatewayName --location $region `
    --vnet $vnetId --public-ip-addresses $publicIpName `
    --gateway-type Vpn --vpn-type RouteBased --sku VpnGw1AZ --no-wait

Read: wait up to one hour, then inspect the result. Gateway creation can take tens of minutes. A timeout does not mean the creation was cancelled; inspect the existing gateway rather than creating another one.

az network vnet-gateway wait --subscription $subscription `
    --resource-group $resourceGroup --name $gatewayName `
    --created --interval 30 --timeout 3600
az network vnet-gateway show --subscription $subscription `
    --resource-group $resourceGroup --name $gatewayName `
    --query '{name:name,state:provisioningState,type:gatewayType,vpnType:vpnType,sku:sku.name}' --output json

Continue only when the state is Succeeded.

Enable Entra-authenticated P2S

For the Microsoft-registered Azure VPN Client application in Azure public cloud:

Field Value
Tenant https://login.microsoftonline.com/<tenant-id>
Audience c632b3df-fb67-4d84-bdcf-b95ad541b5c8
Issuer https://sts.windows.net/<tenant-id>/

The Audience value is a public Microsoft application identifier, not a secret. If your organization uses a custom audience or restricts VPN access to selected users/groups, follow that approved configuration. Do not assume that every tenant user should be allowed to connect.

Write: configure P2S on the gateway you just created:

az network vnet-gateway update --subscription $subscription `
    --resource-group $resourceGroup --name $gatewayName `
    --address-prefixes $p2sPool --client-protocol OpenVPN --vpn-auth-type AAD `
    --aad-tenant "https://login.microsoftonline.com/$tenant" `
    --aad-audience 'c632b3df-fb67-4d84-bdcf-b95ad541b5c8' `
    --aad-issuer "https://sts.windows.net/$tenant/" --no-wait

az network vnet-gateway wait --subscription $subscription `
    --resource-group $resourceGroup --name $gatewayName `
    --updated --interval 30 --timeout 1800

Read: check Succeeded, the intended client pool, OpenVPN, AAD, and your tenant/audience/issuer:

az network vnet-gateway show --subscription $subscription `
    --resource-group $resourceGroup --name $gatewayName `
    --query '{state:provisioningState,pool:vpnClientConfiguration.vpnClientAddressPool.addressPrefixes,protocols:vpnClientConfiguration.vpnClientProtocols,auth:vpnClientConfiguration.vpnAuthenticationTypes,tenant:vpnClientConfiguration.aadTenant,audience:vpnClientConfiguration.aadAudience,issuer:vpnClientConfiguration.aadIssuer}' `
    --output json

The gateway's public IP accepts VPN connections. It does not require exposing Storage, Search, Foundry, or the other GPT-RAG services publicly.

See Configure P2S with Microsoft Entra ID for the complete gateway and authentication instructions.

5. Configure private DNS and connect

Create the DNS inbound subnet and resolver

The DNS resolver commands require the dns-resolver Azure CLI extension. Read: check whether it is installed:

az extension list --query "[?name=='dns-resolver'].{name:name,version:version}" --output table

If it is missing, and installation is approved on your machine, install it:

az extension add --name dns-resolver

Write: create a dedicated delegated subnet. Do not place other resources in it or attach the workload route table:

az network vnet subnet create --subscription $subscription `
    --resource-group $resourceGroup --vnet-name $vnetName --name $dnsSubnetName `
    --address-prefixes $dnsSubnetPrefix --delegations Microsoft.Network/dnsResolvers `
    --output none

$dnsSubnetId = az network vnet subnet show --subscription $subscription `
    --resource-group $resourceGroup --vnet-name $vnetName --name $dnsSubnetName `
    --query id --output tsv
if (-not $dnsSubnetId) { throw 'The DNS subnet resource ID was not returned.' }

az dns-resolver create --subscription $subscription `
    --resource-group $resourceGroup --name $resolverName --location $region `
    --id $vnetId --if-none-match '*' --output none

az dns-resolver inbound-endpoint create --subscription $subscription `
    --resource-group $resourceGroup --dns-resolver-name $resolverName `
    --name $inboundName --location $region --if-none-match '*' `
    --ip-configurations "[{private-ip-address:$dnsInboundIp,private-ip-allocation-method:Static,id:$dnsSubnetId}]" `
    --output none

The CLI shorthand above uses a subnet id and a static inbound IP. The example 10.80.15.36 is a usable address in 10.80.15.32/28; choose an available, non-reserved address if you change that subnet.

Read: verify both resources show Succeeded and the returned IP matches the one you selected:

az dns-resolver show --subscription $subscription --resource-group $resourceGroup `
    --name $resolverName --query '{state:provisioningState,vnet:virtualNetwork.id}' --output json
az dns-resolver inbound-endpoint show --subscription $subscription `
    --resource-group $resourceGroup --dns-resolver-name $resolverName `
    --name $inboundName --query '{state:provisioningState,ip:ipConfigurations[0].privateIpAddress,subnet:ipConfigurations[0].subnet.id}' --output json

For this simple client-to-Azure DNS flow, you do not need an outbound endpoint or forwarding ruleset. An existing corporate DNS design may differ.

Download and edit the VPN profile

Use the gateway's Point-to-site configuration > Download VPN client in Azure Portal to obtain the Entra/OpenVPN profile. This is one of the remaining interactive steps; do not select a certificate/RADIUS authentication option just to generate a different profile with the CLI.

Local files only: extract the downloaded ZIP into a new directory:

$vpnZip = '<full-path-to-downloaded-vpn-profile.zip>'
$vpnProfileFolder = Join-Path $HOME "Downloads\gpt-rag-vpn-$([guid]::NewGuid().ToString('N'))"
Expand-Archive -LiteralPath $vpnZip -DestinationPath $vpnProfileFolder
Get-ChildItem -LiteralPath $vpnProfileFolder -Recurse -Filter '*.xml' |
    Select-Object FullName

Select the Entra profile: normally AzureVPN\azurevpnconfig.xml, or azurevpnconfig_aad.xml if the package includes multiple authentication types. Copy the actual path from the listing:

$profilePath = '<full-path-to-the-entra-profile.xml>'
if (Test-Path -LiteralPath "$profilePath.original") {
    throw 'A profile backup already exists. Preserve it and review before editing again.'
}
Copy-Item -LiteralPath $profilePath -Destination "$profilePath.original"
notepad.exe $profilePath

The backup deliberately does not overwrite an existing .original file. Keep this profile and its backup outside the repository.

The following is a profile fragment, not a complete VPN profile. Replace the placeholder IP, preserve the authentication settings, and do not add a second clientconfig element. Remove i:nil="true" from that element when populating it.

<clientconfig>
  <dnsservers>
    <dnsserver>YOUR-DNS-INBOUND-PRIVATE-IP</dnsserver>
  </dnsservers>
  <dnssuffixes>
    <dnssuffix>.services.ai.azure.com</dnssuffix>
    <dnssuffix>.openai.azure.com</dnssuffix>
    <dnssuffix>.cognitiveservices.azure.com</dnssuffix>
    <dnssuffix>.azconfig.io</dnssuffix>
    <dnssuffix>.vault.azure.net</dnssuffix>
    <dnssuffix>.search.windows.net</dnssuffix>
    <dnssuffix>.blob.core.windows.net</dnssuffix>
    <dnssuffix>.azurecr.io</dnssuffix>
    <dnssuffix>.azurecontainerapps.io</dnssuffix>
  </dnssuffixes>
</clientconfig>

Treat these namespaces as a starting list. Add those actually used by your topology, such as Cosmos DB, Azure Monitor, or a custom application domain. Review the DNS impact on other corporate resources using those namespaces; do not add a broad suffix such as .azure.com.

With this guide's dedicated-VNet defaults, GPT-RAG provisions its private DNS zones and links them to the VNet in step 7. Do not pre-create duplicate service zones or links. If your platform team or Azure Policy manages DNS, use the existing-platform integration settings with that team instead of mixing the two ownership models.

The VPN client profile does not create DNS records. After provisioning, check the records needed for ACR data endpoints and the application, not just one hostname per service.

After saving the XML, validate the file locally before importing:

$profileXml = [xml](Get-Content -LiteralPath $profilePath -Raw)
$clientConfig = @($profileXml.SelectNodes("/*/*[local-name()='clientconfig']"))
if ($clientConfig.Count -ne 1) { throw 'Expected exactly one clientconfig element.' }
$profileDns = @($clientConfig[0].SelectNodes("*[local-name()='dnsservers']/*[local-name()='dnsserver']"))
if ($profileDns.Count -ne 1 -or $profileDns[0].InnerText -ne $dnsInboundIp) {
    throw 'The VPN profile must contain the selected DNS inbound IP.'
}

In Azure VPN Client, select + > Import, import the edited profile, and connect with your Entra account. After any subsequent profile change, disconnect, reimport that profile, and reconnect. Do not replace the corporate VPN profile or change your Wi-Fi adapter's DNS.

Complete Entra sign-in interactively. Confirm Connected, then Read the local routes and effective DNS policy:

Get-NetRoute -AddressFamily IPv4 |
    Where-Object DestinationPrefix -eq $vnetPrefix |
    Format-Table DestinationPrefix, NextHop, InterfaceAlias
Get-DnsClientNrptPolicy -Effective
Resolve-DnsName www.microsoft.com -Server $dnsInboundIp -DnsOnly
Resolve-DnsName www.microsoft.com -Server $dnsInboundIp -DnsOnly -TcpOnly

Expect the route to use your VPN interface, NRPT rules to reference the selected resolver, and both resolver queries to return DNS responses. These public-name queries test resolver reachability, not private endpoint access.

With Entra authentication, Azure VPN Client uses NRPT. Its DNS settings may not appear in ipconfig /all. Use the resolver's inbound IP, not the Azure-only DNS address 168.63.129.16, as the client DNS server.

Before step 7 creates the services and private endpoint records, their names may be unresolved or resolve publicly. Perform the private service DNS checks in step 7, after provisioning, rather than treating that earlier result as a VPN failure.

6. Point GPT-RAG at the existing VNet

The VPN and DNS resources now occupy $resourceGroup. GPT-RAG must use the same group, not a new group named after $environment. The command azd env set AZURE_RESOURCE_GROUP $resourceGroup below saves this selection in the local environment; azd provision later creates the GPT-RAG resources there. It does not move or recreate the network.

Return to the GPT-RAG root and retrieve the actual VNet ID again rather than typing one. Run each command separately and stop on failure:

Set-Location $repoRoot
$vnetId = az network vnet show --subscription $subscription `
    --resource-group $resourceGroup --name $vnetName --query id --output tsv
if (-not $vnetId) { throw 'The VNet resource ID was not returned.' }
azd env set AZURE_SUBSCRIPTION_ID $subscription
azd env set AZURE_RESOURCE_GROUP $resourceGroup
azd env set AZURE_LOCATION $region
azd env set NETWORK_ISOLATION true
azd env set USE_EXISTING_VNET true
azd env set EXISTING_VNET_RESOURCE_ID $vnetId
azd env set DEPLOY_SUBNETS true
azd env set SIDE_BY_SIDE false
azd env set DEPLOYMENT_TOPOLOGY hosted-no-panel
azd env set DEPLOY_ACR_TASK_AGENT_POOL true

Confirm the saved group before proceeding: azd env get-value AZURE_RESOURCE_GROUP must return your $resourceGroup value (for example, rg-gpt-rag-private). EXISTING_VNET_RESOURCE_ID must identify the VNet you just inspected in that group, not another corporate VNet.

DEPLOY_SUBNETS=true assumes a dedicated VNet with room for the GPT-RAG subnets. Do not apply this setting to a shared VNet without reviewing the affected resources. Explicit hosted-no-panel selection avoids depending on how an environment with pre-existing resources is classified.

Keep the host setting unset for the VPN workflow

For the v3.8.5 workflow, leave RUN_FROM_JUMPBOX unset, not false. On a fresh installation, simply never set this key and skip the removal instructions below. Existing true remains compatible with the host checks, but is unnecessary and never bypasses them.

Migration/resume only: if you reused a jumpbox configuration and want the VPN workflow below, remove the entire RUN_FROM_JUMPBOX=... line from the selected root checkout's .azure\$environment\.env. Do not replace false with an empty value. Preserve every unrelated key and secret; do not remove the environment directory or edit child, registry, user, or machine settings. Open only that local file:

$azdEnvFile = Join-Path $repoRoot ".azure\$environment\.env"
if (-not (Test-Path -LiteralPath $azdEnvFile)) {
    throw 'Select the intended local azd environment before editing its settings.'
}
notepad.exe $azdEnvFile

After removing that key if present, save and close the editor, then check without displaying the rest of the file, which can contain sensitive values. Clear the same key from this PowerShell process if an earlier session step set it:

if (Select-String -LiteralPath $azdEnvFile -Pattern '^\s*RUN_FROM_JUMPBOX\s*=' -Quiet) {
    throw 'Remove only RUN_FROM_JUMPBOX from the selected .env file, then save it.'
}
if (Test-Path Env:RUN_FROM_JUMPBOX) {
    Remove-Item Env:RUN_FROM_JUMPBOX
}

No output and no error means the selected root file check passed and this process key is absent. These commands change no global settings. azd env remove removes an environment, not one key; do not use it here.

Use AZURE_SKIP_NETWORK_ISOLATION_WARNING to control the infrastructure-only phases below. In v3.8.5, RUN_FROM_JUMPBOX=false, 0, no, or skip explicitly defers post-provision configuration; it does not mean "run from my local machine." A truthy jumpbox value takes precedence over the warning/deferral setting, but never bypasses the actual network checks. Leaving it unset avoids both ambiguities.

Provisioning supplies ACR_TASK_AGENT_POOL with the created pool's name. Do not pre-fill that output to make a missing pool appear configured.

Local file change only: the following block backs up the root main.parameters.json inside the ignored azd environment directory, then updates only its network parameter values. Adapt every workload prefix if you changed the example VNet range. It preserves the other parameters and does not edit infra or manifest.json.

$networkParameters = @{
    vnetAddressPrefixes = @($vnetPrefix)
    agentSubnetPrefix = '10.80.0.0/24'
    acaEnvironmentSubnetPrefix = '10.80.1.0/24'
    peSubnetPrefix = '10.80.2.0/26'
    azureBastionSubnetPrefix = '10.80.2.64/26'
    azureFirewallSubnetPrefix = '10.80.2.128/26'
    gatewaySubnetPrefix = '10.80.2.192/26'
    azureAppGatewaySubnetPrefix = '10.80.3.0/27'
    jumpboxSubnetPrefix = '10.80.3.64/27'
    devopsBuildAgentsSubnetPrefix = '10.80.3.96/27'
}
$parameterPath = Join-Path $repoRoot 'main.parameters.json'
$backupPath = Join-Path $repoRoot ".azure\$environment\main.parameters.before-vpn.json"
if (Test-Path -LiteralPath $backupPath) {
    throw 'A parameter backup already exists. Review it before editing again.'
}
$parameters = Get-Content -LiteralPath $parameterPath -Raw | ConvertFrom-Json -AsHashtable
if ($parameters.parameters -isnot [System.Collections.IDictionary]) {
    throw 'The root file does not contain a parameters object.'
}
Copy-Item -LiteralPath $parameterPath -Destination $backupPath
foreach ($name in $networkParameters.Keys) {
    $parameters.parameters[$name] = @{ value = $networkParameters[$name] }
}
$parameters | ConvertTo-Json -Depth 100 |
    Set-Content -LiteralPath $parameterPath -Encoding utf8NoBOM
git diff -- main.parameters.json

Review the diff before provisioning. JSON formatting may change, but parameter values outside this network list must remain unchanged.

The infrastructure's gateway-subnet is not the VPN's GatewaySubnet. Keep their names and address ranges separate.

These are deployment settings, not component-version changes. Do not edit the managed infra source or change the release manifest. Avoiding VM login also does not automatically remove VM/Bastion resources from the selected topology. Review their supported deployment options and cost separately.

7. Provision infrastructure and verify connectivity

Release requirement: follow this sequence only with a published release containing the host checks described in step 8.

Proceed only after reviewing the deployment-host requirement in step 8, the proposed resource costs, and the network plan.

Separate infrastructure provisioning from data-plane configuration:

azd env set AZURE_SKIP_NETWORK_ISOLATION_WARNING true
$env:AZURE_SKIP_NETWORK_ISOLATION_WARNING = 'true'
azd provision --preview

Here, AZURE_SKIP_NETWORK_ISOLATION_WARNING=true deliberately defers local data-plane post-provisioning. It does not skip security requirements or prove connectivity. With RUN_FROM_JUMPBOX unset as above, the hook reports the explicit deferral instead of trying private service configuration. Preview can run hooks and prepare local files.

The two assignments deliberately agree: azd env set saves the flag in the selected local environment, while $env: sets it in this PowerShell process. An inherited process warning flag can still defer configuration when the selected environment does not contain that key. Set both to false when resuming configuration, as shown below; do not assume removing a saved key also removes an inherited process value.

Review subnet changes, private endpoints, DNS, NSGs, Firewall, quotas, the private ACR build pool, and preservation of the VPN and resolver subnets. Stop on unexpected changes or preflight errors; do not bypass them.

After approval, provision the resources:

azd provision

Wait for its final result. An ARM provisioning failure is not the same as successfully provisioning infrastructure with data-plane setup deferred. Use the separate phases rather than azd up, so you can check connectivity between them.

Read: inspect only the relevant output values; do not dump all environment values into a shared log:

azd env get-value AZURE_RESOURCE_GROUP
azd env get-value APP_CONFIG_ENDPOINT
azd env get-value ACR_TASK_AGENT_POOL
az network vnet subnet list --subscription $subscription `
    --resource-group $resourceGroup --vnet-name $vnetName `
    --query '[].{name:name,prefix:addressPrefix,routeTable:routeTable.id}' --output table

Expect your selected resource group, a nonempty configuration endpoint, and the provisioned private build-pool name. A missing output is a stop condition, not a reason to invent its value.

Check the return route to VPN clients

The local machine must reach the services, and those services must have a return path to the P2S client pool.

Inspect the actual route tables associated with the GPT-RAG workload subnets. Record their routes, subnet associations, and gateway route propagation settings. With this guide's default Firewall configuration, the deployment creates a workload route table with gateway route propagation disabled and a default route through Azure Firewall. Expect to need an explicit P2S return route; confirm the actual route table and have the network owner review any existing return path before adding one.

For the single-VNet, route-based VPN scenario in this guide, an approved missing return route can be added as follows:

Field Value
Name An unused name, for example return-to-p2s
Destination prefix Your actual P2S client pool; 10.81.0.0/24 in this example
Next hop type Virtual network gateway
Next hop address Not applicable

Do not overwrite conflicting routes, replace the default Firewall route, or attach the workload route table to GatewaySubnet or the DNS inbound subnet. The next hop is not the gateway's public IP.

Read: pick the relevant workload subnet from the preceding list, then retrieve its actual table. Repeat this review if workloads use different tables; do not select the first route table in the resource group.

$workloadSubnet = '<workload-subnet-name-from-the-list>'
$routeTableId = az network vnet subnet show --subscription $subscription `
    --resource-group $resourceGroup --vnet-name $vnetName --name $workloadSubnet `
    --query routeTable.id --output tsv
if (-not $routeTableId) { throw 'No route table is attached. Review the network design.' }

$routeTable = az network route-table show --ids $routeTableId --output json | ConvertFrom-Json
$routeTable | Select-Object name, disableBgpRoutePropagation
$routeTable.subnets | Select-Object id
$routeTable.routes | Select-Object name, addressPrefix, nextHopType, nextHopIpAddress

Write, only for an approved missing route: the block below refuses to replace a conflicting prefix or name and leaves an existing correct route unchanged. It never changes propagation, subnet associations, or other routes.

$routeName = 'return-to-p2s'
if (@($routeTable.subnets | Where-Object {
    $_.id -like '*/subnets/GatewaySubnet' -or $_.id -like "*/subnets/$dnsSubnetName"
}).Count -gt 0) {
    throw 'This table is attached to a VPN or DNS subnet. Stop for network review.'
}
$poolRoutes = @($routeTable.routes | Where-Object addressPrefix -eq $p2sPool)
$nameRoutes = @($routeTable.routes | Where-Object name -eq $routeName)
if ($poolRoutes.Count -eq 1 -and $poolRoutes[0].nextHopType -eq 'VirtualNetworkGateway') {
    Write-Host 'The P2S return route already exists; no change needed.'
} elseif ($poolRoutes.Count -gt 0 -or $nameRoutes.Count -gt 0) {
    throw 'A conflicting route exists. Do not overwrite it.'
} else {
    $routeIdParts = $routeTableId -split '/'
    $routeSubscription = $routeIdParts[2]
    $routeGroup = $routeIdParts[4]
    az network route-table route create --subscription $routeSubscription `
        --resource-group $routeGroup --route-table-name $routeTable.name `
        --name $routeName --address-prefix $p2sPool `
        --next-hop-type VirtualNetworkGateway --output none
}

az network route-table show --ids $routeTableId `
    --query '{propagationDisabled:disableBgpRoutePropagation,subnets:subnets[].id,routes:routes[].{name:name,prefix:addressPrefix,nextHop:nextHopType,ip:nextHopIpAddress}}' `
    --output json

Compare the final read with the preceding inventory. Expect the client-pool route through VirtualNetworkGateway, with the Firewall default route and subnet associations unchanged.

Test the application and build-pool paths separately from service private endpoints. A successful private endpoint connection alone does not prove that all workload return paths are correct.

Recheck this route after every provision, including the second hosted provisioning phase. A manually added route is outside the release's infrastructure definition and may not survive reprovisioning.

Test DNS and private service access

Use each service's original endpoint hostname, obtained from its Azure resource or deployment outputs. Do not replace it with an IP address or a privatelink hostname in application URLs.

$serviceFqdn = '<actual-service-hostname>'

Resolve-DnsName $serviceFqdn -Server $dnsInboundIp -DnsOnly
Resolve-DnsName $serviceFqdn -Server $dnsInboundIp -DnsOnly -TcpOnly
Resolve-DnsName $serviceFqdn
Test-NetConnection -ComputerName $serviceFqdn -Port 443

Compare the DNS result with the expected private address. The test without -Server is essential: it checks the DNS path applications normally use. Repeat for the services your topology needs, including Foundry, App Configuration, Key Vault, Search, Blob, ACR, and the application endpoint when available.

TCP success is not a TLS or authorization test. Also perform an appropriate authenticated, read-only HTTPS operation against each required service using an authorized identity. Do not disable certificate validation or print tokens and secret values. Investigate a 401/403 against both the service's network restrictions and its authentication/authorization requirements.

For example, after infrastructure provisioning, these Read commands test authenticated App Configuration and Blob access without reading secret values or downloading documents:

$appConfigEndpoint = azd env get-value APP_CONFIG_ENDPOINT
az appconfig kv list --subscription $subscription --endpoint $appConfigEndpoint `
    --auth-mode login --key SUBSCRIPTION_ID --label gpt-rag `
    --query '[].{key:key,label:label}' --output json

$storageAccount = '<actual-storage-account-name>'
$documentsContainer = '<approved-source-container-name>'
az storage container exists --account-name $storageAccount `
    --name $documentsContainer --auth-mode login --query exists --output tsv

Before post-provision configuration, the App Configuration key may not exist: an authorized empty list is not a configuration failure, but it does not prove that setup is complete. The container check must return true for the expected container. Both commands use your user identity, not the hosted agent's; they do not prove the agent has its own permissions.

8. Continue the GPT-RAG installation

Configure services over the VPN

Starting with v3.8.5: the post-provision script checks actual private connectivity instead of asking whether you are on the VPN. An unset RUN_FROM_JUMPBOX no longer causes an interactive prompt or an automatic skip in a noninteractive session. After checking the return route and services in step 7, clear the explicit deferral and run from the repository root:

azd env set AZURE_SKIP_NETWORK_ISOLATION_WARNING false
if ($LASTEXITCODE -ne 0) { throw 'Could not enable data-plane configuration.' }
$env:AZURE_SKIP_NETWORK_ISOLATION_WARNING = 'false'
.\scripts\postProvision.ps1

Expect the App Configuration host check to succeed before data-plane configuration starts, then wait for the script's successful completion. This script configures Azure services; it is not a read-only diagnostic. If it reports deferral, configuration has not run: check both the saved and process settings above. The hook can exit successfully after warning that configuration is incomplete; a zero exit code does not override that warning. If a host check fails, fix DNS/routing/TLS before retrying; do not disable validation.

Check the deployment host before building or deploying

The v3.8.5 host checks apply when NETWORK_ISOLATION=true. They use the operating system's normal DNS resolution, require only RFC1918 private IPv4 destinations (10.0.0.0/8, 172.16.0.0/12, or 192.168.0.0/16), then check TCP 443 and normal TLS certificate/hostname validation with SNI for the original service hostname. The checks do not send Azure credentials or an authenticated application request.

Phase Endpoint checked before that phase's protected operation
Post-provision configuration APP_CONFIG_ENDPOINT
Pre-deploy APP_CONFIG_ENDPOINT; also AZURE_AI_PROJECT_ENDPOINT for hosted deployment
Hosted image build AZURE_CONTAINER_REGISTRY_ENDPOINT for the actual registry selected for the build

Public mode (NETWORK_ISOLATION=false) does not run these private-host probes. An invalid explicit isolation value, such as tru, fails instead of silently selecting public mode. The selected azd environment must be readable and valid; a failed, empty, or malformed environment read stops the hook rather than falling back to another .azure directory. Reusing an existing image digest or supplying a prebuilt digest avoids an unnecessary hosted-build ACR probe; it does not bypass checks needed by later deployment phases. A missing/invalid endpoint, public or mixed DNS result, certificate failure, or timeout stops the guarded phase before its writes. RUN_FROM_JUMPBOX=true is not proof of connectivity and never bypasses these checks. The jumpbox and warning flags control only post-provision deferral; pre-deploy and hosted-build checks ignore them. There is no instruction to skip TLS validation, use a proxy bypass, or fall back to public service endpoints.

These checks establish only the tested host-to-endpoint DNS/TCP/TLS path. They do not establish RBAC, API health, ownership of the Azure VNet, connectivity for every service, the remote build pool's outbound access, or success of a fresh GPT-RAG installation. Keep the separate authorized service checks in step 7 and the runtime/document authorization checks below.

Historical v3.8.4: its pre-deploy hook still requires RUN_FROM_JUMPBOX=true. Do not use that flag locally as a workaround. Use a complete published release containing the v3.8.5 host checks for this local build/deploy sequence.

Prepare the hosted image, then provision again

Follow the hosted deployment lifecycle. The following are Write/build operations, not read-only connectivity tests. Stay connected to the VPN, keep RUN_FROM_JUMPBOX unset, and use the same released source, local environment, and resource group throughout:

Set-Location $repoRoot
azd env get-value ACR_TASK_AGENT_POOL
.\scripts\prepareHostedDeployment.ps1
if ($LASTEXITCODE -ne 0) { throw 'Hosted image preparation failed.' }
azd env set AZURE_SKIP_NETWORK_ISOLATION_WARNING true
$env:AZURE_SKIP_NETWORK_ISOLATION_WARNING = 'true'
azd provision

The deferral is restored before the second azd provision because that provision can replace a manually added P2S return route. It prevents automatic data-plane configuration before you have rechecked the network; it does not undo the image preparation or move resources to another group.

Stop and repeat the route and connectivity checks in step 7 after that provision. Once the expected return path and private service access are confirmed, clear deferral and rerun configuration before deployment:

azd env set AZURE_SKIP_NETWORK_ISOLATION_WARNING false
if ($LASTEXITCODE -ne 0) { throw 'Could not re-enable data-plane configuration.' }
$env:AZURE_SKIP_NETWORK_ISOLATION_WARNING = 'false'
.\scripts\postProvision.ps1
if ($LASTEXITCODE -ne 0) { throw 'Post-provision configuration did not complete.' }

Continue only when configuration completes successfully:

azd deploy

Expected outcome: the pre-deploy checks pass and the deployment proceeds to its normal service operations. A passed host check alone is not a successful deployment. A fresh automated live installation/bootstrap/smoke run has not been performed for this release; that acceptance remains separate.

Use the VNet-connected ACR build pool for private builds. Your local VPN connection does not make shared ACR Tasks able to reach private resources. Do not switch to public endpoints to make a failed private build succeed.

9. Understand which permissions GPT-RAG grants automatically

The hosted runtime permission fix is included starting with GPT-RAG v3.8.4. You do not normally need to add these runtime grants manually before deploying. After creating the hosted agent, its post-deploy hook discovers the actual agent instance identity and applies the following minimum roles:

Purpose Automatically assigned role Scope
Read application configuration App Configuration Data Reader The configured App Configuration store
Call the configured Azure OpenAI models Cognitive Services OpenAI User The configured model account
Read the audit-signing secret, when its Key Vault reference is configured Key Vault Secrets User That individual referenced secret, not the entire vault

The last grant is conditional: no audit-secret reference means no secret grant and no secret creation. Bootstrap inspects reference metadata; it does not retrieve or log the secret value.

These grants belong to instance_identity.principal_id on the deployed agent, not to your user account, the Foundry project identity, or a Container App identity. The deployment operator needs discovery access and permission to create role assignments at those scopes. The VPN provides neither.

The hook reuses matching unconditional grants on repeat runs. Conflicting conditional grants cause a failure instead of being bypassed. Root deployment waits for bootstrap and a completed Hello! response before UI cutover. Direct child deployment applies the roles but does not run the greeting. Role visibility in Azure Resource Manager does not prove data-plane propagation; use the documented recovery flow if the greeting fails.

See Plan and recover hosted access for the read-only --plan command and explicit recovery procedure. A successful plan can still report missing grants; inspect exact_unconditional_assignment. Its data_plane_readiness=not-tested result is not an application health check.

Read, after the hosted agent exists: from the repository root, select the environment and inspect its runtime permission plan. The module reads the hosted child project's saved azd environment; it cannot inspect an agent that has not been deployed yet.

Set-Location $repoRoot
$savedPythonPath = $env:PYTHONPATH
$savedAzureEnvName = $env:AZURE_ENV_NAME
try {
    $env:PYTHONPATH = $repoRoot
    $env:AZURE_ENV_NAME = $environment
    Push-Location (Join-Path $repoRoot 'hosted-agent')
    try {
        $accessPlan = python -m config.deployment.hosted_access --azd-env --plan |
            ConvertFrom-Json
    } finally {
        Pop-Location
    }
} finally {
    $env:PYTHONPATH = $savedPythonPath
    $env:AZURE_ENV_NAME = $savedAzureEnvName
}
$accessPlan | Select-Object agent_name, agent_version, audit, data_plane_readiness
$accessPlan.grants | Select-Object role, scope, exact_unconditional_assignment

Expect two runtime grants, or three when the audit-secret reference is configured. Missing grants require investigation of the deployment hook or the documented recovery procedure, not broader access. Keep the output private: it contains deployment resource identifiers. Do not run manual --apply before testing whether a fresh deployment applies these roles automatically.

Search and Blob permissions are separate

Search and Blob document-access grants are not added by this bootstrap. They authorize access to content, rather than just starting the application. Choose and approve the retrieval authorization model before uploading real data.

For an explicitly approved service-identity test against a Blob knowledge source, the hosted agent may need:

Purpose Role Reviewed scope
Query the configured Search service Search Index Data Reader The actual Search service used for retrieval
Read the approved source documents Storage Blob Data Reader Only the approved source container

This is a scoped test pattern, not a universal grant recipe for every grounding source. Confirm the consuming identity for your retrieval configuration and follow the application/document identity guidance. Do not substitute the Foundry project identity or grant broad roles to fix an unexplained 403.

A Search-service grant is not restricted to one test index, and a container grant covers future documents placed in that container. In a service-identity retrieval flow, application callers can receive content the agent is allowed to retrieve; this does not prove per-user document authorization or OBO. Test allowed and denied users separately before enabling protected content.

Optional commands for an approved synthetic-content test

Skip this subsection unless document access has been explicitly approved. It is separate from installation and from the automatic-bootstrap test. Use the actual agent principal returned by the preceding read-only plan. These examples assume the reviewed Search service and Storage account are in the dedicated resource group used by this guide.

Read: resolve the exact resource scopes and inspect existing assignments. Replace the names with the resources actually used for retrieval, not another Search service associated with the Foundry project.

if (-not $accessPlan.principal_id -or $accessPlan.classic) {
    throw 'A valid hosted access plan is required.'
}
$agentPrincipalId = $accessPlan.principal_id
$searchService = '<actual-search-service-used-for-retrieval>'
$storageAccount = '<actual-storage-account-name>'
$documentsContainer = '<approved-synthetic-source-container>'
$searchScope = az search service show --subscription $subscription `
    --resource-group $resourceGroup --name $searchService --query id --output tsv
$containerScope = az storage container-rm show --subscription $subscription `
    --resource-group $resourceGroup --storage-account $storageAccount `
    --name $documentsContainer --query id --output tsv
if (-not $searchScope -or -not $containerScope) {
    throw 'Could not resolve the reviewed Search or container scope.'
}

$documentGrants = @(
    @{ scope = $searchScope; role = '1407120a-92aa-4202-b7e9-c0e197c71c8f' }
    @{ scope = $containerScope; role = '2a2b9908-6ea1-4ae2-8e65-a410df84e7d1' }
)
foreach ($grant in $documentGrants) {
    az role assignment list --subscription $subscription `
        --assignee-object-id $agentPrincipalId --scope $grant.scope --include-inherited `
        --fill-principal-name false --fill-role-definition-name false `
        --query '[].{principal:principalId,role:roleDefinitionId,scope:scope,condition:condition,conditionVersion:conditionVersion}' `
        --output json
}

The role IDs identify Search Index Data Reader and Storage Blob Data Reader, respectively. Review inherited and conditional access as well as direct grants. Do not create another assignment to bypass a condition, or add a redundant grant where approved existing access already suffices.

Write: only an authorized access administrator should run the next block after reviewing the identity, scopes, existing access, and test content. It defaults to refusing the operation. Change the approval switch only for this explicitly approved test; do not include this block in unattended installation.

$documentAccessApproved = $false
if (-not $documentAccessApproved) {
    throw 'Document access has not been approved. No role assignments were created.'
}
# Run only the command for a reviewed, missing grant.
az role assignment create --subscription $subscription `
    --assignee-object-id $agentPrincipalId --assignee-principal-type ServicePrincipal `
    --role '1407120a-92aa-4202-b7e9-c0e197c71c8f' --scope $searchScope --output none

az role assignment create --subscription $subscription `
    --assignee-object-id $agentPrincipalId --assignee-principal-type ServicePrincipal `
    --role '2a2b9908-6ea1-4ae2-8e65-a410df84e7d1' --scope $containerScope --output none

Repeat the preceding Read loop to confirm the assignments. Then allow for RBAC propagation and test only synthetic content. Do not broaden the scopes if access still fails. Track test grants separately for later authorized cleanup; do not remove the runtime grants maintained by bootstrap.

10. Check the result and operate safely

Keep the checks distinct:

Check What it establishes
VPN connected The tunnel is established.
Original service name resolves to the expected private IP DNS selects the intended private destination.
TCP/TLS and an authenticated service read succeed The tested path and authorized operation work.
Agent greeting completes The tested configuration/model path works.
A synthetic document query returns a grounded answer That retrieval path works for the tested identity.
Allowed users succeed and denied users are refused The tested document-authorization boundary is enforced.

Troubleshoot the failing layer rather than granting broader permissions:

Symptom Check next
VPN connection fails Gateway state, profile, Entra audience, user access, and Conditional Access
DNS works only with explicit -Server Client profile, effective NRPT policy, DNS cache, and other VPN clients
A private service resolves publicly DNS suffixes, zone links, and private endpoint records
Private DNS is correct but TCP fails Forward/return routes, P2S pool, NSGs, and Firewall
Access breaks after provisioning Return route, subnet associations, DNS, and private endpoint state
Authenticated requests return 403 Service network restrictions, token audience, identity, and operation-specific RBAC
Remote image build fails Build-pool connectivity, DNS, and approved outbound dependencies
Deploy requests RUN_FROM_JUMPBOX Check the selected release: v3.8.4 has the old gate; v3.8.5 uses actual host checks. Do not fake the flag or mix development files into a release.
Host check rejects public/mixed DNS, TLS, or a timeout Correct the original endpoint, OS DNS/NRPT, private routes, and certificate trust. A TCP-only test is insufficient; do not disable TLS validation.
Post-provision reports deferral Keep RUN_FROM_JUMPBOX unset and set both the saved and process AZURE_SKIP_NETWORK_ISOLATION_WARNING values to false only after route/service checks. RUN_FROM_JUMPBOX=false is also an explicit defer, not a local-host selector.

Disconnect the VPN when it is no longer needed. This does not delete resources or stop their charges. Plan cleanup separately with the resource owners; do not run an indiscriminate teardown against a shared network.

References

© 2025 GPT-RAG — powered by ❤️ and coffee ☕