Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

☁️ Azure Network Interface NAT Rule Association Terraform Module

Attaches one named IP configuration of a network interface to a load balancer's inbound NAT rule β€” forwarding one frontend port straight to this one machine. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Caveat


🧩 Overview

  • ➑️ Attaches one named IP configuration to a load balancer's inbound NAT rule.
  • 🎯 Modifies exactly that configuration β€” the sibling application-security-group association writes itself into every one instead.
  • 🧾 Emits a ready-to-paste import_id whose first half is an IP configuration ID, not the interface's.
  • πŸšͺ Names what the change does: a NAT rule forwards one frontend port to one machine, so this is a direct route rather than a share of balanced traffic.
  • ⚠️ Names what it does not decide: whether anything can traverse that route is set by network security group rules this module cannot see.

πŸ’‘ Why it matters: Azure has no association object here. The provider reads the network interface, attaches the rule to one IP configuration, and writes the interface back β€” so the permission required is write on the interface, a concurrent writer can silently drop the attachment, and the composite Terraform ID is not the shape most people guess.


❀️ Support this project

If this module saved you time:


πŸ—ΊοΈ Where this fits in the family

flowchart TB
    NIC["terraform-azurerm-network-interface"]
    LB["terraform-azurerm-load-balancer -- owns the inbound NAT rule"]
    ASSOC["terraform-azurerm-network-interface-nat-rule-association"]
    IPCFG["ONE named ip configuration -- not all of them"]
    POOLA["terraform-azurerm-network-interface-backend-address-pool-association -- balances instead of forwarding"]
    NSGA["terraform-azurerm-network-interface-security-group-association -- decides whether anything gets through"]

    NIC -->|"id to network_interface_id -- and this interface is what gets WRITTEN"| ASSOC
    LB -->|"inbound nat rule id to nat_rule_id"| ASSOC
    ASSOC -->|"attaches the rule to the configuration named in ip_configuration_name"| IPCFG
    IPCFG -->|"one frontend port now forwards directly to THIS machine"| LB
    POOLA -.->|"same shape, different target on the same load balancer"| ASSOC
    NSGA -.->|"its rules decide whether the forwarded port is reachable at all"| NIC

    classDef self fill:#0078D4,stroke:#004578,color:#ffffff
    classDef keystone fill:#004578,stroke:#002B4A,color:#ffffff
    classDef ext fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    class ASSOC self
    class NIC keystone
    class LB,IPCFG,POOLA,NSGA ext
Loading

Two edges matter beyond the obvious. The loop back to the interface is why the required permission is write on the whole interface. And the dotted edge to the security group association is the one people forget: a NAT rule creates a route, and the rule set attached to the interface decides whether anything can use it.


🧬 What this module builds

flowchart TB
    IN1["network_interface_id -- force-new, and THE OBJECT THAT IS WRITTEN"]
    IN2["ip_configuration_name -- a NAME, and only THIS configuration is attached"]
    IN3["nat_rule_id -- an INBOUND NAT RULE, not a backend address pool"]
    RES["azurerm_network_interface_nat_rule_association.this -- a rewrite, not a resource"]
    OUT1["a SYNTHETIC id whose FIRST HALF IS AN IP CONFIGURATION ID"]
    OUT2["import_id and ip_configuration_id, composed before the resource exists"]
    OUT3["spans_subscriptions and spans_resource_groups -- the region is NOT checkable"]
    OUT4["posture -- one frontend port to ONE machine, and a missing nic FAILS LOUDLY"]

    IN1 --> RES
    IN2 --> RES
    IN3 --> RES
    RES --> OUT1
    RES --> OUT2
    RES --> OUT3
    RES --> OUT4

    classDef self fill:#0078D4,stroke:#004578,color:#ffffff
    classDef ext fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    class RES self
    class IN1,IN2,IN3,OUT1,OUT2,OUT3,OUT4 ext
Loading
Resource Name Cardinality
azurerm_network_interface_nat_rule_association this single β€” an attachment inside one IP configuration, not an ARM object

All three arguments are force-new, so there is no update function and the timeouts type carries three keys.


