Creates and manages an Azure network security perimeter — the logical, default-deny isolation boundary that groups PaaS resources into one governed trust zone. Targets
hashicorp/azurerm ~> 4.0.
- 🛡️ Provisions one
azurerm_network_security_perimeter— the boundary that wraps PaaS resources (Storage, Key Vault, SQL, and similar) in a single logical isolation zone. - 🚧 Creates the perimeter object only. It has no members and enforces nothing until a profile, access rules and resource associations exist -- and a new association's platform default access mode is Transition, which falls back to the resource's own firewall. Default-deny is a property of Enforced associations, not of the perimeter: once a resource is associated in enforced mode, only explicit access rules permit traffic in or out.
- 🧱 Owns only the boundary itself. Profiles, access rules, and resource associations are separate sibling modules that attach to this perimeter by
id, so the boundary is versioned and reasoned about independently. - 🏷️ Carries the universal
tagsandtimeoutstail and emits the perimeteridfirst for downstream wiring. - 🔒 Exposes no relaxation knob — the resource has none; every relaxation is expressed as an explicit access rule in the sibling access-rule module.
💡 Why it matters: A network security perimeter is a stronger control than per-resource firewalls. Per-resource firewalls are configured independently and drift apart; a perimeter enforces one boundary policy across every associated resource, and it enforces nothing on its own -- only an association in
Enforcedmode denies anything. Modeling the boundary as its own module lets a platform stand up the trust zone first, then layer the traffic policy on top through composable siblings.
If this module saves you time, please consider supporting its continued development:
- ⭐ Star the repository on GitHub.
- 🤝 Connect on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
flowchart TD
rg["terraform-azurerm-resource-group"]
nsp["terraform-azurerm-network-security-perimeter (this module)"]
profile["terraform-azurerm-network-security-perimeter-profile (sibling)"]
rule["terraform-azurerm-network-security-perimeter-access-rule (sibling)"]
assoc["terraform-azurerm-network-security-perimeter-association (sibling)"]
paas["Protected PaaS resources (Storage / Key Vault / SQL)"]
rg -->|"resource_group_name + location"| nsp
nsp -->|"perimeter id"| profile
profile -->|"profile id"| rule
profile -->|"profile id"| assoc
assoc -->|"associates resource id (Learning or Enforced)"| paas
classDef me fill:#0078D4,stroke:#004578,color:#fff;
classDef key fill:#004578,stroke:#002a4a,color:#fff;
classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b2733;
class nsp me;
class paas key;
class rg,profile,rule,assoc sib;
The resource group supplies resource_group_name and location. This module creates the perimeter and emits its id. Sibling modules consume that id to attach a profile, then attach inbound/outbound access rules and resource associations that bring protected PaaS resources under the boundary.
flowchart LR
subgraph inputs["Inputs"]
n["name (force-new)"]
rgn["resource_group_name (force-new)"]
loc["location (force-new)"]
tg["tags"]
end
nsp["azurerm_network_security_perimeter.this"]
subgraph outputs["Outputs"]
oid["id (emitted first)"]
onm["name"]
end
subgraph siblings["Governed by sibling modules (attach by id)"]
profile["profile"]
rule["access rule (inbound / outbound)"]
assoc["association (Learning / Enforced)"]
end
n --> nsp
rgn --> nsp
loc --> nsp
tg --> nsp
nsp --> oid
nsp --> onm
oid -->|"network_security_perimeter_id"| profile
profile -->|"profile id"| rule
profile -->|"profile id"| assoc
classDef key fill:#004578,stroke:#002a4a,color:#fff;
classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b2733;
class nsp key;
class profile,rule,assoc sib;
Resource inventory
| Resource | Count | Role |
|---|---|---|
azurerm_network_security_perimeter.this |
1 (keystone) | The isolation boundary. Carries name, resource_group_name, location, tags, and an optional timeouts block. |
Profiles, access rules, and associations are not created here — they are separate catalog modules wired to this perimeter's id.
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
| azurerm provider | ~> 4.0 |
| Provider block | None in this module — the caller configures provider "azurerm" { features {} }, auth, and subscription. |
Schema notes that bite (verified against the live provider schema):
name,resource_group_name, andlocationare effectively immutable. Changing any of them forces replacement of the perimeter, which fails: the provider never requestsforceDeletion, so Azure refuses to delete a perimeter that still has child associations and the apply errors out rather than detaching anything.- The perimeter resource carries only
name,resource_group_name,location, andtags. There is no public-access, TLS, or encryption argument on the boundary itself — those concerns are governed by profiles, access rules, and associations (sibling modules), not by this resource. - Network security perimeter availability varies by region. Confirm the target region supports it before applying.
- The provider will not initialize without a caller-side
features {}block; that is expected and belongs to the root module.
Network Contributoron the target resource group, or a custom role grantingMicrosoft.Network/networkSecurityPerimeters/*, scoped to the resource group (least privilege at the smallest scope that works).
- An existing resource group in a network-security-perimeter-supported US Azure region.
- The
Microsoft.Networkresource provider registered on the target subscription. - The protected PaaS resources already exist, so their
ids are available for the sibling association module. - The caller configures the
provider "azurerm" { features {} }block, auth, and subscription; the module declares none of these.
terraform-azurerm-network-security-perimeter/
├── providers.tf # required_version >= 1.12.0; azurerm ~> 4.0; no provider block
├── variables.tf # name, resource_group_name, location + tags/timeouts tail
├── main.tf # keystone azurerm_network_security_perimeter.this + dynamic timeouts
├── outputs.tf # id first, then name, resource_group_name, location
├── README.md # this document
├── SCOPE.md # the cross-module contract
├── LICENSE # MIT
└── .gitignore # canonical library ignore set
The caller configures the provider, authentication, and the mandatory features {} block; the module never does.
provider "azurerm" {
features {}
}
module "perimeter" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-security-perimeter.git?ref=v1.0.0"
name = "nsp-data-platform"
resource_group_name = "rg-security-eastus"
location = "eastus"
tags = {
environment = "production"
owner = "platform-security"
}
}ℹ️ Pin
?ref=v1.0.0— never a branch — so a consuming composition is reproducible.
Consumes
| Input | Type | Source |
|---|---|---|
resource_group_name |
string |
terraform-azurerm-resource-group (name) |
location |
string |
caller / terraform-azurerm-resource-group (location) |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
Network security perimeter Resource ID (first) | profile / association / access-rule / diagnostics / role-assignment modules |
name |
Perimeter name | diagnostics / tagging |
resource_group_name |
Containing resource group | sibling modules |
location |
Azure region | sibling modules |
The examples below reference existing resources by ID or name rather than creating them; this module owns only its own resource. Those references are declared inputs:
variable "log_analytics_id" {
description = "id of an existing log analytics that these examples reference but do not create."
type = string
}1 · Minimal perimeter (the secure base)
The smallest real call. The empty configuration already produces a default-deny boundary — there is no exposure knob to turn off.
module "perimeter" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-security-perimeter.git?ref=v1.0.0"
name = "nsp-core"
resource_group_name = "rg-security-eastus"
location = "eastus"
}🔒 A perimeter with no associations governs nothing yet; it becomes an active control once a resource is associated (see examples 9–11).
2 · Perimeter with governance tags
Tags flow straight through to the boundary for cost and ownership reporting.
module "perimeter" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-security-perimeter.git?ref=v1.0.0"
name = "nsp-finance"
resource_group_name = "rg-security-eastus"
location = "eastus"
tags = {
environment = "production"
data_class = "confidential"
cost_center = "cc-4820"
owner = "platform-security"
}
}💡 Adopt a consistent tag set across every perimeter so the trust zones are attributable in cost and compliance reports.
3 · Custom operation timeouts
Supply create/read/update/delete timeouts when perimeter operations run long in a busy subscription.
module "perimeter" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-security-perimeter.git?ref=v1.0.0"
name = "nsp-core"
resource_group_name = "rg-security-eastus"
location = "eastus"
timeouts = {
create = "30m"
delete = "30m"
}
}ℹ️ Only the timeout fields you set are applied; omitted fields fall back to the provider defaults.
4 · Many perimeters at scale with for_each
Drive a fleet of trust zones from a single keyed map. Because the keys are stable, adding or removing one perimeter never re-indexes the rest.
locals {
perimeters = {
data = { name = "nsp-data", location = "eastus" }
analytics = { name = "nsp-analytics", location = "eastus2" }
edge = { name = "nsp-edge", location = "westus2" }
}
}
module "perimeter" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-security-perimeter.git?ref=v1.0.0"
for_each = local.perimeters
name = each.value.name
resource_group_name = "rg-security-${each.value.location}"
location = each.value.location
tags = { workload = each.key }
}💡 Use a meaningful, stable map key (
data,analytics,edge) rather than an index so the plan stays surgical.
5 · One perimeter per environment
Keep production, staging, and development boundaries fully separate.
variable "environment" {
type = string
}
module "perimeter" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-security-perimeter.git?ref=v1.0.0"
name = "nsp-${var.environment}"
resource_group_name = "rg-security-${var.environment}"
location = "centralus"
tags = { environment = var.environment }
}
⚠️ name,resource_group_name, andlocationare force-new. Renaming a perimeter to fold environments together replaces it and detaches everything associated with it.
6 · Attaching a profile (sibling module)
A profile is the container for access rules and associations. It is a separate module that consumes this perimeter's id.
module "perimeter" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-security-perimeter.git?ref=v1.0.0"
name = "nsp-data-platform"
resource_group_name = "rg-security-eastus"
location = "eastus"
}
resource "azurerm_network_security_perimeter_profile" "perimeter_profile" {
name = "default"
network_security_perimeter_id = module.perimeter.id
}ℹ️ This module deliberately does not own profiles. Keeping them separate lets one perimeter carry several profiles that evolve on their own cadence.
7 · Adding an inbound access rule (sibling module)
Access rules attach to a profile, not to the perimeter directly. An inbound rule permits traffic into the boundary from named sources.
resource "azurerm_network_security_perimeter_access_rule" "perimeter_inbound_rule" {
direction = "Inbound"
name = "allow-corp-ingress"
network_security_perimeter_profile_id = azurerm_network_security_perimeter_profile.perimeter_profile.id
address_prefixes = ["203.0.113.0/24"]
}🔒 The perimeter denies by default. Every inbound rule is an explicit, reviewable exception scoped to specific prefixes, subscriptions, or service tags.
8 · Adding an outbound access rule (sibling module)
An outbound rule permits traffic leaving the boundary to named destinations (for example approved FQDNs).
resource "azurerm_network_security_perimeter_access_rule" "perimeter_outbound_rule" {
direction = "Outbound"
name = "allow-approved-egress"
network_security_perimeter_profile_id = azurerm_network_security_perimeter_profile.perimeter_profile.id
fqdns = ["updates.example.com", "telemetry.example.com"]
}
⚠️ Egress from a hardened trust zone should be minimal. List only the destinations a workload genuinely needs.
9 · Associating a resource in Learning mode (sibling module)
An association brings a protected resource under the perimeter. Learning mode logs what would be blocked without enforcing — use it to validate rules before cutover.
resource "azurerm_network_security_perimeter_association" "perimeter_association" {
access_mode = "Learning"
name = "assoc-storage-data"
network_security_perimeter_profile_id = azurerm_network_security_perimeter_profile.perimeter_profile.id
resource_id = module.storage.id
}💡 Start in Learning mode, review the diagnostic logs the perimeter emits, then move to Enforced once the access rules cover legitimate traffic.
10 · Associating a resource in Enforced mode (sibling module)
Once validated, switch the association to Enforced so the default-deny boundary is actively applied.
resource "azurerm_network_security_perimeter_association" "perimeter_association" {
access_mode = "Enforced"
name = "assoc-storage-data"
network_security_perimeter_profile_id = azurerm_network_security_perimeter_profile.perimeter_profile.id
resource_id = module.storage.id
}🔒 In Enforced mode the perimeter denies everything not permitted by an explicit access rule. Confirm your rules are complete before flipping this, or legitimate traffic will be blocked.
11 · Learning-to-Enforced rollout across many resources
Roll a fleet of resources through the same lifecycle by parameterizing the association mode.
variable "perimeter_mode" {
type = string
default = "Learning" # promote to "Enforced" after review
}
resource "azurerm_network_security_perimeter_association" "perimeter_associations" {
access_mode = var.perimeter_mode
name = "assoc-${each.key}"
network_security_perimeter_profile_id = azurerm_network_security_perimeter_profile.perimeter_profile.id
resource_id = each.value.id
}💡 Flip a single variable to promote the entire trust zone from observation to enforcement in one reviewed change.
12 · Wiring diagnostics to a Log Analytics workspace
Send perimeter platform logs to a workspace using the diagnostic-setting sibling module and this perimeter's id.
module "perimeter_diagnostics" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-diagnostic-setting.git?ref=v1.0.0"
name = "diag-nsp-core"
target_resource_id = module.perimeter.id
log_analytics_workspace_id = var.log_analytics_id
}💡 Diagnostics are the feedback loop for Learning mode: the logs show exactly what an Enforced perimeter would block.
13 · Assigning RBAC at the perimeter scope
Grant a team management rights over the boundary using the role-assignments sibling module at this perimeter's id.
module "perimeter_rbac" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"
scope = module.perimeter.id
role_assignments = {
net_admins = {
role_definition_name = "Network Contributor"
principal_id = var.network_admins_group_object_id
}
}
}🔒 Scope role assignments to the perimeter
id, not to the whole subscription, so boundary management stays least-privilege.
14 · 🏗️ End-to-end composition (resource group + perimeter + profile + rules + association protecting a storage account)
A complete trust zone: a resource group, a storage account, this perimeter, a profile, one inbound rule, and an enforced association that brings the storage account under the boundary.
provider "azurerm" {
features {}
}
module "resource_group" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-data-platform-eastus"
location = "eastus"
}
module "storage" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account.git?ref=v1.0.0"
name = "stdataplatform01"
resource_group_name = module.resource_group.name
location = module.resource_group.location
}
module "perimeter" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-security-perimeter.git?ref=v1.0.0"
name = "nsp-data-platform"
resource_group_name = module.resource_group.name
location = module.resource_group.location
tags = { environment = "production", data_class = "confidential" }
}
resource "azurerm_network_security_perimeter_profile" "perimeter_profile" {
name = "default"
network_security_perimeter_id = module.perimeter.id
}
resource "azurerm_network_security_perimeter_access_rule" "perimeter_inbound_rule" {
direction = "Inbound"
name = "allow-corp-ingress"
network_security_perimeter_profile_id = azurerm_network_security_perimeter_profile.perimeter_profile.id
address_prefixes = ["203.0.113.0/24"]
}
resource "azurerm_network_security_perimeter_association" "perimeter_association" {
access_mode = "Enforced"
name = "assoc-storage-data"
network_security_perimeter_profile_id = azurerm_network_security_perimeter_profile.perimeter_profile.id
resource_id = module.storage.id
}🔒 The storage account is now governed by a default-deny boundary: only the corporate ingress prefix reaches it, and every other path is denied at the perimeter regardless of the account's own firewall.
Required
| Name | Type | Description |
|---|---|---|
name |
string |
Perimeter name, unique within the resource group. Force-new. |
resource_group_name |
string |
Existing resource group that will contain the perimeter. Force-new. |
location |
string |
Azure region for the perimeter. Force-new. |
Optional (universal tail)
| Name | Type | Default | Description |
|---|---|---|---|
tags |
map(string) |
{} |
Tags applied to the perimeter. |
timeouts |
object(...) |
null |
Optional create/read/update/delete timeouts. |
Full variable schemas
variable "name" {
type = string
# Immutable: changing this forces replacement of the perimeter.
}
variable "resource_group_name" {
type = string
# Existing resource group; the module does not create it. Immutable / force-new.
}
variable "location" {
type = string
# Region availability for network security perimeter varies; confirm support. Immutable / force-new.
}
variable "tags" {
type = map(string)
default = {}
}
variable "timeouts" {
type = object({
create = optional(string)
read = optional(string)
update = optional(string)
delete = optional(string)
})
default = null
}| Output | Description | Kind |
|---|---|---|
id |
Azure Resource ID of the network security perimeter | Passthrough |
name |
Name of the perimeter, as created | Passthrough |
resource_group_name |
Resource group containing the perimeter | Passthrough |
location |
Azure region of the perimeter, read back from Azure in normalised form | Passthrough |
tags |
Tags in force on the perimeter, read back from Azure | Passthrough |
perimeter_alone_enforces_nothing |
Constant true, and the single most important fact about this module | Constant |
enforcement_is_decided_by_each_association_not_by_this_resource |
Constant true | Constant |
default_association_access_mode_blocks_nothing |
Constant true, and the trap this module exists to warn about | Constant |
enforced_is_the_only_authoritative_access_mode |
Constant true, stated positively so a review is not reading a double negative | Constant |
audit_access_mode_is_accepted_by_the_api_but_undocumented |
Constant true, recorded because the two sources disagree | Constant |
private_endpoint_traffic_bypasses_the_perimeter |
Constant true, and a genuine hole in any threat model built on this boundary | Constant |
trusted_service_exceptions_are_not_honoured_in_enforced_mode |
Constant true, and the usual cause of breakage at cutover | Constant |
service_endpoint_traffic_may_be_denied_despite_an_allow_all_rule |
Constant true | Constant |
sas_token_authentication_is_rejected_on_some_paths |
Constant true | Constant |
destroy_never_requests_force_deletion |
Constant true | Constant |
removing_an_association_can_lock_the_resource_out |
Constant true, and the reason a rollback is not free | Constant |
only_tags_update_in_place |
Constant true | Constant |
force_new_fields |
The inputs that force replacement rather than an in-place update | Derived |
replacement_is_a_new_boundary_not_a_rename |
Constant true | Constant |
perimeter_guid_is_not_exposed_by_this_resource |
Constant true | Constant |
sibling_child_ids_are_built_in_the_providers_subscription |
Constant true, and a cross-subscription trap | Constant |
sentinel_enabled_workspaces_silently_lose_their_analytics_rules |
Constant true, and the most damaging silent interaction documented for this feature | Constant |
azure_backup_is_unsupported_for_perimeter_member_storage_accounts |
Constant true | Constant |
generally_available_in_every_azure_public_cloud_region |
Constant true, recorded because the opposite is widely assumed and was true until recently | Constant |
some_member_services_are_still_in_public_preview |
Constant true, and the qualifier the GA headline hides | Constant |
documented_scale_limits |
Microsoft's published scale limits for network security perimeter, none of which is visible in state or enforceable by this module | Derived |
creates_no_profile_association_or_access_rule |
Constant true | Constant |
No secret is emitted; the perimeter resource has no secret-bearing attributes.
- The boundary is the whole module. The
azurerm_network_security_perimeterresource carries onlyname,resource_group_name,location, andtags. There is no traffic control on the resource itself — inbound/outbound behavior is set by the sibling profile and access-rule resources, and whether a resource is observed or enforced is set by the sibling association's access mode. This module owns the boundary; the traffic graph is composed on top. - Force-new identity fields.
name,resource_group_name, andlocationcannot be updated in place. Changing any of them replaces the perimeter, which detaches every associated resource and drops attached profiles and access rules. Treat a rename as a migration, not an edit. for_each, nevercount, for fleets. When standing up multiple perimeters, drive them from a keyed map (example 4) so a stable key means adding or removing one boundary never re-indexes the others.- Optional
timeoutsrenders only when set. Thetimeoutsblock is emitted through adynamicblock guarded on a non-null value, withtry(...)on each field, so an omitted timeout is absent rather than an error. features {}is the caller's. If the configuration appears not to initialize in isolation, the cause is a missing caller-sideprovider "azurerm" { features {} }block. Library modules never carry it.
The perimeter is itself the security control, so its secure posture is structural rather than a set of toggles.
| Concern | Secure default (empty call) | Opt-out |
|---|---|---|
| Cross-perimeter traffic | Default-deny — an enforced perimeter permits only what an explicit access rule allows | Add explicit inbound/outbound access rules (sibling module) |
| Enforcement posture | Recommend Enforced mode for associations once rules are validated | Use Learning mode to observe without enforcing during rollout |
| Boundary exposure knobs | None on the resource — there is no public-access or TLS argument to leave open | n/a — relaxations are explicit access rules, not resource flags |
| Secrets | The resource has no secret-bearing attributes; none are accepted or emitted | n/a |
🔒 Enforced mode over Learning mode is the recommended end state. Learning mode is a rollout aid, not a resting posture.
terraform init -backend=false
terraform validate
terraform fmt -check- Pin the module at
?ref=v1.0.0— never a branch — so consuming compositions are reproducible. - This is plan-only during authoring. A human runs
terraform planandterraform applyfrom CI against real credentials.
The offline proof gate runs without any cloud call:
terraform init -backend=falseresolves the pinned provider without configuring a backend.terraform validateproves the configuration is internally consistent and type-correct against the pinned provider schema — it surfaces every typing mistake the variable schemas are designed to catch.terraform fmt -checkenforces canonical formatting.
Neither validate nor fmt contacts Azure. Only terraform plan (run by a human from CI) exercises the ARM API and confirms region availability, RBAC, and resource-provider registration.
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Outputs:
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-security-eastus/providers/Microsoft.Network/networkSecurityPerimeters/nsp-data-platform"
location = "eastus"
name = "nsp-data-platform"
resource_group_name = "rg-security-eastus"
| Symptom | Cause | Fix |
|---|---|---|
Error: Insufficient features blocks on init/plan |
The caller's root module has no provider "azurerm" { features {} }. |
Add the features {} block to the root provider — it is a caller concern, never the module's. |
| Plan shows the perimeter being replaced after a small edit | You changed name, resource_group_name, or location — all force-new. |
Treat the change as a migration; expect associations, profiles, and rules to detach and be recreated by their sibling modules. |
The resource type is not available in the location |
The chosen region does not support network security perimeter. | Choose a supported region; confirm availability before applying. |
| Legitimate traffic blocked after enabling the boundary | The association is in Enforced mode but the access rules do not cover that traffic. | Add the required inbound/outbound access rule, or move the association to Learning mode while you finalize rules. |
AuthorizationFailed creating the perimeter |
The caller identity lacks perimeter write permission on the resource group. | Grant Network Contributor (or Microsoft.Network/networkSecurityPerimeters/*) at the resource-group scope. |
- Provider resource:
azurerm_network_security_perimeter - Sibling modules:
terraform-azurerm-network-security-perimeter-profile,terraform-azurerm-network-security-perimeter-access-rule,terraform-azurerm-network-security-perimeter-association,terraform-azurerm-resource-group,terraform-azurerm-monitor-diagnostic-setting,terraform-azurerm-role-assignments - This module's
SCOPE.md— the cross-module contract.
💙 "Infrastructure as Code should be standardized, consistent, and secure."