How to Reference an Existing Resource in Another Resource Group in Bicep

By ResourcePulse Team · · 5 min read

The existing keyword looks like the simplest thing in Bicep: name a resource that is already deployed and read its properties. Then the deployment fails with ResourceNotFound — for a resource you can see in the portal, right now, definitely existing.

Nearly every time, the resource is real but Bicep is looking for it in the wrong place. This post shows where Bicep looks by default, how to point it at another resource group or subscription with scope, and the two variants that trip people up even after they know about scope: child resources and Key Vault secrets.

Where Bicep looks when you don't set a scope

Say a web app in app-rg needs the address space of a hub network that the platform team maintains in network-rg:

resource hubVnet 'Microsoft.Network/virtualNetworks@2024-05-01' existing = {
  name: 'vnet-hub'
}

output addressSpace array = hubVnet.properties.addressSpace.addressPrefixes

bicep build is clean. The deployment to app-rg fails:

Code=ResourceNotFound;
Message=The Resource 'Microsoft.Network/virtualNetworks/vnet-hub' under
resource group 'app-rg' was not found.

An existing declaration with only a name resolves at the deployment's target scope — the resource group you passed to az deployment group create, in the current subscription. The vnet lives in network-rg, so the lookup in app-rg finds nothing. The error message actually tells you this: it names the resource group it searched, and it is not the one the resource is in.

The build can't catch it because whether a resource exists is a deploy-time fact. bicep build checks syntax and types; the GET request that resolves an existing reference only happens when the deployment runs.

There is a sneakier version of the same mistake. Compile-time properties like .id and .name never trigger that GET — Bicep assembles the resource ID from the name and scope it was given. Read only hubVnet.id from a wrongly-scoped reference and the deployment happily produces an ID pointing at a vnet in app-rg that does not exist. Nothing fails until something downstream consumes the bad ID, and by then the error blames the downstream resource.

Reading a resource in another resource group

Set the scope property with the resourceGroup() function:

resource hubVnet 'Microsoft.Network/virtualNetworks@2024-05-01' existing = {
  name: 'vnet-hub'
  scope: resourceGroup('network-rg')
}

output addressSpace array = hubVnet.properties.addressSpace.addressPrefixes

resourceGroup() takes the plain resource group name, not a resource ID. Same for name on the resource itself — the resource's own name, never the full /subscriptions/... path. With one argument, the function assumes the current subscription.

This is a capability unique to existing. Resources you deploy in a resource-group-scope file must land in the deployment's own resource group (crossing that boundary requires a module with its own scope). References to existing resources are exempt — they can point anywhere you can read.

Crossing subscriptions

If the hub network lives in a platform subscription, pass the subscription ID as the first argument:

resource hubVnet 'Microsoft.Network/virtualNetworks@2024-05-01' existing = {
  name: 'vnet-hub'
  scope: resourceGroup('1b3c4d5e-platform-sub-id', 'network-rg')
}

One thing changes operationally: the identity running the deployment now needs read access on that scope. The deployment performs a GET against the other subscription, so a service principal that only has Contributor on app-rg gets an authorization error — a different failure than ResourceNotFound, but the same root cause of the reference reaching outside what the pipeline was set up for.

Child resources take parent, not scope

The next thing most templates need from that vnet is a subnet ID. Writing the child as a standalone existing resource with a slash in the name ('vnet-hub/snet-app') sometimes works and reliably confuses the linter. The clean pattern is a parent reference:

resource hubVnet 'Microsoft.Network/virtualNetworks@2024-05-01' existing = {
  name: 'vnet-hub'
  scope: resourceGroup('network-rg')
}

resource appSubnet 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' existing = {
  parent: hubVnet
  name: 'snet-app'
}

output subnetId string = appSubnet.id

The scope lives on the parent only; the child inherits it through parent. Setting scope directly on a child resource that also has a parent is an error — pick one, and for child resources the one is parent.

Key Vault secrets across resource groups

The most common cross-resource-group reference in real templates is a shared Key Vault. The same scope rule applies, with one extra constraint: getSecret() can only be used as an argument to a module parameter marked @secure():

resource kv 'Microsoft.KeyVault/vaults@2023-07-01' existing = {
  name: 'kv-platform'
  scope: resourceGroup('security-rg')
}

module sql './sql.bicep' = {
  name: 'sql'
  params: {
    administratorLoginPassword: kv.getSecret('sqlAdminPassword')
  }
}

If the vault reference resolves in the wrong scope you get the familiar NotFound, but even with the scope right, the vault itself must have enabledForTemplateDeployment set to true or ARM refuses to read the secret. When a secret lookup fails, check both.

Letting a missing resource resolve to null

Sometimes "the resource might not be there" is not a bug but the actual situation — an optional diagnostics rule, a resource that only exists in production. Recent Bicep versions add a decorator for exactly this:

@nullIfNotFound()
resource dcr 'Microsoft.Insights/dataCollectionRules@2023-03-11' existing = {
  name: 'dcr-central'
  scope: resourceGroup('monitoring-rg')
}

output dcrId string = dcr.?id ?? ''

With @nullIfNotFound(), a failed lookup makes the symbol null instead of failing the deployment, and the safe-dereference operator handles the rest — the same .? and ?? pair that guards optional properties that fail with "the language expression property doesn't exist". The decorator graduated from the existingNullIfNotFound experimental flag in Bicep 0.44, so check az bicep version before relying on it.

Don't reach for it to silence a scope bug, though. If the resource should always exist, a hard NotFound at the right scope is more useful than a null that flows quietly into downstream defaults.

A swapped keyword is a new bill

An existing reference adds nothing to your Azure spend — it reads a resource someone else pays for. Which is why the riskiest diff involving one is the diff where it disappears: a template copied from another repo, the existing keyword dropped, and the pull request now creates a second vnet, vault, or workspace instead of referencing the shared one. The change is two words in a diff and an entire duplicated resource in the bill.

If your team reviews Bicep on GitHub, ResourcePulse posts the estimated monthly cost of every resource a pull request adds or changes, so a reference that silently became a deployment shows up as a priced line item in the review. The Preview tier is free on one repository and needs no Azure subscription access.