βœ… Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None in this module β€” the caller configures provider "azurerm" { features {} }, including authentication

Schema notes that bite

  • πŸ”΄ Terraform's ID is a composite whose FIRST HALF IS AN IP CONFIGURATION ID, not the interface's: {networkInterfaceId}/ipConfigurations/{ipConfigurationName}|{natRuleId}. The provider's documentation confirms the format and says it is specific to Terraform.
  • πŸ”΄ This is not a resource in Azure. The provider rewrites the whole network interface, so the permission required is write on the interface.
  • πŸ”΄ A missing network interface FAILS the create, loudly. The sibling application-security-group association returns success in the same situation, having done nothing. Siblings in this directory disagree, and nothing in either schema reveals it.
  • πŸ”΄ A missing IP configuration also fails the create. It is not created for you, and the check happens at apply, because nothing in a Resource ID reveals which configurations an interface has.
  • πŸ”΄ A NAT rule targets ONE backend at a time. Attaching a second IP configuration to a rule that already has one is refused by Azure β€” not by the provider and not by this module β€” so that failure also arrives at apply.
  • 🟠 All three arguments are force-new, so moving to a different rule is a destroy and a create, with a window in between during which the port forwards nowhere.
  • 🟠 The read clears the ID by THREE routes: interface gone, IP configuration gone, or the rule no longer attached.
  • 🟑 ip_configuration_name is checked only for non-emptiness β€” no pattern, no length limit.
  • 🟑 The target must be an inbound NAT RULE, not a backend address pool. They differ by one path segment on the same load balancer.
  • 🟑 The provider locks only the network interface β€” not the load balancer, and not the rule.
  • βœ… The import guard's error names the COMPOSITE ID β€” the address you actually need.
  • βœ… No tags, no sensitive field, no credential.

πŸ”‘ Required Azure RBAC Roles / Permissions

Permission Scope Why
Microsoft.Network/networkInterfaces/read the network interface Read the current IP configurations.
Microsoft.Network/networkInterfaces/write the network interface Create and delete β€” both rewrite the interface.
Microsoft.Network/loadBalancers/inboundNatRules/join/action the inbound NAT rule Attaching an IP configuration to the rule.
Microsoft.Network/loadBalancers/read the load balancer May be in another subscription.

πŸ”’ This opens a direct route to one machine. An inbound NAT rule forwards a specific frontend port to a specific backend, so attaching it makes exactly this interface reachable on that port β€” typically for administrative access such as RDP or SSH. Removing the association closes the route just as directly.

⚠️ Whether anything can traverse the route is decided elsewhere. The network security group rules applying to this interface β€” and to its subnet β€” permit or deny the forwarded traffic, and this module can see neither. A NAT rule pointed at a machine with a permissive rule set is a path from the load balancer's frontend to that machine's port. Review both together.

ℹ️ There is no narrow permission for the write. networkInterfaces/write is the right to change any property of the interface, so whoever can attach a NAT rule can also change its subnet or its security group.


Azure Prerequisites

  • The Microsoft.Network resource provider registered in the target subscription.
  • πŸ”΄ The network interface must exist, and the named IP configuration must exist on it. Both are checked at apply, and both fail loudly.
  • πŸ”΄ The NAT rule must not already have a backend attached β€” one rule, one backend. Refused by Azure at apply.
  • The load balancer and the interface mutually reachable β€” same region and virtual network. Neither region is derivable from a Resource ID.
  • A frontend IP configuration and port on the rule, or the route leads nowhere useful.

πŸ“ Module Structure

terraform-azurerm-network-interface-nat-rule-association/
β”œβ”€β”€ providers.tf    # required_version >= 1.12.0, azurerm ~> 4.0, no provider block
β”œβ”€β”€ variables.tf    # 4 inputs, 4 validations -- the interface, the configuration name, the rule
β”œβ”€β”€ main.tf         # one keystone `this`; ID parsing from the END; the composed import address
β”œβ”€β”€ outputs.tf      # 32 outputs -- the synthetic id, the import id, then the reachability facts
β”œβ”€β”€ README.md       # this file
β”œβ”€β”€ SCOPE.md        # the cross-module contract
β”œβ”€β”€ LICENSE         # MIT
└── .gitignore

