Adds one Azure Monitor resource to an Azure Monitor Private Link Scope. Four arguments, all immutable. Targets
hashicorp/azurerm ~> 4.0.
- βοΈ Creates one
azurerm_monitor_private_link_scoped_service, namedthisβ the membership record that brings a workspace, an Application Insights component, or a Data Collection Endpoint inside a scope. - π΄ The harm is done by the memberships that do not exist. A scope set to
PrivateOnlyblocks every Azure Monitor resource that is not in it, across every network sharing the same DNS, in any subscription or tenant. - π§ Every one of the four arguments is immutable, which is why the provider's
timeoutsblock has noupdatekey. This module'stimeoutstype has three keys to match. - π― Rejects the two values most likely to be transposed: the scope's Resource ID where its name belongs, and a bare name where an ID belongs.
- π Parses the linked resource's own resource group and subscription out of its ID, because
resource_group_nameon this resource is the scope's resource group and the argument name does not say so. - π£ States plainly what it cannot see: whether the scope is complete, and whether the link crosses a subscription.
π‘ Why it matters: this is a four-argument association with no settings, and it decides whether telemetry is reachable. Getting it wrong produces no Terraform error β just a resource that a whole network can no longer talk to, or a scope quietly missing a member. Both are invisible in a plan.
If this module saves you time:
- β Star the repository β it helps others find it.
- πΌ Connect on LinkedIn β linkedin.com/in/microsoftexpert
- β Buy me a coffee β buymeacoffee.com/microsoftexpert
flowchart TB
rg["terraform-azurerm-resource-group"]
scope["terraform-azurerm-monitor-private-link-scope"]
this["terraform-azurerm-monitor-private-link-scoped-service"]
law["terraform-azurerm-log-analytics-workspace"]
appi["terraform-azurerm-application-insights"]
dce["terraform-azurerm-monitor-data-collection-endpoint"]
pe["terraform-azurerm-private-endpoint"]
rg -->|"name"| scope
scope -->|"name plus resource_group_name"| this
law -->|"id, one of three legal types"| this
appi -->|"id, one of three legal types"| this
dce -->|"id, one of three legal types"| this
pe -.->|"attaches to the scope, not to this record"| scope
style this fill:#0078D4,stroke:#004578,color:#ffffff
style scope fill:#004578,stroke:#004578,color:#ffffff
style rg fill:#F3F6F9,stroke:#8A8886,color:#201F1E
style law fill:#F3F6F9,stroke:#8A8886,color:#201F1E
style appi fill:#F3F6F9,stroke:#8A8886,color:#201F1E
style dce fill:#F3F6F9,stroke:#8A8886,color:#201F1E
style pe fill:#F3F6F9,stroke:#8A8886,color:#201F1E
Note the dashed edge. The private endpoint attaches to the scope, not to this membership record β a membership on its own routes nothing, and an endpoint on its own reaches nothing that has not been added.
flowchart TB
subgraph inputs["Inputs, all four force-new"]
i1["scope_name, a NAME"]
i2["resource_group_name, the SCOPE's"]
i3["linked_resource_id, one of three types"]
i4["name, the association record"]
end
this["azurerm_monitor_private_link_scoped_service.this"]
subgraph outputs["Outputs"]
o1["id"]
o2["linked_resource_type, in words"]
o3["linked_resource_group_name, parsed"]
o4["links_across_resource_groups"]
o5["omitting_a_resource_from_the_scope_blocks_it_when_the_scope_is_private_only"]
end
i1 --> this
i2 --> this
i3 --> this
i4 --> this
this --> o1
this --> o2
this --> o3
this --> o4
this --> o5
style this fill:#0078D4,stroke:#004578,color:#ffffff
style inputs fill:#F3F6F9,stroke:#8A8886,color:#201F1E
style outputs fill:#F3F6F9,stroke:#8A8886,color:#201F1E
Resource inventory
| Resource | Count | Notes |
|---|---|---|
azurerm_monitor_private_link_scoped_service.this |
1 | The keystone; all four arguments force-new |
timeouts block |
0β1 | create / read / delete only β no update exists |
| Item | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/azurerm |
~> 4.0 |
| Provider block | None in this module β the caller configures the provider, its auth, and its features {} block |
| ARM API version | Microsoft.Insights 2019-10-17-preview |
Schema notes that bite
- π΄ All four arguments are force-new. There is no in-place change this resource accepts.
- π΄
timeoutshas noupdatekey, because no update operation exists. A four-keytimeoutsobject would have its fourth key silently discarded. - π΄
resource_group_nameis the SCOPE's resource group, not the linked resource's. The provider's own example uses one resource group for everything, which hides it. - π΄
scope_namewants a name;linked_resource_idwants an ID. The two likeliest wrong values are each other. β οΈ Exactly three linked types are accepted, and the provider reports all three validators' errors at once when none matches.β οΈ Case differences inlinked_resource_idare suppressed β re-casing does not force replacement.β οΈ Notags, nolocation. Tag the scope instead.β οΈ A virtual network connects to exactly one scope, so that scope must hold everything the network needs.
| Principal | Permission | Scope | Why |
|---|---|---|---|
| The Terraform identity | Microsoft.Insights/privateLinkScopes/scopedResources/write |
The private link scope | Create the association |
| The Terraform identity | Microsoft.Insights/privateLinkScopes/scopedResources/read |
The association | Refresh and plan |
| The Terraform identity | Microsoft.Insights/privateLinkScopes/scopedResources/delete |
The association | Destroy |
| The Terraform identity | Microsoft.Insights/privateLinkScopes/read |
The private link scope | The scope is located by name, so it must be readable |
| The Terraform identity | read on the linked resource | The workspace, component or DCE | Azure validates the target on create |
Monitoring Contributor covers every row. Note that the write is granted at the scope, not at the linked
resource β so an identity that adds a workspace to a scope needs no write access to the workspace itself.
Plan access is read-only in the useful sense. id is the only computed attribute and no secret is readable
through this resource.
scopedResources/delete on a PrivateOnly scope cuts a resource off, and no role boundary limits that blast
radius to the resource group the association lives in.
Microsoft.Insightsregistered on the subscription.- An existing Azure Monitor Private Link Scope and its resource group. This module locates the scope by name and never creates it.
- An existing linked resource of one of the three accepted types.
- A private endpoint attached to the scope, if private connectivity is the goal β a membership alone routes nothing.
- The private DNS zones that endpoint requires.
- A subnet of at least
/27. Microsoft's guidance is that an Azure Monitor private link setup needs eleven addresses beyond Azure's five reserved ones, even for a single workspace.
terraform-azurerm-monitor-private-link-scoped-service/
βββ providers.tf # required_version, pinned azurerm; no provider block
βββ variables.tf # 5 inputs, 11 validations
βββ main.tf # the keystone plus the ID parsing a plan cannot show
βββ outputs.tf # id first, then identity, then the facts worth asserting on
βββ README.md # this file
βββ SCOPE.md # the cross-module contract
βββ LICENSE # MIT
βββ .gitignore
provider "azurerm" {
features {}
}
module "workspace_in_scope" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scoped-service.git?ref=v1.0.0"
name = "amplssvc-law-core"
resource_group_name = var.private_link_scope_resource_group_name
scope_name = var.private_link_scope_name
linked_resource_id = var.log_analytics_workspace_id
}The caller configures the provider, its authentication, and the mandatory features {} block. This module
declares none of them.
Consumes
| Input | Type | Source |
|---|---|---|
scope_name |
Scope name | terraform-azurerm-monitor-private-link-scope β name |
resource_group_name |
The scope's resource group | terraform-azurerm-monitor-private-link-scope β resource_group_name |
linked_resource_id |
ARM Resource ID, one of three types | terraform-azurerm-log-analytics-workspace β id, terraform-azurerm-application-insights β id, terraform-azurerm-monitor-data-collection-endpoint β id |
Emits
| Output | Description |
|---|---|
id |
Association Resource ID, emitted first |
linked_resource_type |
Which of the three types, in words |
linked_resource_group_name |
The linked resource's resource group, parsed |
links_across_resource_groups |
The argument-name trap, made assertable |
every_argument_is_force_new |
Change planning |
omitting_a_resource_from_the_scope_blocks_it_when_the_scope_is_private_only |
The one to read |
ampls_limits_as_of_authoring |
Published limits, as a snapshot |
1 Β· The whole module
module "workspace_in_scope" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scoped-service.git?ref=v1.0.0"
name = "amplssvc-law-core"
resource_group_name = var.private_link_scope_resource_group_name
scope_name = var.private_link_scope_name
linked_resource_id = var.log_analytics_workspace_id
}βΉοΈ There is no larger version of this call. Four required arguments, one optional
timeoutsblock, and no settings β everything that can be configured about private access lives on the scope, not here.
2 Β· An Application Insights component
module "component_in_scope" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scoped-service.git?ref=v1.0.0"
name = "amplssvc-appi-shop"
resource_group_name = var.private_link_scope_resource_group_name
scope_name = var.private_link_scope_name
linked_resource_id = var.application_insights_id
}
output "what_was_linked" {
value = module.component_in_scope.linked_resource_type
}π‘
linked_resource_typereads back"Application Insights component". The type is buried mid-way through a long Resource ID where it is easy to misread, and it also determines which private endpoint DNS records the link will need.
3 Β· A Data Collection Endpoint
module "dce_in_scope" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scoped-service.git?ref=v1.0.0"
name = "amplssvc-dce-core"
resource_group_name = var.private_link_scope_resource_group_name
scope_name = var.private_link_scope_name
linked_resource_id = var.data_collection_endpoint_id
}
β οΈ These three types β workspace, component, Data Collection Endpoint β are the only ones a scope accepts. Agents that send data through a DCE need the DCE in the scope; adding only the workspace is a common and silent omission.
4 Β· The transposition this module rejects
# REJECTED at plan time by this module.
module "id_where_a_name_belongs" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scoped-service.git?ref=v1.0.0"
name = "amplssvc-law-core"
resource_group_name = module.private_link_scope.resource_group_name
scope_name = module.private_link_scope.id # wrong: this field wants the NAME
linked_resource_id = module.workspace.id
}π
scope_nameandlinked_resource_idsit next to each other and want opposite forms, and the scope module emits bothnameandid. Passing the ID gets you:scope_name must be the private link scope's NAME, not its Resource ID. Pass the scope module's name output rather than its id output - linked_resource_id is the field that wants an ID.Without the check, Azure rejects it at apply with an error that names neither field.
5 Β· The other half of the transposition
# REJECTED at plan time by this module.
module "name_where_an_id_belongs" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scoped-service.git?ref=v1.0.0"
name = "amplssvc-law-core"
resource_group_name = var.private_link_scope_resource_group_name
scope_name = var.private_link_scope_name
linked_resource_id = "law-core" # wrong: this field wants the full Resource ID
}π A bare name is rejected with a message that names the other field, so the fix is obvious in both directions.
6 Β· A resource type no scope can hold
# REJECTED at plan time by this module.
module "wrong_type" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scoped-service.git?ref=v1.0.0"
name = "amplssvc-storage"
resource_group_name = var.private_link_scope_resource_group_name
scope_name = var.private_link_scope_name
linked_resource_id = var.storage_account_id
}
β οΈ The provider enforces the same three types, but does it with a combinator that prints all three validators' failures at once when none matches β which does not read as an answer. This module gives one message naming the three legal types. A near miss like an action group ID underMicrosoft.Insightsis rejected the same way.
7 Β· The resource group that belongs to the scope
module "cross_rg_link" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scoped-service.git?ref=v1.0.0"
name = "amplssvc-appi-shop"
resource_group_name = "rg-networking" # the SCOPE's resource group
scope_name = "ampls-platform"
linked_resource_id = var.application_insights_id # lives in rg-application
}
output "check_the_two_resource_groups" {
value = {
scope_is_in = module.cross_rg_link.resource_group_name
linked_resource_is_in = module.cross_rg_link.linked_resource_group_name
they_differ = module.cross_rg_link.links_across_resource_groups
}
}π
links_across_resource_groupsistruehere, and that is perfectly legal β a scope routinely holds resources from other resource groups. It is emitted because this is the shape a caller creates by accident when they readresource_group_nameas belonging to the thing being linked. If it istrueand you did not mean it, one of the two values is wrong.
8 Β· Asserting the resource groups match, when they should
module "same_rg_link" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scoped-service.git?ref=v1.0.0"
name = "amplssvc-law-core"
resource_group_name = var.observability_resource_group_name
scope_name = var.private_link_scope_name
linked_resource_id = var.log_analytics_workspace_id
}
check "the_scope_and_workspace_share_a_resource_group" {
assert {
condition = !module.same_rg_link.links_across_resource_groups
error_message = "The scope and the linked workspace are in different resource groups. In this estate they should match, so one of resource_group_name or linked_resource_id is wrong."
}
}π‘ A
checkblock is right here because whether the two resource groups should match is an estate convention, not an Azure rule. The module reports; the composition decides.
9 Β· Every argument is force-new
module "repointed_link" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scoped-service.git?ref=v1.0.0"
name = "amplssvc-law-core"
resource_group_name = var.private_link_scope_resource_group_name
scope_name = var.private_link_scope_name
linked_resource_id = var.replacement_workspace_id # any change here replaces the record
}
β οΈ Changing any of the four arguments destroys and recreates the association. Between the destroy and the create, the linked resource is not inside the scope β which, on aPrivateOnlyscope, means it is unreachable from every attached network for the duration.A
moduleblock cannot carry alifecycleblock, so a caller cannot addcreate_before_destroyhere. Where that window matters, add the new membership under a second module instance first, then remove the old one in a later apply.
10 Β· The `timeouts` block with three keys
module "slow_link" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scoped-service.git?ref=v1.0.0"
name = "amplssvc-law-core"
resource_group_name = var.private_link_scope_resource_group_name
scope_name = var.private_link_scope_name
linked_resource_id = var.log_analytics_workspace_id
timeouts = {
create = "30m"
read = "5m"
delete = "30m"
}
}
β οΈ There is noupdatekey, because the resource has no update operation β every argument is force-new. Writingupdate = "30m"here is silently discarded by object type conversion rather than rejected: the plan succeeds and the key does nothing. That is the one place a wrong input to this module produces no signal at all.
11 Β· A whole scope's membership, driven by `for_each`
locals {
# The explicit inventory of everything this scope must contain. This map is the real control -
# anything missing from it is unreachable once the scope is PrivateOnly. In a real composition these
# values come from the workspace, component and DCE module outputs; see example 13.
monitored = {
law_core = var.log_analytics_workspace_id
appi_shop = var.application_insights_id
dce_agents = var.data_collection_endpoint_id
}
}
module "scope_members" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scoped-service.git?ref=v1.0.0"
for_each = local.monitored
name = "amplssvc-${each.key}"
resource_group_name = var.private_link_scope_resource_group_name
scope_name = var.private_link_scope_name
linked_resource_id = each.value
}
check "the_scope_contains_everything_the_inventory_lists" {
assert {
condition = length(module.scope_members) == length(local.monitored)
error_message = "A monitored resource in the inventory has no membership in the private link scope."
}
}
output "membership_types" {
value = { for k, m in module.scope_members : k => m.linked_resource_type }
}π This is where completeness can actually be checked. A single instance of this module cannot see the scope's other memberships β
one_module_instance_cannot_see_the_scope_s_other_membershipssays so β so the inventory and the assertion have to live in the composition that owns the scope. A keyed map rather than a list means removing one member never re-indexes the rest.
12 Β· Reading the published limits
module "workspace_in_scope" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scoped-service.git?ref=v1.0.0"
name = "amplssvc-law-core"
resource_group_name = var.private_link_scope_resource_group_name
scope_name = var.private_link_scope_name
linked_resource_id = var.log_analytics_workspace_id
}
output "scope_capacity" {
value = module.workspace_in_scope.ampls_limits_as_of_authoring
}βΉοΈ The output name carries
as_of_authoringbecause these numbers move β Microsoft's own pages disagreed while this module was written, one listing 300 workspaces and another 3,000. The higher set is reported because the stated increase date has passed. The entry that constrains a design rather than a scale isscopes_per_virtual_network = 1: a network reaches exactly one scope, so that scope must contain everything the network needs.
13 Β· ποΈ End-to-end composition
provider "azurerm" {
features {}
}
module "observability_rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-observability-eastus2"
location = "eastus2"
tags = { workload = "platform", managed_by = "terraform" }
}
module "workspace" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-log-analytics-workspace.git?ref=v1.0.0"
name = "law-platform-eastus2"
location = module.observability_rg.location
resource_group_name = module.observability_rg.name
tags = { workload = "platform", managed_by = "terraform" }
}
module "app_insights" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-application-insights.git?ref=v1.0.0"
name = "appi-shop-eastus2"
location = module.observability_rg.location
resource_group_name = module.observability_rg.name
application_type = "web"
# The workspace's ARM Resource ID, not the workspace_id GUID the same module also emits.
workspace_id = module.workspace.id
tags = { workload = "platform", managed_by = "terraform" }
}
module "private_link_scope" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scope.git?ref=v1.0.0"
name = "ampls-platform"
resource_group_name = module.observability_rg.name
# Both access modes default to PrivateOnly in that module. That is the correct default,
# and it is exactly what makes a missing membership below expensive.
tags = { workload = "platform", managed_by = "terraform" }
}
locals {
# Everything that must stay reachable once the scope is PrivateOnly.
monitored = {
law_platform = module.workspace.id
appi_shop = module.app_insights.id
}
}
module "scope_members" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-private-link-scoped-service.git?ref=v1.0.0"
for_each = local.monitored
name = "amplssvc-${each.key}"
resource_group_name = module.private_link_scope.resource_group_name
scope_name = module.private_link_scope.name
linked_resource_id = each.value
}
module "monitor_endpoint" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-private-endpoint.git?ref=v1.0.0"
name = "pe-ampls-platform"
location = module.observability_rg.location
resource_group_name = module.observability_rg.name
subnet_id = var.private_endpoint_subnet_id
private_service_connection = {
name = "psc-ampls-platform"
private_connection_resource_id = module.private_link_scope.id
subresource_names = ["azuremonitor"]
is_manual_connection = false
}
tags = { workload = "platform", managed_by = "terraform" }
}
check "every_monitored_resource_is_in_the_scope" {
assert {
condition = length(module.scope_members) == length(local.monitored)
error_message = "A resource in the monitored inventory has no membership in the private link scope, so it will be unreachable once the scope is PrivateOnly."
}
}
check "memberships_and_scope_share_a_resource_group" {
assert {
condition = alltrue([
for k, m in module.scope_members : !m.links_across_resource_groups
])
error_message = "A membership links a resource from a different resource group. Legal, but unintended in this estate."
}
}
output "private_monitoring_posture" {
value = {
scope_id = module.private_link_scope.id
ingestion_access = module.private_link_scope.ingestion_access_mode
query_access = module.private_link_scope.query_access_mode
member_count = length(module.scope_members)
member_types = { for k, m in module.scope_members : k => m.linked_resource_type }
endpoint_id = module.monitor_endpoint.id
omission_is_the_risk = values(module.scope_members)[0].omitting_a_resource_from_the_scope_blocks_it_when_the_scope_is_private_only
}
}ποΈ Read the ordering here, because Terraform's dependency direction runs opposite to Microsoft's advice. The memberships reference the scope, so the scope and its
PrivateOnlyaccess modes are created first and the members are added after β meaning a single apply passes through a state where the scope isPrivateOnlywith fewer members than intended. For a greenfield estate that window is harmless, because the private endpoint does not exist yet either. For an existing estate, follow Microsoft's sequence instead: add every membership while the scope is stillOpen, then tighten the access modes in a later apply.Note also that
module.workspace.idfeeds Application Insights, notmodule.workspace.workspace_idβ that module emits both, and only the first is an ARM Resource ID.
Required (4)
| Name | Type | Description |
|---|---|---|
name |
string |
Name of the association record. Force-new. |
resource_group_name |
string |
The scope's resource group. Force-new. |
scope_name |
string |
The scope's name, not its ID. Force-new. |
linked_resource_id |
string |
ARM Resource ID of one of three types. Force-new. |
Optional (1)
| Name | Type | Default | Description |
|---|---|---|---|
timeouts |
object(...) |
null |
create / read / delete only. No update. |
Full input schemas
variable "scope_name" {
type = string
# The private link scope's NAME. Rejected if a Resource ID is passed.
# 1-255 characters, must not end with a period. Immutable.
}
variable "resource_group_name" {
type = string
# The SCOPE's resource group, not the linked resource's. Immutable.
}
variable "linked_resource_id" {
type = string
# Exactly one of these three forms:
# /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.OperationalInsights/workspaces/<name>
# /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.Insights/components/<name>
# /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.Insights/dataCollectionEndpoints/<name>
# For a workspace, pass the module's id output - not its workspace_id GUID. Immutable.
}
variable "timeouts" {
type = object({
create = optional(string)
read = optional(string)
delete = optional(string)
})
default = null
# Three keys, matching the provider. There is no update operation on this resource.
}This resource supports neither
tagsnorlocation, so this module omits both. Tag the private link scope instead.
| Output | Description | Notes |
|---|---|---|
id |
Association Resource ID | Emitted first; nests under the scope's path |
name |
The association record's name | Not the linked resource's name |
scope_name |
The scope this belongs to | |
resource_group_name |
The scope's resource group | |
linked_resource_id |
The linked resource | |
linked_resource_type |
Which of the three types, in words | |
linked_resource_name |
Parsed from the ID | |
linked_resource_group_name |
The linked resource's resource group | Parsed |
linked_resource_subscription_id |
Parsed from the ID | |
links_across_resource_groups |
Whether the two resource groups differ | Legal; assertable |
crossing_subscriptions_is_not_determinable_here |
Constant true |
An honest limit |
every_argument_is_force_new |
Constant true |
Change planning |
timeouts_has_no_update_key_because_nothing_can_be_updated |
Constant true |
|
omitting_a_resource_from_the_scope_blocks_it_when_the_scope_is_private_only |
Constant true |
Read this one |
one_module_instance_cannot_see_the_scope_s_other_memberships |
Constant true |
Composition design |
ampls_limits_as_of_authoring |
Published limits | A snapshot, not a check |
accepts_no_credential |
Constant true |
No secret is emitted. Nothing here is marked
sensitive, because nothing here is a secret.
The risk is an absence. Everything else in this library guards against a resource being configured wrongly.
This one guards against a resource not being configured at all. A scope in PrivateOnly mode blocks Azure
Monitor resources that are not in it β across every network sharing the same DNS, regardless of subscription
or tenant. So the failure is a membership nobody created, and no plan of this module can show you that.
one_module_instance_cannot_see_the_scope_s_other_memberships states the limit, and the for_each examples
show where the real check belongs: an explicit inventory in the composition that owns the scope.
A correct default that raises the stakes. The scope module in this suite defaults both access modes to
PrivateOnly, which is right. It also means the strictest configuration is the one you get without asking,
which makes a forgotten membership expensive rather than merely untidy. That is worth knowing about a secure
default rather than being surprised by it.
Terraform's ordering is the reverse of Microsoft's advice. Microsoft's documented sequence for an existing
estate is to add every Azure Monitor resource to the scope first and switch to PrivateOnly afterwards. But a
membership references its scope, so Terraform must create the scope β with whatever access modes it was
declared with β before any membership exists. A single apply therefore passes through a state where the scope is
PrivateOnly and incomplete. Greenfield estates are unaffected because no private endpoint exists yet; existing
estates should tighten the access modes in a separate, later apply.
Total immutability, and its evidence. All four arguments force replacement, so this record supports no
in-place change at all. The provider's timeouts block confirms it by offering only create, read and
delete β the missing update is a signal, not an oversight, and this module's type matches it exactly. That
matters because object type conversion drops undeclared keys silently: a four-key timeouts would give a
caller an update timeout that appears configured and does nothing.
The two fields that want opposite forms. scope_name needs a name; linked_resource_id, immediately below
it, needs a full Resource ID. The scope module emits both name and id, so the wrong one is always in reach.
Both directions are rejected at plan time with a message naming the other field, because Azure's own error names
neither.
One resource group belongs to something else. resource_group_name here identifies the scope, not the
linked resource β the two are frequently different, the argument name gives no hint, and the provider's example
uses one resource group throughout, which hides it. The linked resource's own resource group and subscription
are parsed out of its ID and emitted so the distinction is visible in a plan rather than discovered at apply.
Membership is not connectivity. A membership brings a resource inside the scope. Reaching it privately also needs a private endpoint attached to the scope and the private DNS zones that endpoint requires. Neither is this module's concern, and neither is implied by a successful apply here.
| Concern | This module's behaviour | Opt-out |
|---|---|---|
| Scope reference form | the scope's name required; an ID is rejected | none |
| Linked resource form | a full ARM ID required; a bare name is rejected | none |
| Linked resource type | exactly three types accepted | none; no other type can be added |
| Immutability | all four arguments force-new, and it is emitted | none; this is the resource |
timeouts shape |
three keys, matching the provider exactly | none |
| Secrets | none accepted, none emitted | none |
timeouts.
terraform init -backend=false
terraform validate
terraform fmt -checkPin the module with ?ref=v1.0.0 β never a branch. This module is plan-only in CI; a human applies.
terraform validate proves the configuration parses and every type resolves; terraform fmt -check proves the
formatting. Neither runs a variable validation block on this module as a called module β but
terraform console does, when this directory is the root:
echo 'null' | terraform console -var-file=bad.tfvarsAll 11 validations were proven to fire this way, identified by line number rather than message text. One
fixture necessarily trips two checks: a bare name in linked_resource_id fails both the "must start with
/subscriptions/" check and the three-type check, since a name satisfies neither. The broad check supplies the
pointed message; the narrow one is isolated with a real Resource ID of a rejected type.
Seven good fixtures were confirmed to fire nothing β one for each accepted linked type, a lower-cased
provider namespace (which the provider suppresses and this module must not reject), a 255-character scope name,
a scope name containing legal punctuation, and a full three-key timeouts block.
The ID parsing was verified against all three linked types rather than assumed, because an off-by-one in segment arithmetic yields a plausible wrong answer that passes every other check.
What only a real plan or apply can exercise: whether the scope exists, whether the linked resource exists,
whether the scope already holds this resource, and β the thing no tooling can tell you β whether the scope holds
everything it needs to.
id = "/subscriptions/.../resourceGroups/rg-observability-eastus2/providers/Microsoft.Insights/privateLinkScopes/ampls-platform/scopedResources/amplssvc-law-platform"
name = "amplssvc-law-platform"
scope_name = "ampls-platform"
resource_group_name = "rg-observability-eastus2"
linked_resource_type = "Log Analytics workspace"
linked_resource_name = "law-platform-eastus2"
linked_resource_group_name = "rg-observability-eastus2"
linked_resource_subscription_id = "00000000-0000-0000-0000-000000000000"
links_across_resource_groups = false
every_argument_is_force_new = true
timeouts_has_no_update_key_because_nothing_can_be_updated = true
omitting_a_resource_from_the_scope_blocks_it_when_the_scope_is_private_only = true
one_module_instance_cannot_see_the_scope_s_other_memberships = true
ampls_limits_as_of_authoring = {
"application_insights_components_per_scope" = 10000
"log_analytics_workspaces_per_scope" = 3000
"private_endpoints_per_scope" = 10
"scopes_per_azure_monitor_resource" = 100
"scopes_per_virtual_network" = 1
}
accepts_no_credential = true
| Symptom | Cause | Fix |
|---|---|---|
| A workspace became unreachable after tightening the scope | It has no membership, and the scope is PrivateOnly |
Add a membership for it; see the for_each inventory example |
| Everything in one subscription broke, including other teams' resources | PrivateOnly blocks non-members across every network sharing the DNS, in any subscription or tenant |
Add the missing members, or set the scope back to Open until the inventory is complete |
| Apply fails naming neither field | The scope's ID was passed to scope_name, or a bare name to linked_resource_id |
The module now rejects both at plan time |
| Apply fails with three validation errors at once | linked_resource_id is not one of the three accepted types |
Pass a workspace, component, or Data Collection Endpoint ID |
linked_resource_group_name differs from resource_group_name unexpectedly |
resource_group_name is the scope's |
Check links_across_resource_groups; correct whichever value is wrong |
| A workspace was added but agents still cannot send data | The Data Collection Endpoint is not in the scope | Add a membership for the DCE too |
| The membership exists but nothing resolves privately | No private endpoint on the scope, or missing private DNS zones | Attach a private endpoint to the scope and create its DNS zones |
An update timeout appears to do nothing |
There is no update operation; the key was silently discarded |
Remove it; use create, read or delete |
| Any argument change destroys the record | All four are force-new | Expected; add the replacement membership before removing the old one |
| Portal monitoring views fail over the private link | Portal and extension traffic needs its own service tags | Allow AzureActiveDirectory, AzureResourceManager, AzureFrontDoor.FirstParty and AzureFrontdoor.Frontend |
| Private endpoint will not fit the subnet | Azure Monitor private link needs 11 addresses beyond Azure's 5 reserved | Use a /27 or larger |
| A second scope cannot be attached to the same network | A virtual network connects to exactly one scope | Consolidate into one scope; see ampls_limits_as_of_authoring |
azurerm_monitor_private_link_scoped_service- Use Azure Private Link to connect networks to Azure Monitor
- Design Azure Monitor private link configuration
- Configure private link for Azure Monitor
- Azure Monitor service limits
- Sibling modules:
terraform-azurerm-monitor-private-link-scope,terraform-azurerm-log-analytics-workspace,terraform-azurerm-application-insights,terraform-azurerm-monitor-data-collection-endpoint,terraform-azurerm-private-endpoint,terraform-azurerm-private-dns-zone - This module's
SCOPE.md
π "Infrastructure as Code should be standardized, consistent, and secure."