{"id":1235,"date":"2026-10-14T08:23:50","date_gmt":"2026-10-14T08:23:50","guid":{"rendered":"https:\/\/www.devopsschool.com\/tutorials\/?p=1235"},"modified":"2026-10-14T08:23:52","modified_gmt":"2026-10-14T08:23:52","slug":"terraform-lab-azure-hands-on-terraform-modules","status":"publish","type":"post","link":"https:\/\/www.devopsschool.com\/tutorials\/terraform-lab-azure-hands-on-terraform-modules\/","title":{"rendered":"Terraform Lab \u2013 Azure Hands-On \u2013\u00a0Terraform Modules"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\"><strong>Course level:<\/strong>&nbsp;Beginner \u2192 Intermediate<br><strong>Course type:<\/strong>&nbsp;Tutorial + runnable hands-on exercises + instructor checkpoints<br><strong>Updated:<\/strong>&nbsp;14 October 2026<br><strong>Terraform:<\/strong>&nbsp;<code>&gt;= 1.15.0, &lt; 2.0.0<\/code>&nbsp;(lab baseline: 1.15.9)<br><strong>AzureRM provider:<\/strong>&nbsp;<code>~&gt; 5.9.0<\/code>&nbsp;(root; child modules declare&nbsp;<code>&gt;= 5.9.0<\/code>)<br><strong>Azure baseline:<\/strong>&nbsp;<code>eastus<\/code>, Ubuntu Server 26.04 LTS, SSH-key login, private-only VM<br><strong>Main goal:<\/strong>&nbsp;Learn Terraform&nbsp;<strong>modules<\/strong>, not complex Azure networking or security features.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">How to use this training<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This is the&nbsp;<strong>master Markdown teaching document<\/strong>. It includes the explanation, step-by-step instructions, mini-assignments, Mermaid diagrams, and&nbsp;<strong>complete code for every exercise<\/strong>&nbsp;below. Identical files also exist as runnable&nbsp;<code>.tf<\/code>,&nbsp;<code>.tfvars.example<\/code>, and&nbsp;<code>.tftest.hcl<\/code>&nbsp;files in the accompanying ZIP.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The course adapts the user&#8217;s prior&nbsp;<em>Azure Terraform Hands-On \u2014 Part 2: Input Variables (All Six Types)<\/em>&nbsp;reference: AzureRM&nbsp;<code>5.9.x<\/code>, Terraform&nbsp;<code>1.15.x<\/code>, Azure&nbsp;<code>eastus<\/code>, the Ubuntu 26.04 LTS image, and familiar variable terminology. It intentionally&nbsp;<strong>does not carry over the old Windows VM, passwords, public IP addresses or 13-resource state<\/strong>. Those were useful for teaching variable types; here they would distract from modules. The new VM labs use SSH keys and no public IP addresses.<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\"><strong>State safety \u2014 read before running:<\/strong>&nbsp;Each numbered lab is a&nbsp;<strong>standalone root\/state snapshot<\/strong>&nbsp;for teaching. These are progressive&nbsp;<em>code examples<\/em>, not a set of in-place upgrades to the old Azure Lab 4 deployment. Do not apply any lab folder to the state from the prior 13-resource course. Optionally apply one isolated lab, verify,&nbsp;<code>terraform destroy<\/code>, and move to the next. Only the explicitly labeled Bonus Lab 12 demonstrates an in-place state-preserving move.<\/p>\n<\/blockquote>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\"><strong>Costs and networking:<\/strong>&nbsp;Creating Azure VMs may incur hourly compute\/disk charges. The VM exercises use a private-only NIC and no public IP. Azure VMs can be verified in the portal or via&nbsp;<code>az vm show<\/code>&nbsp;but cannot be SSH-accessed directly from the public internet. No private key or password is included in the ZIP.<\/p>\n<\/blockquote>\n\n\n\n<h2 class=\"wp-block-heading\">Learning outcomes<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">After completing the course, learners can explain root vs child modules; create and call a local module; pass required\/optional, validated and sensitive inputs; expose and chain outputs; reuse a VM module across instances\/environments; compose modules; select local, Git, and Registry sources; pin\/upgrade module versions; and test\/maintain modules without unnecessarily deploying cloud resources.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Curriculum and code progression<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th class=\"has-text-align-left\" data-align=\"left\">Course module<\/th><th class=\"has-text-align-left\" data-align=\"left\">Covered concepts<\/th><th class=\"has-text-align-left\" data-align=\"left\">Progressive labs<\/th><th class=\"has-text-align-left\" data-align=\"left\">Azure resources per applied lab<\/th><\/tr><\/thead><tbody><tr><td><strong>1. Introduction<\/strong><\/td><td>Modules, root vs child, purpose and trade-offs<\/td><td>01<\/td><td>1 resource group<\/td><\/tr><tr><td><strong>2. Your first module<\/strong><\/td><td>Module folder,&nbsp;<code>source<\/code>, first module call, Terraform workflow<\/td><td>02<\/td><td>1 resource group<\/td><\/tr><tr><td><strong>3. Inputs and outputs<\/strong><\/td><td>Required\/optional values, defaults, outputs, validation, sensitive data<\/td><td>03\u201304<\/td><td>1 resource group each<\/td><\/tr><tr><td><strong>4. Reusability<\/strong><\/td><td>Linux VM module, two calls, dev\/prod, nesting\/composition<\/td><td>05\u201308<\/td><td>5 \/ 7 \/ 5 per env \/ 5 resources<\/td><\/tr><tr><td><strong>5. Sources and versioning<\/strong><\/td><td>Git tag, Registry sources, version pins, Azure Verified Modules<\/td><td>09\u201310<\/td><td>Git naming: random provider; AVM: RG + VNet\/subnet + ancillary providers<\/td><\/tr><tr><td><strong>6. Best practices<\/strong><\/td><td>Providers, tests, docs, design and state-safe migration<\/td><td>11 + Bonus 12<\/td><td>11: none (mock tests); 12: 1 RG<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The repository intentionally repeats a few short blocks between&nbsp;<strong>standalone<\/strong>&nbsp;labs so students can open any lab by itself. The actual Linux VM child module stays essentially identical across the VM exercises, which is the central lesson.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Architecture at a glance<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>flowchart LR\n    Student&#91;\"Student \/ Terraform CLI\"] --&gt; Root&#91;\"Root module\"]\n    Root --&gt;|\"source + input arguments\"| Child&#91;\"Child module\"]\n    Child --&gt;|\"AzureRM \/ AzAPI provider\"| Azure&#91;\"Azure resources\"]\n    Child --&gt;|\"output values\"| Root\n    Root --&gt;|\"display outputs\"| Student\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Prerequisites \u2014 do once<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Install Terraform 1.15.x (or a compatible supported version satisfying\u00a0<code>required_version<\/code>), Azure CLI and Git. Have an Azure subscription where you can create resource groups, networks and VMs. Check any organization policies and quotas first.<\/li>\n\n\n\n<li>Authenticate and select the intended lab subscription:<\/li>\n<\/ol>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform version\naz version\naz login\naz account set --subscription \"YOUR_SUBSCRIPTION_ID\"\nexport ARM_SUBSCRIPTION_ID=\"$(az account show --query id -o tsv)\"\n<\/code><\/pre>\n\n\n\n<ol start=\"3\" class=\"wp-block-list\">\n<li>For VM labs 05\u201308 and each environment of Lab 07, create an SSH key\u00a0<strong>only if you do not already have one<\/strong>:<\/li>\n<\/ol>\n\n\n\n<pre class=\"wp-block-code\"><code>ls -l ~\/.ssh\/id_ed25519.pub\n<em># If the public key does not exist:<\/em>\nssh-keygen -t ed25519 -f ~\/.ssh\/id_ed25519\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Use a passphrase and protect&nbsp;<code>~\/.ssh\/id_ed25519<\/code>. Terraform reads only the&nbsp;<code>.pub<\/code>&nbsp;file. Students with existing keys should&nbsp;<strong>not overwrite them<\/strong>.<\/p>\n\n\n\n<ol start=\"4\" class=\"wp-block-list\">\n<li>Check the chosen Ubuntu image and VM size in your target subscription\/region. The course retains the exact image reference from the previous Azure exercise for continuity:<\/li>\n<\/ol>\n\n\n\n<pre class=\"wp-block-code\"><code>az vm image show --location eastus --urn Canonical:ubuntu-26_04-lts:server:latest\naz vm list-skus --location eastus --resource-type virtualMachines --size Standard_B2s_v2 -o table\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Availability varies by subscription, region, policy and quota. If a check fails, choose a permitted equivalent and edit the&nbsp;<strong>shared VM module<\/strong>&nbsp;deliberately rather than inventing resources. Terraform configuration can validate without guaranteeing deployment will succeed in a particular Azure subscription.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The repeatable student workflow<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Run these&nbsp;<strong>inside the correct root folder<\/strong>, never inside&nbsp;<code>modules\/linux-vm<\/code>&nbsp;or another child directory:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform init\nterraform fmt -recursive\nterraform validate\nterraform plan -out=tfplan\n<em># Carefully check any destructive\/replacement actions before you run:<\/em>\nterraform apply tfplan\nterraform output\nterraform state list\nterraform destroy  <em># Only for this isolated training state, when finished<\/em>\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">For labs with&nbsp;<code>terraform.tfvars.example<\/code>, first copy it to&nbsp;<code>terraform.tfvars<\/code>&nbsp;in the&nbsp;<strong>root<\/strong>.&nbsp;<code>terraform.tfvars<\/code>&nbsp;auto-loads;&nbsp;<code>.example<\/code>&nbsp;does not. For Lab 04, also set its disposable environment variable. Lab 11 prefers&nbsp;<code>terraform test<\/code>&nbsp;rather than apply. Lab 12 documents a different, intentional state-migration procedure.&nbsp;<code>terraform init -upgrade<\/code>&nbsp;is introduced later, not a default part of each run. Never blindly apply a plan with unexpected&nbsp;<code>-\/+<\/code>&nbsp;replacements or resource deletion.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why no PIP\/NSG in VM labs?<\/strong>&nbsp;Those features are already covered in the earlier Azure lessons. The point here is the module boundary. Replacing VM images or adding public SSH exposure would distract from it.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Module 1 \u2014 Introduction to Terraform Modules<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A&nbsp;<strong>Terraform module<\/strong>&nbsp;is a directory containing Terraform configuration files. The directory where you invoke Terraform is the&nbsp;<strong>root module<\/strong>; a module loaded from&nbsp;<code>module { source = \"...\" }<\/code>&nbsp;is a&nbsp;<strong>child module<\/strong>.&nbsp;<code>main.tf<\/code>,&nbsp;<code>variables.tf<\/code>, and&nbsp;<code>outputs.tf<\/code>&nbsp;are common conventions,&nbsp;<strong>not mandatory filenames<\/strong>; Terraform loads the&nbsp;<code>.tf<\/code>&nbsp;files in one module directory together.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Think of a module as a small reusable building block: it accepts&nbsp;<strong>inputs<\/strong>, manages resources\/other modules, and exposes&nbsp;<strong>outputs<\/strong>. Benefits include avoiding copy-paste, setting consistent standards and making changes easier. Costs include more layers, more interfaces to maintain, version coordination and hidden complexity. A module is worthwhile when a useful boundary or reuse need exists\u2014not simply because every resource&nbsp;<em>can<\/em>&nbsp;be wrapped in one.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>flowchart TB\n    Root&#91;\"root\/ (Terraform execution directory)\"]\n    Root --&gt; Files&#91;\"main.tf + variables.tf + outputs.tf\"]\n    Root --&gt; Call&#91;\"module rg (source = local path)\"]\n    Call --&gt; Child&#91;\"modules\/resource-group\/ (child directory)\"]\n    Child --&gt; Actual&#91;\"azurerm_resource_group.this\"]\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Lab 01 \u2014 Root module: no&nbsp;<code>module<\/code>&nbsp;block yet<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Goal.<\/strong>&nbsp;Recognize that any working Terraform directory is a module, even when it only has one&nbsp;<code>resource<\/code>&nbsp;block. Begin with the simplest possible Azure object: a resource group.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Enter\u00a0<code>labs\/01-root-module<\/code>\u00a0and open\u00a0<code>main.tf<\/code>.<\/li>\n\n\n\n<li>Identify the resource\u00a0<em>type<\/em>\u00a0<code>azurerm_resource_group<\/code>, local label\u00a0<code>lab<\/code>, and arguments\u00a0<code>name<\/code>\/<code>location<\/code>.<\/li>\n\n\n\n<li>Run\u00a0<code>terraform init<\/code>,\u00a0<code>terraform validate<\/code>,\u00a0<code>terraform plan<\/code>. Optionally apply, then run\u00a0<code>terraform state list<\/code>.<\/li>\n\n\n\n<li>Find the state address\u00a0<code>azurerm_resource_group.lab<\/code>. Destroy before creating the next isolated exercise.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Checkpoint:<\/strong>&nbsp;Explain why a directory with only&nbsp;<code>main.tf<\/code>&nbsp;and&nbsp;<code>versions.tf<\/code>&nbsp;is called the&nbsp;<em>root module<\/em>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Mini-assignment:<\/strong>&nbsp;Rename the Terraform local label&nbsp;<code>lab<\/code>&nbsp;to&nbsp;<code>training<\/code>&nbsp;<strong>before applying<\/strong>&nbsp;and predict the changed state address; do not change an already-applied lab without assessing state effects.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">Complete code for this lab<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">All paths below are relative to&nbsp;<code>labs\/01-root-module\/<\/code>. The ZIP contains the identical ready-to-edit files.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>main.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>resource \"azurerm_resource_group\" \"lab\" {\n  name     = \"rg-tfmod-01\"\n  location = \"eastus\"\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>versions.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform {\n  required_version = \"&gt;= 1.15.0, &lt; 2.0.0\"\n\n  required_providers {\n    azurerm = {\n      source  = \"hashicorp\/azurerm\"\n      version = \"~&gt; 5.9.0\"\n    }\n  }\n}\n\nprovider \"azurerm\" {\n  features {}\n}\n<\/code><\/pre>\n\n\n\n<h4 class=\"wp-block-heading\">Run this lab<\/h4>\n\n\n\n<pre class=\"wp-block-code\"><code>cd labs\/01-root-module\nterraform init\nterraform fmt -recursive\nterraform validate\nterraform plan\n<em># Optional after reviewing the plan: terraform apply<\/em>\n<em># When done: terraform destroy<\/em>\n<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Module 2 \u2014 Creating Your First Module<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A child module looks like any other Terraform directory:&nbsp;<code>.tf<\/code>&nbsp;files, normally with resource declarations, variable declarations and outputs. The root&#8217;s&nbsp;<code>module<\/code>&nbsp;block selects a source. Terraform obtains local\/remote modules during&nbsp;<code>terraform init<\/code>, then combines the graph when planning. Module names are Terraform addresses, not Azure names.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Module call pattern:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>module \"example\" {\n  source = \".\/modules\/resource-group\"\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Our first module deliberately has no variable or output yet. Those are the&nbsp;<strong>next<\/strong>&nbsp;lesson\u2014not prerequisites for making a module.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Lab 02 \u2014 Create your first local child module<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What changed from Lab 01:<\/strong>&nbsp;The root no longer declares the resource. It delegates to&nbsp;<code>module \"rg\"<\/code>. The child directory owns the actual&nbsp;<code>azurerm_resource_group<\/code>&nbsp;block. This is a&nbsp;<strong>new lab with its own state<\/strong>; it is not an in-place migration from Lab 01.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Open\u00a0<code>main.tf<\/code>\u00a0in the root; read the tiny\u00a0<code>module<\/code>\u00a0call and its\u00a0<code>source<\/code>\u00a0path.<\/li>\n\n\n\n<li>Open\u00a0<code>modules\/resource-group\/main.tf<\/code>. This directory is the child module.<\/li>\n\n\n\n<li>Run the standard workflow. If applied,\u00a0<code>terraform state list<\/code>\u00a0should show\u00a0<code>module.rg.azurerm_resource_group.this<\/code>.<\/li>\n\n\n\n<li>Notice that the child declares\u00a0<code>required_providers<\/code>, but\u00a0<code>provider \"azurerm\"<\/code>\u00a0is only configured in the root.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Checkpoint:<\/strong>&nbsp;Distinguish between&nbsp;<code>module.rg<\/code>&nbsp;(module instance) and&nbsp;<code>azurerm_resource_group.this<\/code>&nbsp;(resource inside the module).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Mini-assignment:<\/strong>&nbsp;Count the number of Azure resources:&nbsp;<strong>one<\/strong>, not two.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">Complete code for this lab<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">All paths below are relative to&nbsp;<code>labs\/02-first-child\/<\/code>. The ZIP contains the identical ready-to-edit files.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>main.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>module \"rg\" {\n  source = \".\/modules\/resource-group\"\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>versions.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform {\n  required_version = \"&gt;= 1.15.0, &lt; 2.0.0\"\n\n  required_providers {\n    azurerm = {\n      source  = \"hashicorp\/azurerm\"\n      version = \"~&gt; 5.9.0\"\n    }\n  }\n}\n\nprovider \"azurerm\" {\n  features {}\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/resource-group\/main.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>resource \"azurerm_resource_group\" \"this\" {\n  name     = \"rg-tfmod-02\"\n  location = \"eastus\"\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/resource-group\/versions.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform {\n  required_providers {\n    azurerm = {\n      source  = \"hashicorp\/azurerm\"\n      version = \"&gt;= 5.9.0\"\n    }\n  }\n}\n<\/code><\/pre>\n\n\n\n<h4 class=\"wp-block-heading\">Run this lab<\/h4>\n\n\n\n<pre class=\"wp-block-code\"><code>cd labs\/02-first-child\nterraform init\nterraform fmt -recursive\nterraform validate\nterraform plan\n<em># Optional after reviewing the plan: terraform apply<\/em>\n<em># When done: terraform destroy<\/em>\n<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Module 3 \u2014 Inputs, Outputs and Configuration<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Modules become reusable when callers choose settings without changing child resource code. An input without&nbsp;<code>default<\/code>&nbsp;is&nbsp;<strong>required<\/strong>; an input with&nbsp;<code>default<\/code>&nbsp;is&nbsp;<strong>optional<\/strong>. Inputs belong in the child&nbsp;<code>variable<\/code>&nbsp;blocks; the root chooses values in its&nbsp;<code>module<\/code>&nbsp;arguments. Child&nbsp;<code>output<\/code>&nbsp;values are read through&nbsp;<code>module.&lt;label&gt;.&lt;output_name&gt;<\/code>.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th class=\"has-text-align-left\" data-align=\"left\">HCL feature<\/th><th class=\"has-text-align-left\" data-align=\"left\">Short meaning<\/th><th class=\"has-text-align-left\" data-align=\"left\">This course<\/th><\/tr><\/thead><tbody><tr><td><code>type = string<\/code><\/td><td>single text input<\/td><td>Resource group name, region<\/td><\/tr><tr><td><code>type = map(string)<\/code><\/td><td>named text values<\/td><td>Optional resource tags<\/td><\/tr><tr><td><code>default = ...<\/code><\/td><td>optional input<\/td><td>region and tags<\/td><\/tr><tr><td><code>validation {}<\/code><\/td><td>reject invalid values<\/td><td><code>rg-<\/code>&nbsp;name prefix<\/td><\/tr><tr><td><code>sensitive = true<\/code><\/td><td>hide normal CLI display<\/td><td>disposable example secret<\/td><\/tr><tr><td><code>output { value = ... }<\/code><\/td><td>exported module result<\/td><td>RG name\/ID<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Important:<\/strong>&nbsp;Sensitive Terraform values may still enter state\/plan files. Use secure state storage and a real secret-management process for production. SSH public keys are&nbsp;<em>not<\/em>&nbsp;passwords and do not need to be marked sensitive.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Lab 03 \u2014 Pass variables in; expose outputs out<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What changed:<\/strong>&nbsp;Lab 02 had hard-coded values inside the child. Lab 03 accepts the resource-group name and region as module inputs and returns the name and ID as outputs.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Copy\u00a0<code>terraform.tfvars.example<\/code>\u00a0to\u00a0<code>terraform.tfvars<\/code>. Terraform auto-loads\u00a0<code>terraform.tfvars<\/code>\u00a0<strong>in the root<\/strong>.<\/li>\n\n\n\n<li>Study root\u00a0<code>var.rg_name<\/code>\u00a0and module arguments\u00a0<code>name = var.rg_name<\/code>,\u00a0<code>location = var.azure_region<\/code>.<\/li>\n\n\n\n<li>Study child\u00a0<code>variables.tf<\/code>:\u00a0<code>name<\/code>\/<code>location<\/code>\u00a0are required;\u00a0<code>tags<\/code>\u00a0is optional and has a default.<\/li>\n\n\n\n<li>Study child\u00a0<code>outputs.tf<\/code>; root output\u00a0<code>module.rg.id<\/code>\u00a0passes a value outside the module boundary.<\/li>\n\n\n\n<li>Run the workflow.\u00a0<strong>After<\/strong>\u00a0applying, run\u00a0<code>terraform output rg_name<\/code>\u00a0and\u00a0<code>terraform output rg_id<\/code>.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Checkpoint:<\/strong>&nbsp;Root variables and child variables are different declarations; values cross the boundary through the&nbsp;<code>module<\/code>&nbsp;block.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Mini-assignment:<\/strong>&nbsp;Change&nbsp;<code>rg_name<\/code>&nbsp;<em>before applying<\/em>&nbsp;to&nbsp;<code>rg-tfmod-03-demo<\/code>&nbsp;and predict the output. Keep&nbsp;<code>azure_region<\/code>&nbsp;absent to use its default.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">Complete code for this lab<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">All paths below are relative to&nbsp;<code>labs\/03-inputs-outputs\/<\/code>. The ZIP contains the identical ready-to-edit files.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>main.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>module \"rg\" {\n  source   = \".\/modules\/resource-group\"\n  name     = var.rg_name\n  location = var.azure_region\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>outputs.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>output \"rg_name\" {\n  value = module.rg.name\n}\n\noutput \"rg_id\" {\n  value = module.rg.id\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>terraform.tfvars.example<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>rg_name = \"rg-tfmod-03\"\n# azure_region is optional; defaults to eastus.\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>variables.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>variable \"rg_name\" {\n  type = string\n}\n\nvariable \"azure_region\" {\n  type    = string\n  default = \"eastus\"\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>versions.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform {\n  required_version = \"&gt;= 1.15.0, &lt; 2.0.0\"\n\n  required_providers {\n    azurerm = {\n      source  = \"hashicorp\/azurerm\"\n      version = \"~&gt; 5.9.0\"\n    }\n  }\n}\n\nprovider \"azurerm\" {\n  features {}\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/resource-group\/main.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>resource \"azurerm_resource_group\" \"this\" {\n  name     = var.name\n  location = var.location\n  tags     = var.tags\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/resource-group\/outputs.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>output \"name\" {\n  description = \"Name created by the module.\"\n  value       = azurerm_resource_group.this.name\n}\n\noutput \"id\" {\n  description = \"Azure resource group ID.\"\n  value       = azurerm_resource_group.this.id\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/resource-group\/variables.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>variable \"name\" {\n  description = \"Resource group name.\"\n  type        = string\n}\n\nvariable \"location\" {\n  description = \"Azure region.\"\n  type        = string\n}\n\nvariable \"tags\" {\n  description = \"Optional resource tags.\"\n  type        = map(string)\n  default     = { course = \"terraform-modules\" }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/resource-group\/versions.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform {\n  required_providers {\n    azurerm = {\n      source  = \"hashicorp\/azurerm\"\n      version = \"&gt;= 5.9.0\"\n    }\n  }\n}\n<\/code><\/pre>\n\n\n\n<h4 class=\"wp-block-heading\">Run this lab<\/h4>\n\n\n\n<pre class=\"wp-block-code\"><code>cd labs\/03-inputs-outputs\ncp terraform.tfvars.example terraform.tfvars\nterraform init\nterraform fmt -recursive\nterraform validate\nterraform plan\n<em># Optional after reviewing the plan: terraform apply<\/em>\n<em># When done: terraform destroy<\/em>\n<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Lab 04 \u2014 Add validation and understand sensitive outputs<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What changed:<\/strong>&nbsp;We validate&nbsp;<code>rg_name<\/code>&nbsp;and send a&nbsp;<strong>disposable demonstration secret<\/strong>&nbsp;through a tiny child module. The Azure resource group never receives that secret.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Copy\u00a0<code>terraform.tfvars.example<\/code>\u00a0to\u00a0<code>terraform.tfvars<\/code>.<\/li>\n\n\n\n<li>Set\u00a0<code>export TF_VAR_training_secret=\"throwaway-training-value\"<\/code>\u00a0in your shell. Do not use a real password.<\/li>\n\n\n\n<li>Observe\u00a0<code>validation<\/code>\u00a0on\u00a0<code>rg_name<\/code>\u00a0and\u00a0<code>sensitive = true<\/code>\u00a0in root, child input and child output.<\/li>\n\n\n\n<li>Run\u00a0<code>terraform init<\/code>,\u00a0<code>terraform validate<\/code>,\u00a0<code>terraform plan<\/code>\u00a0and, if desired,\u00a0<code>terraform apply<\/code>.<\/li>\n\n\n\n<li>After apply,\u00a0<code>terraform output<\/code>\u00a0shows\u00a0<code>(sensitive value)<\/code>\u00a0while\u00a0<code>terraform output -raw redacted_demo<\/code>\u00a0<strong>explicitly prints the disposable demo value<\/strong>.<\/li>\n\n\n\n<li>Try a failed plan with\u00a0<code>terraform plan -var='rg_name=wrong-name'<\/code>; restore the original value afterwards.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Checkpoint:<\/strong>&nbsp;Marking a value&nbsp;<code>sensitive<\/code>&nbsp;hides default CLI rendering; it does&nbsp;<strong>not<\/strong>&nbsp;encrypt or guarantee omission from local\/remote state or saved plan files.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Mini-assignment:<\/strong>&nbsp;Explain why passing real secrets in&nbsp;<code>.tfvars<\/code>&nbsp;committed to Git is unsafe.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">Complete code for this lab<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">All paths below are relative to&nbsp;<code>labs\/04-validation-sensitive\/<\/code>. The ZIP contains the identical ready-to-edit files.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>main.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>module \"rg\" {\n  source   = \".\/modules\/resource-group\"\n  name     = var.rg_name\n  location = var.azure_region\n}\n\nmodule \"secret_example\" {\n  source = \".\/modules\/secret-example\"\n  value  = var.training_secret\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>outputs.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>output \"rg_name\" {\n  value = module.rg.name\n}\n\noutput \"redacted_demo\" {\n  description = \"Sensitive is redacted in normal output, NOT encrypted in state.\"\n  value       = module.secret_example.value\n  sensitive   = true\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>terraform.tfvars.example<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>rg_name = \"rg-tfmod-04\"\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>variables.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>variable \"rg_name\" {\n  type = string\n\n  validation {\n    condition     = startswith(var.rg_name, \"rg-\")\n    error_message = \"The resource group name must start with rg-.\"\n  }\n}\n\nvariable \"azure_region\" {\n  type    = string\n  default = \"eastus\"\n}\n\nvariable \"training_secret\" {\n  description = \"Disposable demo-only text; never use a real password.\"\n  type        = string\n  sensitive   = true\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>versions.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform {\n  required_version = \"&gt;= 1.15.0, &lt; 2.0.0\"\n\n  required_providers {\n    azurerm = {\n      source  = \"hashicorp\/azurerm\"\n      version = \"~&gt; 5.9.0\"\n    }\n  }\n}\n\nprovider \"azurerm\" {\n  features {}\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/resource-group\/main.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>resource \"azurerm_resource_group\" \"this\" {\n  name     = var.name\n  location = var.location\n  tags     = var.tags\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/resource-group\/outputs.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>output \"name\" {\n  description = \"Name created by the module.\"\n  value       = azurerm_resource_group.this.name\n}\n\noutput \"id\" {\n  description = \"Azure resource group ID.\"\n  value       = azurerm_resource_group.this.id\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/resource-group\/variables.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>variable \"name\" {\n  description = \"Resource group name.\"\n  type        = string\n  validation {\n    condition     = startswith(var.name, \"rg-\")\n    error_message = \"Resource group names in this course must start with rg-.\"\n  }\n}\n\nvariable \"location\" {\n  description = \"Azure region.\"\n  type        = string\n}\n\nvariable \"tags\" {\n  description = \"Optional resource tags.\"\n  type        = map(string)\n  default     = { course = \"terraform-modules\" }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/resource-group\/versions.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform {\n  required_providers {\n    azurerm = {\n      source  = \"hashicorp\/azurerm\"\n      version = \"&gt;= 5.9.0\"\n    }\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/secret-example\/outputs.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>output \"value\" {\n  value     = var.value\n  sensitive = true\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/secret-example\/variables.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>variable \"value\" {\n  type      = string\n  sensitive = true\n}\n<\/code><\/pre>\n\n\n\n<h4 class=\"wp-block-heading\">Run and validate the sensitive-value demo<\/h4>\n\n\n\n<pre class=\"wp-block-code\"><code>cd labs\/04-validation-sensitive\ncp terraform.tfvars.example terraform.tfvars\nexport TF_VAR_training_secret=\"throwaway-training-value\"\nterraform init &amp;&amp; terraform validate &amp;&amp; terraform plan\n<em># After optional terraform apply:<\/em>\nterraform output redacted_demo\nterraform output -raw redacted_demo <em># Explicitly prints ONLY disposable demo data.<\/em>\n<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Module 4 \u2014 Reusable Modules in Practice<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This module uses a tiny, realistic Ubuntu Linux VM child module:&nbsp;<strong>one network interface plus one VM<\/strong>. All required Azure networking is provisioned in the calling root or in a separate small&nbsp;<code>network<\/code>&nbsp;module. In the first exercise,&nbsp;<code>resource_group + VNet + subnet = 3<\/code>&nbsp;root resources, and&nbsp;<code>NIC + VM = 2<\/code>&nbsp;child resources. We reuse the exact same child design, first twice, then across two roots, and finally as a submodule of a stack.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>flowchart LR\n    Root&#91;\"Root module\"] --&gt; RG&#91;\"Azure Resource Group\"]\n    Root --&gt; VNET&#91;\"VNet + Subnet\"]\n    Root --&gt; VMCall&#91;\"module.linux_vm\"]\n    VNET --&gt;|\"subnet_id\"| VMCall\n    RG --&gt;|\"name\/location\"| VMCall\n    VMCall --&gt; NIC&#91;\"NIC\"]\n    VMCall --&gt; VM&#91;\"Ubuntu VM (private only)\"]\n    NIC --&gt; VM\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Resource outputs passed as arguments create implicit Terraform dependencies. There is no need to introduce explicit&nbsp;<code>depends_on<\/code>&nbsp;for the simple examples.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Lab 05 \u2014 Build your own reusable Ubuntu VM module<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What changed:<\/strong>&nbsp;We graduate from a one-resource module to a practical,&nbsp;<strong>two-resource<\/strong>&nbsp;Linux VM module. The root owns the prerequisite resource group, VNet and subnet; the child owns only the NIC and VM.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Create an Ed25519 SSH key if\u00a0<code>~\/.ssh\/id_ed25519.pub<\/code>\u00a0does not exist (setup section). The module consumes its\u00a0<strong>public<\/strong>\u00a0key, not your private key.<\/li>\n\n\n\n<li>Open\u00a0<code>main.tf<\/code>: find three root resource blocks, then one module call with the subnet ID and public key.<\/li>\n\n\n\n<li>Open\u00a0<code>modules\/linux-vm\/main.tf<\/code>: count the two resources (NIC and Linux VM). The Ubuntu image matches the uploaded previous lab&#8217;s 26.04 LTS image reference.<\/li>\n\n\n\n<li>Run\u00a0<code>terraform init<\/code>,\u00a0<code>terraform validate<\/code>,\u00a0<code>terraform plan<\/code>; inspect the five total resources. Apply only if subscription quota\/image availability are confirmed.<\/li>\n\n\n\n<li>After apply, run\u00a0<code>terraform output private_ip<\/code>, then\u00a0<code>az vm show -g rg-tfmod-05 -n vm-tfmod-05 --query provisioningState -o tsv<\/code>.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Checkpoint:<\/strong>&nbsp;This VM has&nbsp;<strong>no public IP<\/strong>. A private IP is not directly SSH-reachable from the public internet. Connecting requires an approved private path (VPN\/Bastion\/private network), deliberately outside module basics.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Mini-assignment:<\/strong>&nbsp;Change the module argument&nbsp;<code>vm_size<\/code>&nbsp;via the root variable and read the plan&nbsp;<strong>before<\/strong>&nbsp;applying.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">Complete code for this lab<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">All paths below are relative to&nbsp;<code>labs\/05-linux-vm\/<\/code>. The ZIP contains the identical ready-to-edit files.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>main.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>resource \"azurerm_resource_group\" \"lab\" {\n  name     = \"rg-tfmod-05\"\n  location = var.azure_region\n}\n\nresource \"azurerm_virtual_network\" \"lab\" {\n  name                = \"vnet-tfmod-05\"\n  location            = azurerm_resource_group.lab.location\n  resource_group_name = azurerm_resource_group.lab.name\n  address_space       = &#91;\"10.50.0.0\/16\"]\n}\n\nresource \"azurerm_subnet\" \"vm\" {\n  name                 = \"snet-vm\"\n  resource_group_name  = azurerm_resource_group.lab.name\n  virtual_network_name = azurerm_virtual_network.lab.name\n  address_prefixes     = &#91;\"10.50.1.0\/24\"]\n}\n\nmodule \"linux_vm\" {\n  source              = \".\/modules\/linux-vm\"\n  name                = \"vm-tfmod-05\"\n  location            = azurerm_resource_group.lab.location\n  resource_group_name = azurerm_resource_group.lab.name\n  subnet_id           = azurerm_subnet.vm.id\n  ssh_public_key      = file(pathexpand(\"~\/.ssh\/id_ed25519.pub\"))\n  vm_size             = var.vm_size\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>outputs.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>output \"vm_id\" {\n  value = module.linux_vm.vm_id\n}\n\noutput \"private_ip\" {\n  value = module.linux_vm.private_ip\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>variables.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>variable \"azure_region\" {\n  type    = string\n  default = \"eastus\"\n}\n\nvariable \"vm_size\" {\n  type    = string\n  default = \"Standard_B2s_v2\"\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>versions.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform {\n  required_version = \"&gt;= 1.15.0, &lt; 2.0.0\"\n\n  required_providers {\n    azurerm = {\n      source  = \"hashicorp\/azurerm\"\n      version = \"~&gt; 5.9.0\"\n    }\n  }\n}\n\nprovider \"azurerm\" {\n  features {}\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/linux-vm\/main.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>resource \"azurerm_network_interface\" \"this\" {\n  name                = \"nic-${var.name}\"\n  location            = var.location\n  resource_group_name = var.resource_group_name\n\n  ip_configuration {\n    name                          = \"internal\"\n    subnet_id                     = var.subnet_id\n    private_ip_address_allocation = \"Dynamic\"\n  }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"this\" {\n  name                = var.name\n  location            = var.location\n  resource_group_name = var.resource_group_name\n  size                  = var.vm_size\n  admin_username        = var.admin_username\n  network_interface_ids = &#91;azurerm_network_interface.this.id]\n\n  admin_ssh_key {\n    username   = var.admin_username\n    public_key = var.ssh_public_key\n  }\n\n  os_disk {\n    caching              = \"ReadWrite\"\n    storage_account_type = \"Standard_LRS\"\n  }\n\n  source_image_reference {\n    publisher = \"Canonical\"\n    offer     = \"ubuntu-26_04-lts\"\n    sku       = \"server\"\n    version   = \"latest\"\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/linux-vm\/outputs.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>output \"vm_id\" {\n  value = azurerm_linux_virtual_machine.this.id\n}\n\noutput \"vm_name\" {\n  value = azurerm_linux_virtual_machine.this.name\n}\n\noutput \"private_ip\" {\n  value = azurerm_network_interface.this.private_ip_address\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/linux-vm\/variables.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>variable \"name\"                { type = string }\nvariable \"location\"            { type = string }\nvariable \"resource_group_name\" { type = string }\nvariable \"subnet_id\"           { type = string }\nvariable \"ssh_public_key\"      { type = string }\n\nvariable \"vm_size\" {\n  type    = string\n  default = \"Standard_B2s_v2\"\n}\n\nvariable \"admin_username\" {\n  type    = string\n  default = \"labadmin\"\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/linux-vm\/versions.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform {\n  required_providers {\n    azurerm = {\n      source  = \"hashicorp\/azurerm\"\n      version = \"&gt;= 5.9.0\"\n    }\n  }\n}\n<\/code><\/pre>\n\n\n\n<h4 class=\"wp-block-heading\">Run this lab<\/h4>\n\n\n\n<pre class=\"wp-block-code\"><code>cd labs\/05-linux-vm\nterraform init\nterraform fmt -recursive\nterraform validate\nterraform plan\n<em># Optional after reviewing the plan: terraform apply<\/em>\n<em># When done: terraform destroy<\/em>\n<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Lab 06 \u2014 Reuse the same module with two different instances<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What changed:<\/strong>&nbsp;Instead of writing a second VM resource and NIC resource manually, the root calls&nbsp;<code>.\/modules\/linux-vm<\/code>&nbsp;twice as&nbsp;<code>module.app<\/code>&nbsp;and&nbsp;<code>module.worker<\/code>.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Compare Lab 05 and Lab 06. The child module stays the same; only two independent module calls and root outputs change.<\/li>\n\n\n\n<li>Run the workflow, and check that the plan creates three shared network resources plus two resources inside\u00a0<strong>each<\/strong>\u00a0VM module:\u00a0<strong>seven Azure resources<\/strong>\u00a0total.<\/li>\n\n\n\n<li>After apply, run\u00a0<code>terraform output vm_names<\/code>\u00a0and\u00a0<code>terraform output private_ips<\/code>.<\/li>\n\n\n\n<li>Check state addresses under\u00a0<code>module.app.*<\/code>\u00a0and\u00a0<code>module.worker.*<\/code>.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Checkpoint:<\/strong>&nbsp;Two&nbsp;<em>module instances<\/em>&nbsp;can reuse identical source code while owning separate state addresses and distinct Azure objects.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Mini-assignment:<\/strong>&nbsp;Predict the effect of accidentally using the same Azure VM name for both module instances within the same resource group. This is an Azure naming conflict, not a Terraform-module limitation.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Cost note:<\/strong>&nbsp;This lab creates two VMs. Skip apply if you only want to study the module concept; destroy when finished.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">Complete code for this lab<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">All paths below are relative to&nbsp;<code>labs\/06-two-vms\/<\/code>. The ZIP contains the identical ready-to-edit files.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>main.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>resource \"azurerm_resource_group\" \"lab\" {\n  name     = \"rg-tfmod-06\"\n  location = var.azure_region\n}\n\nresource \"azurerm_virtual_network\" \"lab\" {\n  name                = \"vnet-tfmod-06\"\n  location            = azurerm_resource_group.lab.location\n  resource_group_name = azurerm_resource_group.lab.name\n  address_space       = &#91;\"10.60.0.0\/16\"]\n}\n\nresource \"azurerm_subnet\" \"vm\" {\n  name                 = \"snet-vm\"\n  resource_group_name  = azurerm_resource_group.lab.name\n  virtual_network_name = azurerm_virtual_network.lab.name\n  address_prefixes     = &#91;\"10.60.1.0\/24\"]\n}\n\nmodule \"app\" {\n  source              = \".\/modules\/linux-vm\"\n  name                = \"vm-tfmod-06-app\"\n  location            = azurerm_resource_group.lab.location\n  resource_group_name = azurerm_resource_group.lab.name\n  subnet_id           = azurerm_subnet.vm.id\n  ssh_public_key      = file(pathexpand(\"~\/.ssh\/id_ed25519.pub\"))\n  vm_size             = var.vm_size\n}\n\nmodule \"worker\" {\n  source              = \".\/modules\/linux-vm\"\n  name                = \"vm-tfmod-06-worker\"\n  location            = azurerm_resource_group.lab.location\n  resource_group_name = azurerm_resource_group.lab.name\n  subnet_id           = azurerm_subnet.vm.id\n  ssh_public_key      = file(pathexpand(\"~\/.ssh\/id_ed25519.pub\"))\n  vm_size             = var.vm_size\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>outputs.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>output \"vm_names\" {\n  value = &#91;module.app.vm_name, module.worker.vm_name]\n}\n\noutput \"private_ips\" {\n  value = &#91;module.app.private_ip, module.worker.private_ip]\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>variables.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>variable \"azure_region\" {\n  type    = string\n  default = \"eastus\"\n}\n\nvariable \"vm_size\" {\n  type    = string\n  default = \"Standard_B2s_v2\"\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>versions.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform {\n  required_version = \"&gt;= 1.15.0, &lt; 2.0.0\"\n\n  required_providers {\n    azurerm = {\n      source  = \"hashicorp\/azurerm\"\n      version = \"~&gt; 5.9.0\"\n    }\n  }\n}\n\nprovider \"azurerm\" {\n  features {}\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/linux-vm\/main.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>resource \"azurerm_network_interface\" \"this\" {\n  name                = \"nic-${var.name}\"\n  location            = var.location\n  resource_group_name = var.resource_group_name\n\n  ip_configuration {\n    name                          = \"internal\"\n    subnet_id                     = var.subnet_id\n    private_ip_address_allocation = \"Dynamic\"\n  }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"this\" {\n  name                = var.name\n  location            = var.location\n  resource_group_name = var.resource_group_name\n  size                  = var.vm_size\n  admin_username        = var.admin_username\n  network_interface_ids = &#91;azurerm_network_interface.this.id]\n\n  admin_ssh_key {\n    username   = var.admin_username\n    public_key = var.ssh_public_key\n  }\n\n  os_disk {\n    caching              = \"ReadWrite\"\n    storage_account_type = \"Standard_LRS\"\n  }\n\n  source_image_reference {\n    publisher = \"Canonical\"\n    offer     = \"ubuntu-26_04-lts\"\n    sku       = \"server\"\n    version   = \"latest\"\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/linux-vm\/outputs.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>output \"vm_id\" {\n  value = azurerm_linux_virtual_machine.this.id\n}\n\noutput \"vm_name\" {\n  value = azurerm_linux_virtual_machine.this.name\n}\n\noutput \"private_ip\" {\n  value = azurerm_network_interface.this.private_ip_address\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/linux-vm\/variables.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>variable \"name\"                { type = string }\nvariable \"location\"            { type = string }\nvariable \"resource_group_name\" { type = string }\nvariable \"subnet_id\"           { type = string }\nvariable \"ssh_public_key\"      { type = string }\n\nvariable \"vm_size\" {\n  type    = string\n  default = \"Standard_B2s_v2\"\n}\n\nvariable \"admin_username\" {\n  type    = string\n  default = \"labadmin\"\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/linux-vm\/versions.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform {\n  required_providers {\n    azurerm = {\n      source  = \"hashicorp\/azurerm\"\n      version = \"&gt;= 5.9.0\"\n    }\n  }\n}\n<\/code><\/pre>\n\n\n\n<h4 class=\"wp-block-heading\">Run this lab<\/h4>\n\n\n\n<pre class=\"wp-block-code\"><code>cd labs\/06-two-vms\nterraform init\nterraform fmt -recursive\nterraform validate\nterraform plan\n<em># Optional after reviewing the plan: terraform apply<\/em>\n<em># When done: terraform destroy<\/em>\n<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Lab 07 \u2014 Use one child module from dev and prod roots<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What changed:<\/strong>&nbsp;One shared module under&nbsp;<code>labs\/07-environments\/modules\/linux-vm<\/code>&nbsp;serves&nbsp;<strong>two root configurations<\/strong>,&nbsp;<code>environments\/dev<\/code>&nbsp;and&nbsp;<code>environments\/prod<\/code>. Each root has its own&nbsp;<code>terraform.tfvars<\/code>, Terraform state and naming\/CIDR values.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Start in\u00a0<code>labs\/07-environments\/environments\/dev<\/code>\u00a0and copy\u00a0<code>terraform.tfvars.example<\/code>\u00a0to\u00a0<code>terraform.tfvars<\/code>.<\/li>\n\n\n\n<li>Run the standard workflow\u00a0<strong>from dev\/<\/strong>; observe\u00a0<code>rg-tfmod-dev<\/code>\u00a0and\u00a0<code>vm-tfmod-dev<\/code>.<\/li>\n\n\n\n<li>Independently change into\u00a0<code>..\/prod<\/code>; copy its\u00a0<code>.example<\/code>\u00a0file and run its own\u00a0<code>init<\/code>\u00a0and\u00a0<code>plan<\/code>.<\/li>\n\n\n\n<li>Compare the distinct environment names, address spaces, module\u00a0<code>source = \"..\/..\/modules\/linux-vm\"<\/code>, and separate state paths.<\/li>\n\n\n\n<li>If you applied either root, destroy\u00a0<strong>that root<\/strong>\u00a0individually. Do not run Terraform from\u00a0<code>labs\/07-environments\/<\/code>\u00a0itself.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Checkpoint:<\/strong>&nbsp;Changing one&nbsp;<code>.tfvars<\/code>&nbsp;value in a&nbsp;<em>single shared root\/state<\/em>&nbsp;does not give you isolated dev\/prod environments. It typically changes\/replaces what that state already manages.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Mini-assignment:<\/strong>&nbsp;Set a different&nbsp;<code>vm_size<\/code>&nbsp;in the prod root&#8217;s&nbsp;<code>.tfvars<\/code>&nbsp;and confirm only its plan reflects the difference.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Production note:<\/strong>&nbsp;The&nbsp;<code>prod<\/code>&nbsp;folder is a&nbsp;<strong>teaching simulation<\/strong>, not a production-grade security\/HA deployment.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">Complete code for this lab<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">All paths below are relative to&nbsp;<code>labs\/07-environments\/<\/code>. The ZIP contains the identical ready-to-edit files.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>environments\/dev\/main.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>resource \"azurerm_resource_group\" \"lab\" {\n  name     = \"rg-tfmod-${var.environment}\"\n  location = var.azure_region\n}\n\nresource \"azurerm_virtual_network\" \"lab\" {\n  name                = \"vnet-tfmod-${var.environment}\"\n  location            = azurerm_resource_group.lab.location\n  resource_group_name = azurerm_resource_group.lab.name\n  address_space       = &#91;var.vnet_cidr]\n}\n\nresource \"azurerm_subnet\" \"vm\" {\n  name                 = \"snet-vm\"\n  resource_group_name  = azurerm_resource_group.lab.name\n  virtual_network_name = azurerm_virtual_network.lab.name\n  address_prefixes     = &#91;var.subnet_cidr]\n}\n\nmodule \"linux_vm\" {\n  source              = \"..\/..\/modules\/linux-vm\"\n  name                = \"vm-tfmod-${var.environment}\"\n  location            = azurerm_resource_group.lab.location\n  resource_group_name = azurerm_resource_group.lab.name\n  subnet_id           = azurerm_subnet.vm.id\n  ssh_public_key      = file(pathexpand(\"~\/.ssh\/id_ed25519.pub\"))\n  vm_size             = var.vm_size\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>environments\/dev\/outputs.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>output \"vm_name\" {\n  value = module.linux_vm.vm_name\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>environments\/dev\/terraform.tfvars.example<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>environment = \"dev\"\nazure_region = \"eastus\"\nvm_size      = \"Standard_B2s_v2\"\nvnet_cidr    = \"10.70.0.0\/16\"\nsubnet_cidr  = \"10.70.1.0\/24\"\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>environments\/dev\/variables.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>variable \"environment\" { type = string }\nvariable \"azure_region\" { type = string }\nvariable \"vm_size\"     { type = string }\nvariable \"vnet_cidr\"   { type = string }\nvariable \"subnet_cidr\" { type = string }\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>environments\/dev\/versions.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform {\n  required_version = \"&gt;= 1.15.0, &lt; 2.0.0\"\n\n  required_providers {\n    azurerm = {\n      source  = \"hashicorp\/azurerm\"\n      version = \"~&gt; 5.9.0\"\n    }\n  }\n}\n\nprovider \"azurerm\" {\n  features {}\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>environments\/prod\/main.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>resource \"azurerm_resource_group\" \"lab\" {\n  name     = \"rg-tfmod-${var.environment}\"\n  location = var.azure_region\n}\n\nresource \"azurerm_virtual_network\" \"lab\" {\n  name                = \"vnet-tfmod-${var.environment}\"\n  location            = azurerm_resource_group.lab.location\n  resource_group_name = azurerm_resource_group.lab.name\n  address_space       = &#91;var.vnet_cidr]\n}\n\nresource \"azurerm_subnet\" \"vm\" {\n  name                 = \"snet-vm\"\n  resource_group_name  = azurerm_resource_group.lab.name\n  virtual_network_name = azurerm_virtual_network.lab.name\n  address_prefixes     = &#91;var.subnet_cidr]\n}\n\nmodule \"linux_vm\" {\n  source              = \"..\/..\/modules\/linux-vm\"\n  name                = \"vm-tfmod-${var.environment}\"\n  location            = azurerm_resource_group.lab.location\n  resource_group_name = azurerm_resource_group.lab.name\n  subnet_id           = azurerm_subnet.vm.id\n  ssh_public_key      = file(pathexpand(\"~\/.ssh\/id_ed25519.pub\"))\n  vm_size             = var.vm_size\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>environments\/prod\/outputs.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>output \"vm_name\" {\n  value = module.linux_vm.vm_name\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>environments\/prod\/terraform.tfvars.example<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>environment = \"prod\"\nazure_region = \"eastus\"\nvm_size      = \"Standard_B2s_v2\"\nvnet_cidr    = \"10.80.0.0\/16\"\nsubnet_cidr  = \"10.80.1.0\/24\"\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>environments\/prod\/variables.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>variable \"environment\" { type = string }\nvariable \"azure_region\" { type = string }\nvariable \"vm_size\"     { type = string }\nvariable \"vnet_cidr\"   { type = string }\nvariable \"subnet_cidr\" { type = string }\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>environments\/prod\/versions.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform {\n  required_version = \"&gt;= 1.15.0, &lt; 2.0.0\"\n\n  required_providers {\n    azurerm = {\n      source  = \"hashicorp\/azurerm\"\n      version = \"~&gt; 5.9.0\"\n    }\n  }\n}\n\nprovider \"azurerm\" {\n  features {}\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/linux-vm\/main.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>resource \"azurerm_network_interface\" \"this\" {\n  name                = \"nic-${var.name}\"\n  location            = var.location\n  resource_group_name = var.resource_group_name\n\n  ip_configuration {\n    name                          = \"internal\"\n    subnet_id                     = var.subnet_id\n    private_ip_address_allocation = \"Dynamic\"\n  }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"this\" {\n  name                = var.name\n  location            = var.location\n  resource_group_name = var.resource_group_name\n  size                  = var.vm_size\n  admin_username        = var.admin_username\n  network_interface_ids = &#91;azurerm_network_interface.this.id]\n\n  admin_ssh_key {\n    username   = var.admin_username\n    public_key = var.ssh_public_key\n  }\n\n  os_disk {\n    caching              = \"ReadWrite\"\n    storage_account_type = \"Standard_LRS\"\n  }\n\n  source_image_reference {\n    publisher = \"Canonical\"\n    offer     = \"ubuntu-26_04-lts\"\n    sku       = \"server\"\n    version   = \"latest\"\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/linux-vm\/outputs.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>output \"vm_id\" {\n  value = azurerm_linux_virtual_machine.this.id\n}\n\noutput \"vm_name\" {\n  value = azurerm_linux_virtual_machine.this.name\n}\n\noutput \"private_ip\" {\n  value = azurerm_network_interface.this.private_ip_address\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/linux-vm\/variables.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>variable \"name\"                { type = string }\nvariable \"location\"            { type = string }\nvariable \"resource_group_name\" { type = string }\nvariable \"subnet_id\"           { type = string }\nvariable \"ssh_public_key\"      { type = string }\n\nvariable \"vm_size\" {\n  type    = string\n  default = \"Standard_B2s_v2\"\n}\n\nvariable \"admin_username\" {\n  type    = string\n  default = \"labadmin\"\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/linux-vm\/versions.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform {\n  required_providers {\n    azurerm = {\n      source  = \"hashicorp\/azurerm\"\n      version = \"&gt;= 5.9.0\"\n    }\n  }\n}\n<\/code><\/pre>\n\n\n\n<h4 class=\"wp-block-heading\">Run each environment independently<\/h4>\n\n\n\n<pre class=\"wp-block-code\"><code>cd labs\/07-environments\/environments\/dev\ncp terraform.tfvars.example terraform.tfvars\nterraform init &amp;&amp; terraform validate &amp;&amp; terraform plan\n<em># Optional: terraform apply, then terraform output vm_name; terraform destroy<\/em>\ncd ..\/prod\ncp terraform.tfvars.example terraform.tfvars\nterraform init &amp;&amp; terraform validate &amp;&amp; terraform plan\n<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Lab 08 \u2014 Compose a stack module from two child modules<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What changed:<\/strong>&nbsp;The root calls only&nbsp;<code>module \"stack\"<\/code>. Inside&nbsp;<code>modules\/stack<\/code>, the stack calls&nbsp;<code>module \"network\"<\/code>&nbsp;and&nbsp;<code>module \"linux_vm\"<\/code>. The network output&nbsp;<code>subnet_id<\/code>&nbsp;becomes an input to the VM child.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Read root\u00a0<code>main.tf<\/code>: one top-level\u00a0<code>module \"stack\"<\/code>\u00a0call.<\/li>\n\n\n\n<li>Read\u00a0<code>modules\/stack\/main.tf<\/code>. Find\u00a0<code>module.network.subnet_id<\/code>\u00a0passed as\u00a0<code>subnet_id<\/code>\u00a0to\u00a0<code>module.linux_vm<\/code>.<\/li>\n\n\n\n<li>Trace the actual Azure resources in\u00a0<code>modules\/network<\/code>\u00a0(RG, VNet, subnet) and\u00a0<code>modules\/linux-vm<\/code>\u00a0(NIC, VM).<\/li>\n\n\n\n<li>Run\u00a0<code>terraform init<\/code>,\u00a0<code>terraform validate<\/code>,\u00a0<code>terraform plan<\/code>\u00a0and inspect module nesting in the plan.<\/li>\n\n\n\n<li>Optionally apply and run\u00a0<code>terraform output vm_name<\/code>.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Checkpoint:<\/strong>&nbsp;The path&nbsp;<code>module.stack.module.linux_vm.azurerm_linux_virtual_machine.this<\/code>&nbsp;is one resource within a composed module tree.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Mini-assignment:<\/strong>&nbsp;Without changing the VM module, explain how a second stack could receive a different&nbsp;<code>project<\/code>&nbsp;argument.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">Complete code for this lab<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">All paths below are relative to&nbsp;<code>labs\/08-composition\/<\/code>. The ZIP contains the identical ready-to-edit files.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>main.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>module \"stack\" {\n  source         = \".\/modules\/stack\"\n  project        = \"tfmod-08\"\n  location       = \"eastus\"\n  ssh_public_key = file(pathexpand(\"~\/.ssh\/id_ed25519.pub\"))\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>outputs.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>output \"vm_name\" {\n  value = module.stack.vm_name\n}\n\noutput \"resource_group_name\" {\n  value = module.stack.resource_group_name\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>versions.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform {\n  required_version = \"&gt;= 1.15.0, &lt; 2.0.0\"\n\n  required_providers {\n    azurerm = {\n      source  = \"hashicorp\/azurerm\"\n      version = \"~&gt; 5.9.0\"\n    }\n  }\n}\n\nprovider \"azurerm\" {\n  features {}\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/linux-vm\/main.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>resource \"azurerm_network_interface\" \"this\" {\n  name                = \"nic-${var.name}\"\n  location            = var.location\n  resource_group_name = var.resource_group_name\n\n  ip_configuration {\n    name                          = \"internal\"\n    subnet_id                     = var.subnet_id\n    private_ip_address_allocation = \"Dynamic\"\n  }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"this\" {\n  name                = var.name\n  location            = var.location\n  resource_group_name = var.resource_group_name\n  size                  = var.vm_size\n  admin_username        = var.admin_username\n  network_interface_ids = &#91;azurerm_network_interface.this.id]\n\n  admin_ssh_key {\n    username   = var.admin_username\n    public_key = var.ssh_public_key\n  }\n\n  os_disk {\n    caching              = \"ReadWrite\"\n    storage_account_type = \"Standard_LRS\"\n  }\n\n  source_image_reference {\n    publisher = \"Canonical\"\n    offer     = \"ubuntu-26_04-lts\"\n    sku       = \"server\"\n    version   = \"latest\"\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/linux-vm\/outputs.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>output \"vm_id\" {\n  value = azurerm_linux_virtual_machine.this.id\n}\n\noutput \"vm_name\" {\n  value = azurerm_linux_virtual_machine.this.name\n}\n\noutput \"private_ip\" {\n  value = azurerm_network_interface.this.private_ip_address\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/linux-vm\/variables.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>variable \"name\"                { type = string }\nvariable \"location\"            { type = string }\nvariable \"resource_group_name\" { type = string }\nvariable \"subnet_id\"           { type = string }\nvariable \"ssh_public_key\"      { type = string }\n\nvariable \"vm_size\" {\n  type    = string\n  default = \"Standard_B2s_v2\"\n}\n\nvariable \"admin_username\" {\n  type    = string\n  default = \"labadmin\"\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/linux-vm\/versions.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform {\n  required_providers {\n    azurerm = {\n      source  = \"hashicorp\/azurerm\"\n      version = \"&gt;= 5.9.0\"\n    }\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/network\/main.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>resource \"azurerm_resource_group\" \"this\" {\n  name     = \"rg-${var.project}\"\n  location = var.location\n}\n\nresource \"azurerm_virtual_network\" \"this\" {\n  name                = \"vnet-${var.project}\"\n  location            = var.location\n  resource_group_name = azurerm_resource_group.this.name\n  address_space       = &#91;\"10.88.0.0\/16\"]\n}\n\nresource \"azurerm_subnet\" \"this\" {\n  name                 = \"snet-vm\"\n  resource_group_name  = azurerm_resource_group.this.name\n  virtual_network_name = azurerm_virtual_network.this.name\n  address_prefixes     = &#91;\"10.88.1.0\/24\"]\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/network\/outputs.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>output \"resource_group_name\" {\n  value = azurerm_resource_group.this.name\n}\n\noutput \"subnet_id\" {\n  value = azurerm_subnet.this.id\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/network\/variables.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>variable \"project\"  { type = string }\nvariable \"location\" { type = string }\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/network\/versions.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform {\n  required_providers {\n    azurerm = {\n      source  = \"hashicorp\/azurerm\"\n      version = \"&gt;= 5.9.0\"\n    }\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/stack\/main.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>module \"network\" {\n  source   = \"..\/network\"\n  project  = var.project\n  location = var.location\n}\n\nmodule \"linux_vm\" {\n  source              = \"..\/linux-vm\"\n  name                = \"vm-${var.project}\"\n  location            = var.location\n  resource_group_name = module.network.resource_group_name\n  subnet_id           = module.network.subnet_id\n  ssh_public_key      = var.ssh_public_key\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/stack\/outputs.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>output \"vm_name\" {\n  value = module.linux_vm.vm_name\n}\n\noutput \"resource_group_name\" {\n  value = module.network.resource_group_name\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/stack\/variables.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>variable \"project\"        { type = string }\nvariable \"location\"       { type = string }\nvariable \"ssh_public_key\" { type = string }\n<\/code><\/pre>\n\n\n\n<h4 class=\"wp-block-heading\">Run this lab<\/h4>\n\n\n\n<pre class=\"wp-block-code\"><code>cd labs\/08-composition\nterraform init\nterraform fmt -recursive\nterraform validate\nterraform plan\n<em># Optional after reviewing the plan: terraform apply<\/em>\n<em># When done: terraform destroy<\/em>\n<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Module 5 \u2014 Module Sources and Versioning<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Terraform can load a module from a&nbsp;<strong>local directory<\/strong>, a&nbsp;<strong>Git URL<\/strong>&nbsp;or the&nbsp;<strong>Terraform Registry<\/strong>. Local paths begin with&nbsp;<code>.\/<\/code>&nbsp;or&nbsp;<code>..\/<\/code>; Git sources use&nbsp;<code>git::...<\/code>&nbsp;and typically select a tag\/commit via&nbsp;<code>?ref=<\/code>; Registry sources use&nbsp;<code>namespace\/name\/provider<\/code>&nbsp;and support a separate&nbsp;<code>version<\/code>&nbsp;argument.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th class=\"has-text-align-left\" data-align=\"left\">Source<\/th><th class=\"has-text-align-left\" data-align=\"left\">Example<\/th><th class=\"has-text-align-left\" data-align=\"left\">How to select a release<\/th><\/tr><\/thead><tbody><tr><td>Local<\/td><td><code>.\/modules\/linux-vm<\/code><\/td><td>Files are part of the same repository\/working tree<\/td><\/tr><tr><td>Git<\/td><td><code>git::https:\/\/...repo.git?ref=0.4.3<\/code><\/td><td><code>?ref=<\/code>&nbsp;tag \/ commit hash \/ branch<\/td><\/tr><tr><td>Registry<\/td><td><code>Azure\/avm-res-resources-resourcegroup\/azurerm<\/code><\/td><td><code>version = \"0.4.0\"<\/code><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Use&nbsp;<em>pinned<\/em>&nbsp;module releases for repeatable training\/production. When changing a module&#8217;s&nbsp;<code>source<\/code>&nbsp;or selected release, run&nbsp;<code>terraform init<\/code>&nbsp;again.&nbsp;<code>terraform init -upgrade<\/code>&nbsp;requests the newest module\/provider dependencies&nbsp;<strong>allowed by your constraints<\/strong>; it cannot move an exact&nbsp;<code>version = \"0.4.0\"<\/code>&nbsp;to&nbsp;<code>0.5.0<\/code>&nbsp;until you edit that constraint.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Version-constraint experiment (do not run in an active deployment without reviewing the plan):<\/strong>&nbsp;Change one pinned Registry&nbsp;<code>version = \"0.4.0\"<\/code>&nbsp;to&nbsp;<code>version = \"~&gt; 0.4.0\"<\/code>. This permits compatible&nbsp;<code>0.4.x<\/code>&nbsp;patches, not&nbsp;<code>0.5.0<\/code>. Then run&nbsp;<code>terraform init -upgrade<\/code>, inspect the dependency selections and&nbsp;<code>terraform plan<\/code>, and pin the selected release again for repeatable teaching. Provider&nbsp;<code>.terraform.lock.hcl<\/code>&nbsp;changes and Registry module versions are related but not the same mechanism.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Azure&nbsp;<strong>Verified Modules (AVM)<\/strong>&nbsp;are real published modules maintained against Azure guidance. Their short callers can still install multiple providers and create several implementation resources; review upstream requirements, security defaults, licenses, examples and release history before adoption.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Lab 09 \u2014 Consume a Git module with a pinned tag<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What changed:<\/strong>&nbsp;The module&nbsp;<code>source<\/code>&nbsp;is no longer a&nbsp;<code>.\/local-path<\/code>. It is a remote Git source, pinned with&nbsp;<code>?ref=0.4.3<\/code>. This exercise uses Azure&#8217;s naming module solely to teach Git-source syntax; this specific repository was&nbsp;<strong>archived on September 15, 2026<\/strong>, so it is not a recommendation for new long-lived production dependencies.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Open\u00a0<code>main.tf<\/code>\u00a0and identify the\u00a0<code>git::https:\/\/...git?ref=0.4.3<\/code>\u00a0source.<\/li>\n\n\n\n<li>Run\u00a0<code>terraform init<\/code>; Terraform downloads the Git repository and its dependencies.<\/li>\n\n\n\n<li>Run\u00a0<code>terraform validate<\/code>,\u00a0<code>terraform plan<\/code>. After an optional\u00a0<code>apply<\/code>, inspect\u00a0<code>terraform output example_resource_group_name<\/code>.<\/li>\n\n\n\n<li>Change the tag\u00a0<strong>only after verifying a replacement release exists<\/strong>; rerun\u00a0<code>terraform init<\/code>\u00a0to refresh the source.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Checkpoint:<\/strong>&nbsp;Git modules use&nbsp;<code>?ref=&lt;tag|commit|branch&gt;<\/code>;&nbsp;<strong>do not<\/strong>&nbsp;use a&nbsp;<code>version<\/code>&nbsp;argument with an ordinary Git module source.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Mini-assignment:<\/strong>&nbsp;Write, on paper, a Git source URL pinned to a commit SHA instead of a moving branch.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">Complete code for this lab<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">All paths below are relative to&nbsp;<code>labs\/09-git-source\/<\/code>. The ZIP contains the identical ready-to-edit files.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>main.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform {\n  required_version = \"&gt;= 1.15.0, &lt; 2.0.0\"\n}\n\nmodule \"naming\" {\n  source = \"git::https:\/\/github.com\/Azure\/terraform-azurerm-naming.git?ref=0.4.3\"\n  suffix = &#91;\"tfmod\", \"git\"]\n}\n\noutput \"example_resource_group_name\" {\n  value = module.naming.resource_group.name\n}\n<\/code><\/pre>\n\n\n\n<h4 class=\"wp-block-heading\">Run this lab<\/h4>\n\n\n\n<pre class=\"wp-block-code\"><code>cd labs\/09-git-source\nterraform init\nterraform fmt -recursive\nterraform validate\nterraform plan\n<em># Optional after reviewing the plan: terraform apply<\/em>\n<em># When done: terraform destroy<\/em>\n<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Lab 10 \u2014 Use real Azure Registry\/AVM modules<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What changed:<\/strong>&nbsp;We now consume the public Terraform Registry using exact&nbsp;<code>source<\/code>&nbsp;and&nbsp;<code>version<\/code>&nbsp;values. Three published modules demonstrate naming, resource-group provisioning, and VNet\/subnet provisioning without writing the long Azure resources yourself.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Read\u00a0<code>module \"naming\"<\/code>\u00a0(<code>Azure\/naming\/azurerm<\/code>\u00a00.4.3),\u00a0<code>module \"rg\"<\/code>\u00a0(Azure Verified Resource Group 0.4.0),\u00a0<code>module \"vnet\"<\/code>\u00a0(Azure Verified Virtual Network 0.22.2).<\/li>\n\n\n\n<li>Notice how\u00a0<code>module.naming.resource_group.name<\/code>\u00a0flows into RG\u00a0<code>name<\/code>, then\u00a0<code>module.rg.resource_id<\/code>\u00a0flows into the VNet\u00a0<code>parent_id<\/code>.<\/li>\n\n\n\n<li>Run\u00a0<code>terraform init<\/code>\u00a0to download the external modules and required providers, including Azure AzAPI.<\/li>\n\n\n\n<li>Run\u00a0<code>terraform validate<\/code>,\u00a0<code>terraform plan<\/code>\u00a0and review the extra resources\/dependencies hidden by external modules. Apply optionally; then inspect\u00a0<code>terraform output vnet_id<\/code>.<\/li>\n\n\n\n<li>Inspect\u00a0<code>terraform providers<\/code>\u00a0and the upstream module&#8217;s examples\/README when investigating configuration or upgrades.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Checkpoint:<\/strong>&nbsp;Registry modules use&nbsp;<code>version = \"x.y.z\"<\/code>. An exact pin stays on that version even if you run&nbsp;<code>terraform init -upgrade<\/code>; update the requested version intentionally.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Mini-assignment:<\/strong>&nbsp;Compare the very short VNet call with the AzureRM resource blocks in Lab 05; describe the trade-off between code concision and dependency\/provider complexity.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Note:<\/strong>&nbsp;The naming dependency is archived; for real long-lived projects, evaluate maintenance and alternatives before adopting a module. The AVM resource-group and VNet modules are separate projects.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">Complete code for this lab<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">All paths below are relative to&nbsp;<code>labs\/10-registry-avm\/<\/code>. The ZIP contains the identical ready-to-edit files.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>main.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform {\n  required_version = \"&gt;= 1.15.0, &lt; 2.0.0\"\n\n  required_providers {\n    azapi = {\n      source  = \"Azure\/azapi\"\n      version = \"~&gt; 2.4\"\n    }\n  }\n}\n\nprovider \"azapi\" {}\n\nmodule \"naming\" {\n  source  = \"Azure\/naming\/azurerm\"\n  version = \"0.4.3\"\n  suffix  = &#91;\"tfmod\", \"10\"]\n}\n\nmodule \"rg\" {\n  source           = \"Azure\/avm-res-resources-resourcegroup\/azurerm\"\n  version          = \"0.4.0\"\n  name             = module.naming.resource_group.name\n  location         = \"eastus\"\n  enable_telemetry = false\n}\n\nmodule \"vnet\" {\n  source           = \"Azure\/avm-res-network-virtualnetwork\/azurerm\"\n  version          = \"0.22.2\"\n  name             = \"vnet-tfmod-10\"\n  location         = \"eastus\"\n  parent_id        = module.rg.resource_id\n  address_space    = &#91;\"10.90.0.0\/16\"]\n  enable_telemetry = false\n\n  subnets = {\n    vm = {\n      name             = \"snet-vm\"\n      address_prefixes = &#91;\"10.90.1.0\/24\"]\n    }\n  }\n}\n\noutput \"resource_group_id\" {\n  value = module.rg.resource_id\n}\n\noutput \"vnet_id\" {\n  value = module.vnet.resource_id\n}\n<\/code><\/pre>\n\n\n\n<h4 class=\"wp-block-heading\">Run this lab<\/h4>\n\n\n\n<pre class=\"wp-block-code\"><code>cd labs\/10-registry-avm\nterraform init\nterraform fmt -recursive\nterraform validate\nterraform plan\n<em># Optional after reviewing the plan: terraform apply<\/em>\n<em># When done: terraform destroy<\/em>\n<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Module 6 \u2014 Best Practices and Real-World Design<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Keep modules narrow but meaningful. A two-resource VM module is a good educational boundary; a separate custom module for every single argument would be overengineering. Modules should document a stable purpose, required\/optional inputs, outputs, provider constraints, supported Terraform versions and example call. At scale, add automated fmt\/validate\/test, protected state storage, CI checks and semantic releases.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Provider rule:<\/strong>&nbsp;Configure real provider credentials and&nbsp;<code>provider \"azurerm\" { features {} }<\/code>&nbsp;in the&nbsp;<strong>root<\/strong>. Reusable child modules declare&nbsp;<code>required_providers<\/code>, normally with a&nbsp;<strong>minimum<\/strong>&nbsp;compatible version, and inherit the root provider configuration; they must not each configure their own provider. The root pins the provider to a tested version range and commits the generated&nbsp;<code>.terraform.lock.hcl<\/code>&nbsp;in a real repository.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Suggested code review checklist:<\/strong>&nbsp;Clear module purpose; no surprising hidden Azure resources; stable module\/source versions; explicit input names\/descriptions\/types; safe secret handling; no hardcoded credentials; outputs kept minimal; independent environment states; readme\/example\/test maintained; evaluated replacements for any resource address or name change.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Simple module release flow:<\/strong>&nbsp;change code + docs \u2192 run&nbsp;<code>terraform fmt -recursive<\/code>&nbsp;\u2192&nbsp;<code>terraform validate<\/code>&nbsp;and mock plan tests \u2192 review compatibility \u2192 tag release in Git (<code>v1.0.0<\/code>) \u2192 publish to a Registry if needed \u2192 upgrade consumers intentionally and inspect plan. Avoid releasing breaking changes as a patch.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Lab 11 \u2014 Test and document modules without creating Azure infrastructure<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What changed:<\/strong>&nbsp;We return to the tiny Resource Group module to keep&nbsp;<strong>tests<\/strong>&nbsp;(not Azure plumbing) the teaching focus. The child declares&nbsp;<code>required_providers<\/code>. A&nbsp;<code>.tftest.hcl<\/code>&nbsp;uses&nbsp;<code>mock_provider \"azurerm\" {}<\/code>&nbsp;and two plan tests.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Read the test file: the first test expects\u00a0<code>rg-tfmod-11<\/code>; the second expects a failure when\u00a0<code>rg_name<\/code>\u00a0lacks the required\u00a0<code>rg-<\/code>\u00a0prefix.<\/li>\n\n\n\n<li>Run\u00a0<code>terraform init<\/code>,\u00a0<code>terraform fmt -recursive<\/code>,\u00a0<code>terraform validate<\/code>, and\u00a0<code>terraform test -verbose<\/code>.<\/li>\n\n\n\n<li>Confirm no Azure resources were deployed; do\u00a0<strong>not<\/strong>\u00a0run\u00a0<code>terraform apply<\/code>\u00a0unless intentionally testing a real cloud deployment.<\/li>\n\n\n\n<li>As a maintenance exercise, document inputs, outputs, provider requirements, supported versions, and release notes for the RG module.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Checkpoint:<\/strong>&nbsp;Terraform test runs can create real resources by default. In&nbsp;<strong>this<\/strong>&nbsp;test file,&nbsp;<code>command = plan<\/code>&nbsp;and&nbsp;<code>mock_provider<\/code>&nbsp;are used to avoid them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Mini-assignment:<\/strong>&nbsp;Edit the success assertion to expect a wrong name and confirm that your test detects the error, then restore it.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">Complete code for this lab<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">All paths below are relative to&nbsp;<code>labs\/11-tests-quality\/<\/code>. The ZIP contains the identical ready-to-edit files.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>main.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>module \"rg\" {\n  source   = \".\/modules\/resource-group\"\n  name     = var.rg_name\n  location = \"eastus\"\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>outputs.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>output \"resource_group_name\" {\n  value = module.rg.name\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>variables.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>variable \"rg_name\" {\n  type    = string\n  default = \"rg-tfmod-11\"\n\n  validation {\n    condition     = startswith(var.rg_name, \"rg-\")\n    error_message = \"The resource group name must start with rg-.\"\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>versions.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform {\n  required_version = \"&gt;= 1.15.0, &lt; 2.0.0\"\n\n  required_providers {\n    azurerm = {\n      source  = \"hashicorp\/azurerm\"\n      version = \"~&gt; 5.9.0\"\n    }\n  }\n}\n\nprovider \"azurerm\" {\n  features {}\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>tests\/module.tftest.hcl<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># The provider is mocked: no Azure resources are created by this test.\nmock_provider \"azurerm\" {}\n\nrun \"accept_valid_resource_group_name\" {\n  command = plan\n\n  assert {\n    condition     = module.rg.name == \"rg-tfmod-11\"\n    error_message = \"The child module should preserve the requested name.\"\n  }\n}\n\nrun \"reject_invalid_resource_group_name\" {\n  command = plan\n\n  variables {\n    rg_name = \"bad-name\"\n  }\n\n  expect_failures = &#91;var.rg_name]\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/resource-group\/main.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>resource \"azurerm_resource_group\" \"this\" {\n  name     = var.name\n  location = var.location\n  tags     = var.tags\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/resource-group\/outputs.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>output \"name\" {\n  description = \"Name created by the module.\"\n  value       = azurerm_resource_group.this.name\n}\n\noutput \"id\" {\n  description = \"Azure resource group ID.\"\n  value       = azurerm_resource_group.this.id\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/resource-group\/variables.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>variable \"name\" {\n  description = \"Resource group name.\"\n  type        = string\n}\n\nvariable \"location\" {\n  description = \"Azure region.\"\n  type        = string\n}\n\nvariable \"tags\" {\n  description = \"Optional resource tags.\"\n  type        = map(string)\n  default     = { course = \"terraform-modules\" }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/resource-group\/versions.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform {\n  required_providers {\n    azurerm = {\n      source  = \"hashicorp\/azurerm\"\n      version = \"&gt;= 5.9.0\"\n    }\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>modules\/resource-group\/README.md<\/code>&nbsp;\u2014 module documentation and example call<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># Resource Group teaching module\n\nSmall module used for the test and maintenance lab. It creates <strong>**one**<\/strong> Azure Resource Group. Configure AzureRM only in the calling root; the child declares its `required_providers`.\n\n## Input interface\n\n| Name | Type | Required? | Description |\n|---|---|---|---|\n| `name` | `string` | Yes | Resource group name |\n| `location` | `string` | Yes | Azure region |\n| `tags` | `map(string)` | No | Default course tag provided |\n\n## Output interface\n\n| Name | Meaning |\n|---|---|\n| `name` | Azure resource group name |\n| `id` | Azure resource group resource ID |\n\n## Example root call\n\n```hcl\nmodule \"rg\" {\n  source   = \".\/modules\/resource-group\"\n  name     = \"rg-tfmod-11\"\n  location = \"eastus\"\n}\n```\n\n## Quality and release checklist\n\nRun `terraform fmt -recursive`, `terraform validate`, and `terraform test` (from the root). Review output\/variable interface compatibility before tagging a new module release. Use a provider minimum requirement in this module and a tested, bounded provider constraint in the root.\n<\/code><\/pre>\n\n\n\n<h4 class=\"wp-block-heading\">Run the mock tests (no Azure deployment)<\/h4>\n\n\n\n<pre class=\"wp-block-code\"><code>cd labs\/11-tests-quality\nterraform init\nterraform fmt -recursive\nterraform validate\nterraform test -verbose\n<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Lab 12 \u2014 Safely move an existing resource into a child module<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What changed:<\/strong>&nbsp;Unlike Labs 01\u201311, this advanced mini-lab&nbsp;<strong>intentionally preserves the same existing Terraform state<\/strong>&nbsp;while refactoring from a root resource to an identically configured resource inside a child module.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Enter\u00a0<code>labs\/12-bonus-moved-block\/before<\/code>; run\u00a0<code>terraform init<\/code>,\u00a0<code>terraform plan<\/code>, and (if you can safely create a sample RG)\u00a0<code>terraform apply<\/code>.<\/li>\n\n\n\n<li>Save a\u00a0<strong>private backup<\/strong>\u00a0of the Terraform state.<\/li>\n\n\n\n<li>From inside\u00a0<strong>that same\u00a0<code>before<\/code>\u00a0directory<\/strong>, copy the configuration in\u00a0<code>..\/after\/<\/code>\u00a0into the current directory with\u00a0<code>cp -R ..\/after\/. .\/<\/code>.<\/li>\n\n\n\n<li>Run\u00a0<code>terraform init<\/code>\u00a0and\u00a0<code>terraform plan<\/code>. Look for wording that the address\u00a0<code>azurerm_resource_group.lab<\/code>\u00a0<em>has moved to<\/em>\u00a0<code>module.rg.azurerm_resource_group.this<\/code>\u00a0rather than delete-and-create operations.<\/li>\n\n\n\n<li>Apply after inspection.\u00a0<code>terraform state list<\/code>\u00a0now shows the child address. Destroy when done.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Checkpoint:<\/strong>&nbsp;A&nbsp;<code>moved<\/code>&nbsp;block changes Terraform&#8217;s address bookkeeping; it does not rewrite the actual Azure object. Matching real-world resource arguments is still necessary to avoid replacements.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Mini-assignment:<\/strong>&nbsp;Explain why changing a resource name&nbsp;<strong>at the same time<\/strong>&nbsp;as moving its address can still cause replacement.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Safety note:<\/strong>&nbsp;The user&#8217;s earlier 13-resource Windows\/Ubuntu lab needs a separately reviewed move plan for&nbsp;<em>each<\/em>&nbsp;transferred resource; do not paste this one-resource demo into its active state and assume all 13 resources are migrated.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">Complete code for this lab<\/h4>\n\n\n\n<p class=\"wp-block-paragraph\">All paths below are relative to&nbsp;<code>labs\/12-bonus-moved-block\/<\/code>. The ZIP contains the identical ready-to-edit files.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>after\/main.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>module \"rg\" {\n  source = \".\/modules\/resource-group\"\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>after\/moved.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>moved {\n  from = azurerm_resource_group.lab\n  to   = module.rg.azurerm_resource_group.this\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>after\/versions.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform {\n  required_version = \"&gt;= 1.15.0, &lt; 2.0.0\"\n  required_providers {\n    azurerm = {\n      source  = \"hashicorp\/azurerm\"\n      version = \"~&gt; 5.9.0\"\n    }\n  }\n}\nprovider \"azurerm\" { features {} }\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>before\/main.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>resource \"azurerm_resource_group\" \"lab\" {\n  name     = \"rg-tfmod-move\"\n  location = \"eastus\"\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>before\/versions.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform {\n  required_version = \"&gt;= 1.15.0, &lt; 2.0.0\"\n  required_providers {\n    azurerm = {\n      source  = \"hashicorp\/azurerm\"\n      version = \"~&gt; 5.9.0\"\n    }\n  }\n}\nprovider \"azurerm\" { features {} }\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>after\/modules\/resource-group\/main.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>resource \"azurerm_resource_group\" \"this\" {\n  name     = \"rg-tfmod-move\"\n  location = \"eastus\"\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>after\/modules\/resource-group\/versions.tf<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform {\n  required_providers {\n    azurerm = {\n      source  = \"hashicorp\/azurerm\"\n      version = \"&gt;= 5.9.0\"\n    }\n  }\n}\n<\/code><\/pre>\n\n\n\n<h4 class=\"wp-block-heading\">Important: execute inside the SAME state directory<\/h4>\n\n\n\n<pre class=\"wp-block-code\"><code>cd labs\/12-bonus-moved-block\/before\nterraform init\nterraform apply    <em># creates initial RG after showing the plan<\/em>\n<em># Back up terraform.tfstate securely outside any shared\/public directory first.<\/em>\ncp -R ..\/after\/. .\/\nterraform init\nterraform plan     <em># expect an address move, not resource recreation<\/em>\nterraform apply    <em># review plan first<\/em>\nterraform state list\nterraform destroy\n<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Reference \u2014 common questions and troubleshooting<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th class=\"has-text-align-left\" data-align=\"left\">Question or symptom<\/th><th class=\"has-text-align-left\" data-align=\"left\">Likely cause<\/th><th class=\"has-text-align-left\" data-align=\"left\">Action<\/th><\/tr><\/thead><tbody><tr><td><code>Module not installed<\/code><\/td><td>New\/changed&nbsp;<code>source<\/code>&nbsp;was not initialized<\/td><td>Run&nbsp;<code>terraform init<\/code>&nbsp;from the root directory<\/td><\/tr><tr><td><code>No value for required variable<\/code><\/td><td>Missing root&nbsp;<code>.tfvars<\/code>&nbsp;or module argument<\/td><td>Copy&nbsp;<code>.tfvars.example<\/code>&nbsp;into the root and pass every required input<\/td><\/tr><tr><td><code>Unsupported argument<\/code>&nbsp;in a&nbsp;<code>module<\/code>&nbsp;block<\/td><td>Caller uses a name not declared by child&#8217;s&nbsp;<code>variable<\/code>&nbsp;blocks<\/td><td>Match input names exactly<\/td><\/tr><tr><td><code>Unsupported attribute<\/code>&nbsp;on&nbsp;<code>module.xyz<\/code><\/td><td>Child has no matching&nbsp;<code>output<\/code>&nbsp;block<\/td><td>Add an output in the child, then reference its name<\/td><\/tr><tr><td>Git&nbsp;<code>ref<\/code>&nbsp;not found<\/td><td>Tag is misspelled or uses the wrong prefix (<code>v<\/code>&nbsp;vs no&nbsp;<code>v<\/code>)<\/td><td>Verify tags; Lab 09 uses&nbsp;<strong><code>0.4.3<\/code><\/strong>, not&nbsp;<code>v0.4.3<\/code><\/td><\/tr><tr><td>Registry module doesn&#8217;t accept a&nbsp;<code>version<\/code>&nbsp;or cannot install<\/td><td>Source syntax\/version mismatch or missing internet<\/td><td>Use Registry&nbsp;<code>namespace\/name\/provider<\/code>; rerun&nbsp;<code>init<\/code>, check current module version<\/td><\/tr><tr><td>Azure authentication fails<\/td><td>Wrong subscription, CLI credentials, policy or missing&nbsp;<code>ARM_SUBSCRIPTION_ID<\/code><\/td><td><code>az login<\/code>,&nbsp;<code>az account set<\/code>, export subscription ID<\/td><\/tr><tr><td>SSH-key file not found<\/td><td><code>~\/.ssh\/id_ed25519.pub<\/code>&nbsp;doesn&#8217;t exist<\/td><td>Generate an Ed25519 key or update the&nbsp;<code>file()<\/code>&nbsp;path to your existing public key<\/td><\/tr><tr><td>VM SKU\/image not found<\/td><td>Offer\/SKU not available in selected region\/subscription<\/td><td>Query Azure image &amp; SKU catalogs; select approved equivalent<\/td><\/tr><tr><td>VM has no public IP<\/td><td>Intentional private-only module design<\/td><td>Verify with Azure CLI\/portal; use approved private connectivity for SSH<\/td><\/tr><tr><td><code>terraform test<\/code>&nbsp;unexpectedly deploys resources<\/td><td>Test uses default&nbsp;<code>command = apply<\/code>&nbsp;without mocking<\/td><td>Use&nbsp;<code>mock_provider<\/code>&nbsp;plus&nbsp;<code>command = plan<\/code>&nbsp;as in Lab 11<\/td><\/tr><tr><td>Plan proposes unexpected destroy\/create after extracting a module<\/td><td>Terraform address moved, or a real resource setting changed<\/td><td>Use reviewed&nbsp;<code>moved<\/code>&nbsp;blocks; match old resource configuration; don&#8217;t blindly apply<\/td><\/tr><tr><td>Environments overwrite each other<\/td><td>Reusing one root\/state for dev and prod<\/td><td>Use separate root directories and separate state\/backends<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Instructor discussion: when should we&nbsp;<em>not<\/em>&nbsp;create a module?<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Use standalone resources when there is one small, context-specific object and no useful reused interface. Prefer a small in-repo module when a team repeats a coherent group of objects (for example, NIC + Linux VM) and the inputs\/outputs remain understandable. Prefer mature Registry\/AVM modules when their documented behavior aligns with the team&#8217;s needs and the operational benefits justify extra dependencies.&nbsp;<strong>Less code in the root is not automatically less complexity.<\/strong><\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Optional module topics after this course<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Once learners are comfortable, extend the same VM module with&nbsp;<code>for_each<\/code>&nbsp;to create a map of named instances, tags, optional OS disk settings and explicit provider aliases across subscriptions. Treat these as&nbsp;<strong>next-course material<\/strong>\u2014they are intentionally excluded from the core labs so learners focus on module mechanics first. Remote state\/backends and CI\/CD for module publishing are separate follow-on lessons.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Recommended assessment \u2014 short hands-on tasks<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Assignment 1: First reusable module.<\/strong>&nbsp;Create a&nbsp;<code>modules\/resource-group<\/code>&nbsp;child, accept&nbsp;<code>name<\/code>&nbsp;and&nbsp;<code>location<\/code>, and return&nbsp;<code>id<\/code>. Call it from a root and show a successful&nbsp;<code>terraform validate<\/code>&nbsp;and plan.&nbsp;<strong>Acceptance:<\/strong>&nbsp;no resource group defined directly in the root, inputs are passed through the module block, and the output references&nbsp;<code>module.rg.id<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Assignment 2: Reuse the VM module.<\/strong>&nbsp;Use one Linux VM module twice with different names in a single VNet and subnet.&nbsp;<strong>Acceptance:<\/strong>&nbsp;two module calls, two separate VM state addresses, same module source, no public IP. Applying is optional; a valid, reviewed plan is sufficient for a classroom submission.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Assignment 3: External modules.<\/strong>&nbsp;Compare a Git source using&nbsp;<code>?ref=<\/code>&nbsp;and a Registry source using&nbsp;<code>version<\/code>. Explain&nbsp;<code>terraform init -upgrade<\/code>, exact pinning and why external modules may have additional provider dependencies.&nbsp;<strong>Acceptance:<\/strong>&nbsp;learners can identify the pinning mechanism correctly and show successful&nbsp;<code>init\/validate<\/code>&nbsp;for an example with network access.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Assignment 4: Module quality.<\/strong>&nbsp;Add a mock Terraform test checking a resource-group naming rule.&nbsp;<strong>Acceptance:<\/strong>&nbsp;one passing configuration test and one expected failure test; no real Azure resource is created by the tests.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Final review checklist<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>[ ] Can explain root module vs child module and the difference between resource addresses and Azure names.<\/li>\n\n\n\n<li>[ ] Can create a module with\u00a0<code>main.tf<\/code>,\u00a0<code>variables.tf<\/code>,\u00a0<code>outputs.tf<\/code>\u00a0and root\u00a0<code>module<\/code>\u00a0block.<\/li>\n\n\n\n<li>[ ] Knows where\u00a0<code>.tfvars<\/code>\u00a0files are loaded and how optional defaults work.<\/li>\n\n\n\n<li>[ ] Can pass an output from one module into another module&#8217;s input.<\/li>\n\n\n\n<li>[ ] Understands\u00a0<code>sensitive<\/code>\u00a0is presentation redaction, not secure state storage.<\/li>\n\n\n\n<li>[ ] Can reuse the same VM module twice and across separately managed dev\/prod roots.<\/li>\n\n\n\n<li>[ ] Can distinguish local, Git and Registry source pinning.<\/li>\n\n\n\n<li>[ ] Can inspect upstream AVM module inputs\/outputs\/dependencies before adopting it.<\/li>\n\n\n\n<li>[ ] Knows provider configurations stay in root and child modules declare\u00a0<code>required_providers<\/code>.<\/li>\n\n\n\n<li>[ ] Can run a mock plan test and use\u00a0<code>moved<\/code>\u00a0safely when extracting resources into modules.<\/li>\n\n\n\n<li>[ ] Has destroyed optional demo Azure resources and preserved any required state safely.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Source references and versions checked (14 October 2026)<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Prior student reference: uploaded\u00a0<code>Azure_Terraform_Part_2_Variables_All_6_Types(1).md<\/code>\u00a0(AzureRM 5.9.x, Terraform 1.15.x, 26.04 LTS image, region and naming conventions).<\/li>\n\n\n\n<li>Terraform\u00a0<a href=\"https:\/\/developer.hashicorp.com\/terraform\/language\/modules\/configuration\">Use modules<\/a>\u00a0\u2014 source\/version, outputs and init.<\/li>\n\n\n\n<li>Terraform\u00a0<a href=\"https:\/\/developer.hashicorp.com\/terraform\/language\/modules\/develop\/providers\">Provider configurations within modules<\/a>\u00a0\u2014 root provider vs child\u00a0<code>required_providers<\/code>.<\/li>\n\n\n\n<li>Terraform\u00a0<a href=\"https:\/\/developer.hashicorp.com\/terraform\/language\/modules\/develop\/refactoring\">Refactor modules<\/a>\u00a0\u2014 safe\u00a0<code>moved<\/code>\u00a0address changes.<\/li>\n\n\n\n<li>Terraform\u00a0<a href=\"https:\/\/developer.hashicorp.com\/terraform\/language\/tests\/mocking\">Provider mocking in tests<\/a>\u00a0\u2014\u00a0<code>mock_provider<\/code>.<\/li>\n\n\n\n<li>Terraform\u00a0<a href=\"https:\/\/developer.hashicorp.com\/terraform\/language\/tests\">Tests<\/a>\u00a0\u2014 plan tests,\u00a0<code>expect_failures<\/code>\u00a0and cleanup cautions.<\/li>\n\n\n\n<li>Terraform Registry\u00a0<a href=\"https:\/\/registry.terraform.io\/modules\/Azure\/naming\/azurerm\/0.4.3\">Azure naming 0.4.3<\/a>\u00a0and GitHub\u00a0<a href=\"https:\/\/github.com\/Azure\/terraform-azurerm-naming\/releases\/tag\/0.4.3\">release tag\u00a0<code>0.4.3<\/code><\/a>.\u00a0<em>Note: its repository was archived in September 2026.<\/em><\/li>\n\n\n\n<li>Terraform Registry\u00a0<a href=\"https:\/\/registry.terraform.io\/modules\/Azure\/avm-res-resources-resourcegroup\/azurerm\/0.4.0\">Azure Verified Resource Group 0.4.0<\/a>\u00a0\u2014\u00a0<code>name<\/code>,\u00a0<code>location<\/code>,\u00a0<code>resource_id<\/code>.<\/li>\n\n\n\n<li>Terraform Registry\u00a0<a href=\"https:\/\/registry.terraform.io\/modules\/Azure\/avm-res-network-virtualnetwork\/azurerm\/0.22.2\">Azure Verified Virtual Network 0.22.2<\/a>\u00a0\u2014\u00a0<code>parent_id<\/code>,\u00a0<code>address_space<\/code>,\u00a0<code>subnets<\/code>,\u00a0<code>resource_id<\/code>.<\/li>\n\n\n\n<li>AzureRM provider\u00a0<a href=\"https:\/\/github.com\/hashicorp\/terraform-provider-azurerm\/blob\/main\/CHANGELOG.md\">release notes<\/a>\u00a0\u2014 5.9.0 released 8 October 2026.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">Verification status and limitations<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">All lab source files were assembled and subjected to local static structural checks.&nbsp;<strong>No authenticated Azure&nbsp;<code>apply<\/code>&nbsp;and no Terraform CLI&nbsp;<code>init<\/code>,&nbsp;<code>validate<\/code>,&nbsp;<code>plan<\/code>&nbsp;or&nbsp;<code>test<\/code>&nbsp;were executed in the authoring environment<\/strong>, because Terraform\/providers and network\/cloud access were not available there. The first live run must use your subscription and verify the actual pinned versions, quota, policies, image\/SKU availability and any upstream module changes. Do not interpret static checks as a guarantee that Azure will provision every example.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Course level:&nbsp;Beginner \u2192 IntermediateCourse type:&nbsp;Tutorial + runnable hands-on exercises + instructor checkpointsUpdated:&nbsp;14 October 2026Terraform:&nbsp;&gt;= 1.15.0, &lt; 2.0.0&nbsp;(lab baseline: 1.15.9)AzureRM provider:&nbsp;~&gt; 5.9.0&nbsp;(root; child modules declare&nbsp;&gt;= 5.9.0)Azure baseline:&nbsp;eastus, Ubuntu&#8230; <\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1],"tags":[],"class_list":["post-1235","post","type-post","status-publish","format-standard","hentry","category-uncategorized"],"_links":{"self":[{"href":"https:\/\/www.devopsschool.com\/tutorials\/wp-json\/wp\/v2\/posts\/1235","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.devopsschool.com\/tutorials\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.devopsschool.com\/tutorials\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.devopsschool.com\/tutorials\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.devopsschool.com\/tutorials\/wp-json\/wp\/v2\/comments?post=1235"}],"version-history":[{"count":1,"href":"https:\/\/www.devopsschool.com\/tutorials\/wp-json\/wp\/v2\/posts\/1235\/revisions"}],"predecessor-version":[{"id":1236,"href":"https:\/\/www.devopsschool.com\/tutorials\/wp-json\/wp\/v2\/posts\/1235\/revisions\/1236"}],"wp:attachment":[{"href":"https:\/\/www.devopsschool.com\/tutorials\/wp-json\/wp\/v2\/media?parent=1235"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.devopsschool.com\/tutorials\/wp-json\/wp\/v2\/categories?post=1235"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.devopsschool.com\/tutorials\/wp-json\/wp\/v2\/tags?post=1235"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}