βš™οΈ Quick Start

provider "azurerm" {
  features {}
}

module "rdp_rule_attachment" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-interface-nat-rule-association.git?ref=v1.0.0"

  network_interface_id  = module.app_nic.id
  ip_configuration_name = "internal"
  nat_rule_id           = var.rdp_nat_rule_id
}

⚠️ That makes this machine reachable on the rule's frontend port. See example 2.


πŸ”Œ Cross-Module Contract

Consumes

Input Type Source
network_interface_id string terraform-azurerm-network-interface β†’ id
ip_configuration_name string the caller β€” a name, not an ID
nat_rule_id string terraform-azurerm-load-balancer β€” an inbound NAT rule ID
timeouts object(...) the caller β€” three keys

Emits

Output Description Consumed by
id Synthetic β€” the first half is an IP configuration ID reporting only
import_id The exact terraform import address operations
ip_configuration_id A real Resource ID for the configuration modified reporting
this_opens_a_direct_path_to_this_one_machine Constant true security review

πŸ“š Example Library

1 Β· The smallest real call
module "rdp_rule_attachment" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-interface-nat-rule-association.git?ref=v1.0.0"

  network_interface_id  = module.app_nic.id
  ip_configuration_name = "internal"
  nat_rule_id           = var.rdp_nat_rule_id
}

ℹ️ Three arguments, all force-new. There is no update function, and fields_that_can_change_after_creation returns an empty list.

⚠️ ip_configuration_name is a name β€” the name of a configuration that already exists on the interface. It is not an ID, and it is not created for you.

2 Β· A rule forwards one port to one machine
# On the load balancer: one frontend port, one backend.
#   frontend_port = 50001  ->  backend_port = 3389
module "rdp_rule_attachment" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-interface-nat-rule-association.git?ref=v1.0.0"

  network_interface_id  = module.app_nic.id
  ip_configuration_name = "internal"
  nat_rule_id           = var.rdp_nat_rule_id
}

πŸ”΄ This is the line that makes the machine reachable on that port. Unlike a backend address pool, which spreads traffic across whichever machines belong to it, an inbound NAT rule creates a direct route to exactly this interface β€” which is why it is usually reached for when someone needs administrative access to one instance.

⚠️ Removing the association closes the route just as directly. A plan line reading "1 to destroy" here is a machine losing its forwarded port.

πŸ’‘ this_opens_a_direct_path_to_this_one_machine is a constant true so the point appears in the plan, where the load balancer's rule definition does not.

3 Β· The route exists; the rules decide whether it works
module "rdp_rule_attachment" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-interface-nat-rule-association.git?ref=v1.0.0"

  network_interface_id  = module.app_nic.id
  ip_configuration_name = "internal"
  nat_rule_id           = var.rdp_nat_rule_id
}

# The rule set that decides whether anything reaches the forwarded port.
module "nic_security_group" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-interface-security-group-association.git?ref=v1.0.0"

  network_interface_id      = module.app_nic.id
  network_security_group_id = module.app_nsg.id
}

πŸ”’ A NAT rule creates a path; it permits nothing. What actually traverses the forwarded port is decided by the network security group rules applying to this interface β€” and to its subnet, which is evaluated as well. This module can see neither.

⚠️ Read the two together. A NAT rule for RDP pointed at a machine whose rule set allows 3389 from anywhere is a route from the load balancer's public frontend to that machine's console.

πŸ’‘ The two modules are shown side by side because that is the only honest way to read the change. In a real configuration they are frequently in different files.

4 Β· Only the named configuration is attached
module "app_nic" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-interface.git?ref=v1.0.0"

  name                = "nic-app01"
  resource_group_name = module.rg.name
  location            = module.rg.location

  ip_configurations = {
    internal = {
      subnet_id                     = module.app_vnet.subnet_ids["snet-app"]
      private_ip_address_allocation = "Dynamic"
      primary                       = true
    }
    management = {
      subnet_id                     = module.app_vnet.subnet_ids["snet-mgmt"]
      private_ip_address_allocation = "Dynamic"
    }
  }
}

module "rdp_rule_attachment" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-interface-nat-rule-association.git?ref=v1.0.0"

  network_interface_id  = module.app_nic.id
  ip_configuration_name = "management" # ONLY this one gets the forwarded port
  nat_rule_id           = var.rdp_nat_rule_id
}

πŸ’‘ This is the structural difference from the sibling application-security-group association, which has no ip_configuration_name argument and writes itself into every configuration on the interface.

πŸ”’ On a machine with a service address and a management address, naming the management configuration is exactly the point β€” the forwarded port reaches the address intended for it and not the other.

ℹ️ only_the_named_ip_configuration_is_modified is a constant true.

5 Β· The import address, and the half people get wrong
output "import_command" {
  value = "terraform import module.rdp_rule_attachment.azurerm_network_interface_nat_rule_association.this '${module.rdp_rule_attachment.import_id}'"
}

output "the_configuration_being_modified" {
  # A REAL Azure Resource ID -- the interface's, with /ipConfigurations/<name>.
  value = module.rdp_rule_attachment.ip_configuration_id
}

πŸ”΄ The composite ID's first half is an IP CONFIGURATION ID, not the network interface's. The full shape is {networkInterfaceId}/ipConfigurations/{ipConfigurationName}|{natRuleId} β€” confirmed by the provider's own import documentation.

πŸ’‘ Both import_id and ip_configuration_id are emitted, and both derive from the inputs β€” so they are available before the resource exists, which is exactly when you need them if you are adopting an attachment someone made in the portal.

⚠️ Quote it in a shell. The pipe separator is a metacharacter.

6 Β· A NAT rule takes one backend, and this is not the check that stops you
module "rdp_first_machine" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-interface-nat-rule-association.git?ref=v1.0.0"

  network_interface_id  = var.first_nic_id
  ip_configuration_name = "internal"
  nat_rule_id           = var.rdp_nat_rule_id
}

# WRONG -- the same rule cannot serve a second machine.
module "rdp_second_machine" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-interface-nat-rule-association.git?ref=v1.0.0"

  network_interface_id  = var.second_nic_id
  ip_configuration_name = "internal"
  nat_rule_id           = var.rdp_nat_rule_id # <-- the SAME rule
}

πŸ”΄ An inbound NAT rule targets one backend at a time, and the second attachment is refused by Azure β€” not by the provider, and not by this module. The failure arrives at apply.

πŸ’‘ This module does not invent a check for it: knowing whether the rule already has a backend would mean reading the rule's current state, which is not derivable from an ID. Refusing on a guess would reject legal configurations.

ℹ️ For several machines, use a NAT rule per machine, or an inbound NAT pool on the load balancer, which allocates a port range across a scale set.

7 Β· A missing interface fails here, and does not fail one sibling
module "rdp_rule_attachment" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-interface-nat-rule-association.git?ref=v1.0.0"

  network_interface_id  = var.nic_id_that_may_not_exist
  ip_configuration_name = "internal"
  nat_rule_id           = var.rdp_nat_rule_id
}

πŸ”΄ This resource's create ERRORS if the interface does not exist, with <interface> was not found.

⚠️ The application-security-group association does the opposite: its create logs the fact, clears the resource ID, and returns SUCCESS β€” a green apply that made no attachment at all. The two live in the same provider directory and share helper functions.

πŸ’‘ Neither behaviour is derivable from the schema; both were read from the provider's source. Referencing the interface through a module output rather than a variable is worth doing regardless β€” Terraform's dependency graph then guarantees it exists.

8 Β· A misspelled configuration name fails at apply
module "rdp_rule_attachment" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-interface-nat-rule-association.git?ref=v1.0.0"

  network_interface_id  = module.app_nic.id
  ip_configuration_name = "managment" # missing an e
  nat_rule_id           = var.rdp_nat_rule_id
}

πŸ”΄ The create fails with IP Configuration "managment" was not found for <interface>. The configuration is not created for you, and the failure is not silent.

⚠️ But it happens at APPLY, not at plan. Nothing in a Resource ID reveals which configurations an interface has, so no amount of validation here could catch it. The provider's only check on this argument is that the string is non-empty.

πŸ’‘ This module adds one check the provider does not: a value beginning with / is refused, because a Resource ID in a name field can only be a mistake. Anything else is left alone.

9 Β· A concurrent writer closes the route
module "rdp_rule_attachment" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-interface-nat-rule-association.git?ref=v1.0.0"

  network_interface_id  = module.app_nic.id
  ip_configuration_name = "internal"
  nat_rule_id           = var.rdp_nat_rule_id
}

πŸ”΄ The provider locks the network interface while it reads, modifies and writes β€” inside one Terraform process. A second apply, a portal edit, a CLI call or a parallel pipeline reads the same interface, writes back its own version, and one side's change disappears. Neither reports an error.

⚠️ Here that loss means the forwarded port quietly stops reaching the machine, and stays that way until the next refresh notices.

πŸ’‘ The mitigation is ownership rather than retries: one pipeline owns a network interface's attachments.

10 Β· Three ways this disappears from state
output "detectable_drift" {
  # EMPTY, and not by oversight.
  value = module.rdp_rule_attachment.fields_azure_returns_on_read
}

ℹ️ The refresh removes this resource from state if the interface is gone, if the named IP configuration is gone, or if the rule is simply no longer attached to that configuration. All three look identical from the outside.

⚠️ The following plan then proposes to create it again, which reads like a first-time create rather than like something took it away.

πŸ’‘ fields_azure_returns_on_read is empty because the read establishes presence and then sets the arguments from the composite ID rather than from the API response. With three force-new arguments and no others, there is nothing a change could be.

11 Β· What NOT to do
# WRONG -- a backend address pool where an inbound NAT rule belongs.
nat_rule_id = var.load_balancer_backend_pool_id

ℹ️ Refused at terraform plan, offline, with a message naming the module to use instead: terraform-azurerm-network-interface-backend-address-pool-association. The two differ by one path segment on the same load balancer.

# WRONG -- an ID that stops at the load balancer.
nat_rule_id = var.load_balancer_id

ℹ️ Refused. The pattern requires the /inboundNatRules/<rule> segments.

# WRONG -- an IP configuration's Resource ID where the INTERFACE's belongs.
network_interface_id = var.nic_ip_configuration_id

ℹ️ Refused. Pass the interface's ID and name the configuration separately.

# WRONG -- a Resource ID where a NAME belongs.
ip_configuration_name = var.nic_ip_configuration_id

ℹ️ Refused by this module, which goes one step further than the provider here.

12 Β· πŸ—οΈ End-to-end composition
provider "azurerm" {
  features {}
}

module "rg" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"

  name     = "rg-app-eastus"
  location = "eastus"

  tags = {
    environment = "production"
    workload    = "line-of-business-app"
  }
}

module "app_vnet" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network.git?ref=v1.0.0"

  name                = "vnet-app-eastus"
  resource_group_name = module.rg.name
  location            = module.rg.location
  address_space       = ["10.130.0.0/16"]

  subnets = {
    snet-app = {
      address_prefixes = ["10.130.1.0/24"]
    }
  }

  tags = module.rg.tags
}

module "app_nic" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-interface.git?ref=v1.0.0"

  name                = "nic-app01"
  resource_group_name = module.rg.name
  location            = module.rg.location

  ip_configurations = {
    internal = {
      subnet_id                     = module.app_vnet.subnet_ids["snet-app"]
      private_ip_address_allocation = "Dynamic"
      primary                       = true
    }
  }

  tags = module.rg.tags
}

# The rule set that decides whether the forwarded port is usable at all.
module "app_nsg" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-security-group.git?ref=v1.0.0"

  name                = "nsg-app"
  resource_group_name = module.rg.name
  location            = module.rg.location

  tags = module.rg.tags
}

module "nic_security_group" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-interface-security-group-association.git?ref=v1.0.0"

  network_interface_id      = module.app_nic.id
  network_security_group_id = module.app_nsg.id
}

module "rdp_rule_attachment" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-network-interface-nat-rule-association.git?ref=v1.0.0"

  network_interface_id  = module.app_nic.id
  ip_configuration_name = "internal"
  nat_rule_id           = var.rdp_nat_rule_id
}

output "rdp_rule_attachment" {
  value = {
    terraform_id     = module.rdp_rule_attachment.id
    import_address   = module.rdp_rule_attachment.import_id
    configuration_id = module.rdp_rule_attachment.ip_configuration_id
    interface        = module.rdp_rule_attachment.network_interface_name
    load_balancer    = module.rdp_rule_attachment.load_balancer_name
    rule             = module.rdp_rule_attachment.nat_rule_name
    cross_sub        = module.rdp_rule_attachment.spans_subscriptions
    opens_a_route    = module.rdp_rule_attachment.this_opens_a_direct_path_to_this_one_machine
  }
}

πŸ”’ The security group association is in the composition even though this module never references it, and that is the point: a NAT rule creates a route, and the rule set decides whether anything can use it. Leaving it out would misrepresent the change.

πŸ’‘ Referencing the interface through module.app_nic.id rather than a variable is deliberate β€” Terraform's dependency graph then guarantees the interface exists before either association is attempted.

⚠️ module.rdp_rule_attachment.id appears in the output because it is Terraform's handle, not because it can be passed to anything expecting a Resource ID. configuration_id is the only real Azure Resource ID of the three.


πŸ“₯ Inputs

Required: network_interface_id, ip_configuration_name, nat_rule_id Optional: timeouts

There is no tags variable β€” the resource exposes none. Tag the interface or the load balancer.

Full input schemas
variable "network_interface_id" {
  type = string
  # REQUIRED, FORCE-NEW. Anchored to .../networkInterfaces/<name> at both ends.
  # THIS IS THE OBJECT THAT GETS WRITTEN, and the provider locks it by name.
  # A MISSING INTERFACE FAILS THE CREATE -- unlike the sibling ASG association.
}

variable "ip_configuration_name" {
  type = string
  # REQUIRED, FORCE-NEW. The provider checks only that it is non-empty.
  # ONLY THIS CONFIGURATION IS MODIFIED. A name that does not exist on the
  # interface fails the create AT APPLY -- no plan can catch it.
  # This module additionally refuses a value beginning with "/".
}

variable "nat_rule_id" {
  type = string
  # REQUIRED, FORCE-NEW. A load balancer INBOUND NAT RULE:
  #   .../Microsoft.Network/loadBalancers/<lb>/inboundNatRules/<rule>
  # A BACKEND ADDRESS POOL is a different resource and is refused.
  # THE RULE TAKES ONE BACKEND -- a second attachment is refused by AZURE.
}

variable "timeouts" {
  type = object({
    create = optional(string)
    read   = optional(string)
    delete = optional(string)
  })
  default = null
  # THREE KEYS. All three arguments are force-new, so there is no update
  # operation; Terraform would discard an `update` key silently.
}

🧾 Outputs

Output Type Notes
id string Synthetic β€” first half is an IP configuration ID
import_id string The exact terraform import address
ip_configuration_id string A real Azure Resource ID
network_interface_id / network_interface_name string The interface that gets written
network_interface_resource_group_name / network_interface_subscription_id string Parsed
ip_configuration_name string The one configuration modified
nat_rule_id / nat_rule_name string The inbound NAT rule
load_balancer_name string Parsed from the rule's ID
nat_rule_resource_group_name / nat_rule_subscription_id string Where the rule lives
spans_subscriptions / spans_resource_groups bool Where the read permission belongs
cannot_verify_the_interface_and_rule_share_a_region bool Constant true β€” a stated limitation
this_is_not_a_resource_in_azure bool Constant true
creating_this_requires_write_on_the_whole_network_interface bool Constant true
this_opens_a_direct_path_to_this_one_machine bool Constant true
only_the_named_ip_configuration_is_modified bool Constant true
a_missing_interface_fails_the_create_loudly bool Constant true β€” one sibling does the opposite
a_missing_ip_configuration_fails_the_create_loudly bool Constant true
a_concurrent_writer_can_silently_drop_this_association bool Constant true
the_read_clears_the_id_by_three_separate_routes bool Constant true
an_import_guard_exists_and_it_names_the_composite_id bool Constant true
there_is_no_update_operation_at_all bool Constant true
destroying_this_removes_the_attachment_not_the_rule bool Constant true
no_secret_is_accepted_or_emitted_by_this_module bool Constant true
this_resource_supports_no_azure_resource_tags bool Constant true
force_new_fields list(string) All three arguments
fields_that_can_change_after_creation list(string) Empty
fields_azure_returns_on_read list(string) Empty β€” and the description says why

🧠 Architecture Notes

This is not a resource in Azure, and most of the caveats follow from that. The provider reads the network interface, attaches the inbound NAT rule to the named IP configuration, and writes the whole interface back, holding an internal lock on the interface's name. Delete is the same operation in reverse. So the permission required is networkInterfaces/write β€” the right to change any property of that interface β€” and Azure offers nothing narrower.

A NAT rule is the pointed sibling of a backend address pool. A pool spreads traffic across whichever machines belong to it; a rule forwards one frontend port to one backend. That is why this association reads as a reachability change rather than a capacity one, and why it is usually reached for when someone needs administrative access to a specific instance.

And it decides nothing about what can traverse the route. The network security group rules applying to the interface β€” and to its subnet, which is evaluated as well β€” permit or deny the forwarded traffic. Neither is visible from this module, which is why the composition in example 12 includes a security group association this module never references.

The composite ID is not the shape most people guess. Its first half is an IP configuration Resource ID β€” the interface's ID with /ipConfigurations/<name> appended β€” not the interface's own. Both import_id and ip_configuration_id are emitted for that reason, and both derive from the inputs so they exist before the resource does.

Siblings in this provider directory disagree about a missing parent. This resource's create errors when the interface is absent; the application-security-group association's create clears its ID and returns success, having done nothing. Both behaviours were read from the provider's source, and the one that applies here is emitted as a constant so it does not have to be rediscovered.

Two failures land at apply that no plan can catch. A misspelled IP configuration name, because nothing in a Resource ID reveals which configurations an interface has; and a NAT rule that already has a backend, because a rule takes one at a time and knowing that would mean reading the rule's current state. This module adds one refusal the provider does not β€” an ip_configuration_name beginning with / β€” and invents nothing else.

A concurrent writer wins silently, and here that closes a route. The lock lives inside one Terraform process. A second apply, a portal edit or a CLI call rewrites the same interface, one side's change vanishes with no error, and the forwarded port quietly stops reaching the machine.

There is nothing to drift. The read establishes only whether the rule is attached to the named configuration, then sets the arguments from the composite ID. With three force-new arguments and no others, there is nothing a change could be β€” so fields_azure_returns_on_read is deliberately empty with the reason attached, and the resource can disappear from state by three routes that all look the same.

lifecycle is not valid inside a module block, so a caller cannot add prevent_destroy here.


🧱 Design Principles

Concern This module's position What the caller must type to change it
The import address Composed and emitted, along with the real IP configuration ID β€”
ip_configuration_name Refuses a Resource ID, and nothing more β€” the provider publishes no pattern β€”
The target type Anchored to inbound NAT rules, with the sibling module named in the message use the pool module for pools
One backend per rule Documented, never enforced β€” it would mean reading the rule's state β€”
The security dependency Emitted as a constant, because the deciding rules are invisible here read them yourself
The region rule Stated as uncheckable, in contrast to the two comparisons that are made compare in the calling configuration
fields_azure_returns_on_read Empty, with the reason in the description β€”
tags Absent by design β€” the resource exposes none tag the interface or the load balancer

πŸ”’ The question here is not a security setting. It is which address gets the forwarded port, what the rule set allows through it, and who else can write the interface.


πŸš€ Runbook

terraform init -backend=false
terraform validate
terraform fmt -check

Pin the module by tag β€” ?ref=v1.0.0 β€” never a branch.

Plan-only: this repository performs no cloud apply. A human applies from CI β€” knowing that a destroy line closes a route and a create line opens one.


πŸ§ͺ Testing

What terraform validate covers, offline and without credentials:

  • The anchored network interface ID check, at both ends.
  • A non-empty ip_configuration_name, and a refusal of a Resource ID in that field.
  • The anchored NAT rule ID check, including refusing a backend address pool.

What only terraform plan exercises (credentials required):

  • The import guard, if this rule is already attached to the named configuration.

What only terraform apply reveals:

  • Whether the network interface exists β€” loudly, on this resource.
  • Whether the named IP configuration exists on it.
  • Whether the NAT rule already has a backend.
  • Whether the interface and the load balancer are mutually reachable.

What nothing reveals at all:

  • Whether the network security group rules permit traffic on the forwarded port.
  • Whether a concurrent writer is editing the same interface.

ℹ️ terraform console fires root-module variable validations, unlike terraform validate on a calling configuration. It is the honest offline harness for the first list and for every derived flag β€” including both branches of each span flag and the composite ID with a whitespace-padded configuration name.


πŸ’¬ Example Output

Outputs:

id                     = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-app-eastus/providers/Microsoft.Network/networkInterfaces/nic-app01/ipConfigurations/internal|/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-app-eastus/providers/Microsoft.Network/loadBalancers/lb-app/inboundNatRules/rdp-01"
ip_configuration_id    = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-app-eastus/providers/Microsoft.Network/networkInterfaces/nic-app01/ipConfigurations/internal"
network_interface_name = "nic-app01"
ip_configuration_name  = "internal"
load_balancer_name     = "lb-app"
nat_rule_name          = "rdp-01"
spans_subscriptions    = false
spans_resource_groups  = false
fields_that_can_change_after_creation = []
fields_azure_returns_on_read          = []

ℹ️ Compare the first two. id is two IDs with a pipe between them; ip_configuration_id is its first half, and the only one of the two Azure would recognise.


πŸ” Troubleshooting

Symptom Cause Fix
must be a network interface Resource ID An IP configuration's ID, or a rule's Pass the interface; name the configuration separately
must not be empty or whitespace A blank configuration name Non-empty is the provider's only check
must be a NAME, not a Resource ID The configuration's full ID was passed This module's own refusal
must be a load balancer INBOUND NAT RULE A backend address pool, or an ID stopping at the balancer The message names the sibling module
Apply fails <interface> was not found The interface does not exist This resource fails loudly; its ASG sibling would not
Apply fails IP Configuration ... was not found A misspelled or absent configuration name Checked at apply only
Apply fails saying the rule is in use The NAT rule already has a backend One rule, one backend. Use another rule or a NAT pool
Apply fails with an authorization error Write on the interface, or join on the rule, is missing The rule may be in another subscription
Apply fails on reachability The interface and load balancer cannot see each other Same region and virtual network; not checkable here
The attachment vanished A concurrent writer rewrote the interface One pipeline should own an interface's attachments
The port forwards but nothing connects The rule set does not permit it Network security group rules, on the interface and the subnet
The wrong address got the port The wrong configuration was named Only the named one is attached
No drift is ever reported The read checks presence only Expected; see fields_azure_returns_on_read
A destroy closed an admin route That is exactly what it does Expected. The plan says only "1 to destroy"
The import command fails to parse The pipe was not quoted It is a shell metacharacter; quote import_id

πŸ”— Related Docs

  • azurerm_network_interface_nat_rule_association β€” provider reference, including the Terraform-specific ID format
  • Manage inbound NAT rules β€” how a rule forwards a port to one backend
  • Sibling modules: terraform-azurerm-network-interface-backend-address-pool-association, terraform-azurerm-network-interface-security-group-association, terraform-azurerm-network-interface-application-security-group-association, terraform-azurerm-network-interface, terraform-azurerm-load-balancer, terraform-azurerm-network-security-group, terraform-azurerm-resource-group
  • This module's SCOPE.md β€” the cross-module contract

πŸ’™ "Infrastructure as Code should be standardized, consistent, and secure."