{"id":1237,"date":"2026-10-14T12:44:00","date_gmt":"2026-10-14T12:44:00","guid":{"rendered":"https:\/\/www.devopsschool.com\/tutorials\/?p=1237"},"modified":"2026-10-14T12:44:02","modified_gmt":"2026-10-14T12:44:02","slug":"terraform-lab-azure-hands-on-terraform-template","status":"publish","type":"post","link":"https:\/\/www.devopsschool.com\/tutorials\/terraform-lab-azure-hands-on-terraform-template\/","title":{"rendered":"Terraform Lab \u2013 Azure Hands-On \u2013\u00a0Terraform Template"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\"><strong>Level:<\/strong>&nbsp;Beginner to Intermediate<br><strong>Format:<\/strong>&nbsp;Tutorial + concept guide + practical lab workbook<br><strong>Modules:<\/strong>&nbsp;6 |&nbsp;<strong>Labs:<\/strong>&nbsp;12 |&nbsp;<strong>Cloud emphasis:<\/strong>&nbsp;Azure Linux VM, AWS EC2 comparison<br><strong>Baseline:<\/strong>&nbsp;Terraform 1.15.9+ (within 1.x); AzureRM 5.9.x; AWS provider 6.x<br><strong>Updated:<\/strong>&nbsp;14 October 2026<br><strong>Focus:<\/strong>&nbsp;Learn Terraform&nbsp;<strong>string templates and&nbsp;<code>templatefile()<\/code><\/strong>, not Terraform module development or general cloud networking.<\/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>Connection to your previous Azure exercises:<\/strong>&nbsp;Your earlier Labs 1\u20134 \/ six-variable lesson used AzureRM 5.9.x, an Ubuntu 26.04 VM, a VNet\/subnet\/NIC and a larger 13-resource Windows+Ubuntu setup. This course reuses those familiar concepts but intentionally reduces the cloud portion to&nbsp;<strong>five Azure resources and one Linux VM<\/strong>. These are&nbsp;<strong>fresh standalone labs with independent Terraform state<\/strong>, not drop-in replacements for the earlier applied state. Do not overwrite your previous labs&#8217;&nbsp;<code>main.tf<\/code>&nbsp;or state.<\/p>\n<\/blockquote>\n\n\n\n<h2 class=\"wp-block-heading\">Learning outcomes<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">By completion, a learner can explain template scope, render strings and files, use conditional and loop directives, control whitespace, escape Terraform sequences, encode structured data safely, generate and inspect Linux shell scripts and cloud-init, attach a rendered template to an Azure VM, distinguish Azure&nbsp;<code>custom_data<\/code>&nbsp;from AWS&nbsp;<code>user_data<\/code>, and validate generated artifacts before deployment.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Requirements and setup<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Terraform CLI. The guide targets the user-provided\u00a0<strong>Terraform 1.15.9<\/strong>\u00a0baseline. Check\u00a0<code>terraform version<\/code>.<\/li>\n\n\n\n<li>Text editor; Bash or POSIX shell;\u00a0<code>python3<\/code>\u00a0for an optional JSON syntax check.<\/li>\n\n\n\n<li>Labs 01\u201309 need\u00a0<strong>no cloud provider, credentials or paid resources<\/strong>.<\/li>\n\n\n\n<li>Azure Labs 10 and 12: Azure CLI, a permitted subscription, AzureRM 5.9.x provider access, an existing local SSH\u00a0<strong>public key<\/strong>, quota for the selected VM, and permissions to deploy five resources.<\/li>\n\n\n\n<li>Optional AWS Lab 11: AWS CLI\/credentials, an existing subnet, a matching Linux AMI and permission to create EC2 instances.<\/li>\n\n\n\n<li>Both cloud deployments incur cost until\u00a0<code>terraform destroy<\/code>. Review the plan before any\u00a0<code>apply<\/code>.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One-time setup<\/strong>&nbsp;(macOS\/Linux):<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform version\n<em># For Azure labs only:<\/em>\naz login\naz account set --subscription \"YOUR_SUBSCRIPTION_ID\"\nexport ARM_SUBSCRIPTION_ID=\"$(az account show --query id -o tsv)\"\n<em># Check your public key before running Azure labs:<\/em>\nls -l \"$HOME\/.ssh\/id_ed25519.pub\" || true\n<em># Generate a new key only when neither file already exists:<\/em>\nif &#91; ! -e \"$HOME\/.ssh\/id_ed25519\" ] &amp;&amp; &#91; ! -e \"$HOME\/.ssh\/id_ed25519.pub\" ]; then\n  ssh-keygen -t ed25519 -f \"$HOME\/.ssh\/id_ed25519\"\nfi\n<em># Confirm there is a usable public key. If not, use a different existing key.<\/em>\ntest -f \"$HOME\/.ssh\/id_ed25519.pub\"\n<\/code><\/pre>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\">Skip&nbsp;<code>ssh-keygen<\/code>&nbsp;when the public key already exists. If your organization uses a different key, pass&nbsp;<code>-var='ssh_public_key_path=\/absolute\/path\/to\/key.pub'<\/code>. We&nbsp;<strong>never<\/strong>&nbsp;put passwords, private keys, tokens or secrets inside example templates.<\/p>\n<\/blockquote>\n\n\n\n<h2 class=\"wp-block-heading\">Training architecture<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>flowchart TB\n    A&#91;Input variables \/ HCL data] --&gt; B&#91;templatefile path + vars object]\n    T&#91;templates\/*.tftpl] --&gt; B\n    B --&gt; C&#91;Rendered UTF-8 text]\n    C --&gt; D&#91;\"Outputs and local previews&lt;br\/&gt;Labs 01-09\"]\n    C --&gt; E&#91;\"Azure VM custom_data&lt;br\/&gt;base64encode Lab 10\/12\"]\n    C --&gt; F&#91;\"AWS EC2 user_data&lt;br\/&gt;plain text Lab 11\"]\n    E --&gt; G&#91;cloud-init at first boot]\n    F --&gt; H&#91;User-data script at first boot]\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Progressive lab map<\/h3>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th class=\"has-text-align-right\" data-align=\"right\">Lab<\/th><th class=\"has-text-align-left\" data-align=\"left\">Module<\/th><th class=\"has-text-align-left\" data-align=\"left\">Adds one main concept<\/th><th class=\"has-text-align-left\" data-align=\"left\">Run environment<\/th><\/tr><\/thead><tbody><tr><td class=\"has-text-align-right\" data-align=\"right\">01<\/td><td>1<\/td><td>Inline string interpolation<\/td><td>Local, free<\/td><\/tr><tr><td class=\"has-text-align-right\" data-align=\"right\">02<\/td><td>2<\/td><td><code>.tftpl<\/code>&nbsp;and&nbsp;<code>templatefile()<\/code><\/td><td>Local, free<\/td><\/tr><tr><td class=\"has-text-align-right\" data-align=\"right\">03<\/td><td>2<\/td><td>Expressions and nested object scope<\/td><td>Local, free<\/td><\/tr><tr><td class=\"has-text-align-right\" data-align=\"right\">04<\/td><td>3<\/td><td><code>%{ if }<\/code>,&nbsp;<code>%{ else }<\/code>,&nbsp;<code>%{ endif }<\/code><\/td><td>Local, free<\/td><\/tr><tr><td class=\"has-text-align-right\" data-align=\"right\">05<\/td><td>3<\/td><td><code>%{ for }<\/code>&nbsp;over a list<\/td><td>Local, free<\/td><\/tr><tr><td class=\"has-text-align-right\" data-align=\"right\">06<\/td><td>3<\/td><td>Map iteration and&nbsp;<code>~<\/code>&nbsp;whitespace<\/td><td>Local, free<\/td><\/tr><tr><td class=\"has-text-align-right\" data-align=\"right\">07<\/td><td>4<\/td><td><code>jsonencode()<\/code>&nbsp;and template debugging<\/td><td>Local, free<\/td><\/tr><tr><td class=\"has-text-align-right\" data-align=\"right\">08<\/td><td>5<\/td><td>Linux shell startup script + escaping<\/td><td>Local, free<\/td><\/tr><tr><td class=\"has-text-align-right\" data-align=\"right\">09<\/td><td>5<\/td><td>Cloud-init YAML preview<\/td><td>Local, free<\/td><\/tr><tr><td class=\"has-text-align-right\" data-align=\"right\">10<\/td><td>5<\/td><td>Azure VM&nbsp;<code>custom_data<\/code><\/td><td>Azure (billable)<\/td><\/tr><tr><td class=\"has-text-align-right\" data-align=\"right\">11<\/td><td>5<\/td><td>AWS EC2&nbsp;<code>user_data<\/code><\/td><td>AWS (optional; billable)<\/td><\/tr><tr><td class=\"has-text-align-right\" data-align=\"right\">12<\/td><td>6<\/td><td>Composition, maintainability, end-to-end verification<\/td><td>Azure (billable)<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How to use the ZIP:<\/strong>&nbsp;Every lab is a self-contained snapshot, complete with its own&nbsp;<code>main.tf<\/code>, template files and&nbsp;<code>README.md<\/code>. Start Lab 01, then move to the next lab and compare code. You can run Labs 01\u201309 independently; cloud labs have their own isolated states and distinct Azure resource prefixes. Do not apply both Azure labs unless you intend to pay for two VMs. For a real-world stateful upgrade, use one working folder and first inspect&nbsp;<code>terraform plan<\/code>\u2014changing VM custom data usually requires VM replacement.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 class=\"wp-block-heading\">Module 1 \u2014 Introduction to Terraform Templates<\/h1>\n\n\n\n<h2 class=\"wp-block-heading\">1.1 What is a Terraform template?<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A Terraform template is&nbsp;<strong>text containing Terraform interpolation or template directives<\/strong>&nbsp;that Terraform evaluates to produce text. You can place a small template in a quoted\/heredoc string, or put a larger one in a UTF-8&nbsp;<code>.tftpl<\/code>&nbsp;file and render it with&nbsp;<code>templatefile()<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>A template is not a Terraform module.<\/strong>&nbsp;A module groups Terraform configuration that defines infrastructure. A template is a way to&nbsp;<em>generate text<\/em>&nbsp;such as application config, shell scripts, HTML, cloud-init or EC2 user data. Generating a string alone creates no cloud resources.<\/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\">Item<\/th><th class=\"has-text-align-left\" data-align=\"left\">Role<\/th><th class=\"has-text-align-left\" data-align=\"left\">Example<\/th><\/tr><\/thead><tbody><tr><td>Variable<\/td><td>Holds typed input<\/td><td><code>var.environment = \"dev\"<\/code><\/td><\/tr><tr><td>Interpolation<\/td><td>Inserts a value<\/td><td><code>\"server-${var.environment}\"<\/code><\/td><\/tr><tr><td><code>.tftpl<\/code>&nbsp;file<\/td><td>Holds reusable formatted text<\/td><td><code>templates\/app.conf.tftpl<\/code><\/td><\/tr><tr><td><code>templatefile()<\/code><\/td><td>Renders an external template with supplied values<\/td><td><code>templatefile(\"...\", { environment = var.environment })<\/code><\/td><\/tr><tr><td>Terraform module<\/td><td>Groups infrastructure configuration<\/td><td><code>module \"network\" { ... }<\/code>&nbsp;\u2014&nbsp;<strong>not taught here<\/strong><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>When not to use a template:<\/strong>&nbsp;A single string is often clearer as&nbsp;<code>\"prefix-${var.name}\"<\/code>. For JSON\/YAML, prefer&nbsp;<code>jsonencode<\/code>\/<code>yamlencode<\/code>&nbsp;over hand-building escaped strings. Use an external&nbsp;<code>.tftpl<\/code>&nbsp;file when it makes a longer output easier to read and review.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">1.2 Your first result<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Below we start without providers, backend setup, resources or credentials. You can run the first nine labs anywhere the Terraform CLI is installed.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Lab 01 \u2014 First template: inline interpolation<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Estimated classroom slot:<\/strong>&nbsp;10 min<br><strong>Execution:<\/strong>&nbsp;<strong>Local only \u2014 no cloud costs<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Objective:<\/strong>&nbsp;Learn that Terraform string expressions can render a changing message without any cloud provider.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What changed from the previous lab:<\/strong>&nbsp;Starting point. Two input variables and one output; no resources or templates directory.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 1 \u2014 Understand the folder<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>labs\/01-inline-interpolation\/\n  main.tf\n  README.md\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 2 \u2014 Create\/review each file<\/h3>\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\nvariable \"app_name\" {\n  type    = string\n  default = \"demoapp\"\n}\n\nvariable \"environment\" {\n  type    = string\n  default = \"dev\"\n}\n\noutput \"message\" {\n  value = \"App ${upper(var.app_name)} runs in ${var.environment}\"\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 3 \u2014 Run and inspect<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">From the lab folder, use the following commands. Replace placeholder IDs for cloud labs.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform init -backend=false\nterraform validate\nterraform apply -auto-approve\nterraform output -raw message\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Expected:<\/strong>&nbsp;App DEMOAPP runs in dev<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 4 \u2014 Student practice<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Run&nbsp;<code>terraform apply -auto-approve -var=\"environment=stage\"<\/code>. Confirm the message changes without provisioning anything.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 class=\"wp-block-heading\">Module 2 \u2014 Template Fundamentals<\/h1>\n\n\n\n<h2 class=\"wp-block-heading\">Core syntax to remember<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">An interpolation&nbsp;<code>${expression}<\/code>&nbsp;inserts the value of an expression, like&nbsp;<code>${app.name}<\/code>&nbsp;or&nbsp;<code>${upper(app.name)}<\/code>. A template file is normally named&nbsp;<code>*.tftpl<\/code>&nbsp;and does not create resources by itself.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>output \"result\" {\n  value = templatefile(\"${path.module}\/templates\/message.tftpl\", {\n    app_name = var.app_name\n  })\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Scope rule:<\/strong>&nbsp;<code>var.app_name<\/code>&nbsp;is available in the parent&nbsp;<code>.tf<\/code>; inside the external file it is available only as&nbsp;<code>app_name<\/code>&nbsp;because that is the key passed in the second argument.&nbsp;<code>path.module<\/code>&nbsp;makes the template path relative to the running configuration directory, not your terminal&#8217;s current working directory.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Lab 02 \u2014 Extract a reusable&nbsp;<code>.tftpl<\/code>&nbsp;file<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Estimated classroom slot:<\/strong>&nbsp;10 min<br><strong>Execution:<\/strong>&nbsp;<strong>Local only \u2014 no cloud costs<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Objective:<\/strong>&nbsp;Move the text outside the Terraform configuration and pass exactly two named values to&nbsp;<code>templatefile()<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What changed from the previous lab:<\/strong>&nbsp;Retains the two variables from Lab 01. Replaces the inline output string with an external template.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 1 \u2014 Understand the folder<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>labs\/02-first-templatefile\/\n  main.tf\n  templates\/message.tftpl\n  README.md\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 2 \u2014 Create\/review each file<\/h3>\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\nvariable \"app_name\" {\n  type    = string\n  default = \"demoapp\"\n}\n\nvariable \"environment\" {\n  type    = string\n  default = \"dev\"\n}\n\noutput \"message\" {\n  value = templatefile(\"${path.module}\/templates\/message.tftpl\", {\n    app_name    = var.app_name\n    environment = var.environment\n  })\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>templates\/message.tftpl<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>App ${upper(app_name)} runs in ${environment}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 3 \u2014 Run and inspect<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">From the lab folder, use the following commands. Replace placeholder IDs for cloud labs.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform init -backend=false\nterraform validate\nterraform apply -auto-approve\nterraform output -raw message\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Expected:<\/strong>&nbsp;App DEMOAPP runs in dev<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 4 \u2014 Student practice<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Change&nbsp;<code>templates\/message.tftpl<\/code>&nbsp;to add&nbsp;<code>Welcome to ${environment}<\/code>&nbsp;and confirm that&nbsp;<code>terraform output -raw message<\/code>&nbsp;changes after&nbsp;<code>terraform apply<\/code>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Lab 03 \u2014 Expressions, objects, and template scope<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Estimated classroom slot:<\/strong>&nbsp;10 min<br><strong>Execution:<\/strong>&nbsp;<strong>Local only \u2014 no cloud costs<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Objective:<\/strong>&nbsp;Pass an object into a template and access its fields. Practice a function and a number inside interpolation.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What changed from the previous lab:<\/strong>&nbsp;The two scalar variables become one&nbsp;<code>app<\/code>&nbsp;object. Inside the template the object is named&nbsp;<code>app<\/code>, not&nbsp;<code>var.app<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 1 \u2014 Understand the folder<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>labs\/03-objects-and-scope\/\n  main.tf\n  templates\/message.tftpl\n  README.md\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 2 \u2014 Create\/review each file<\/h3>\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\nvariable \"app\" {\n  type = object({\n    name        = string\n    environment = string\n    port        = number\n  })\n  default = {\n    name        = \"demoapp\"\n    environment = \"dev\"\n    port        = 8080\n  }\n}\n\noutput \"message\" {\n  value = templatefile(\"${path.module}\/templates\/message.tftpl\", {\n    app = var.app\n  })\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>templates\/message.tftpl<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>App ${upper(app.name)} (${app.environment}) listens on ${app.port}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 3 \u2014 Run and inspect<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">From the lab folder, use the following commands. Replace placeholder IDs for cloud labs.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform init -backend=false\nterraform validate\nterraform apply -auto-approve\nterraform output -raw message\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Expected:<\/strong>&nbsp;App DEMOAPP (dev) listens on 8080<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 4 \u2014 Student practice<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Change&nbsp;<code>app.port<\/code>&nbsp;to&nbsp;<code>9090<\/code>&nbsp;in the default object and inspect the newly rendered output.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 class=\"wp-block-heading\">Module 3 \u2014 Template Directives<\/h1>\n\n\n\n<p class=\"wp-block-paragraph\">Directives choose or repeat whole text sections. They are&nbsp;<strong>not Terraform resource loops<\/strong>&nbsp;and they do not deploy any infrastructure.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>%{ if enable_logging ~}\nlogging=enabled\n%{ else ~}\nlogging=disabled\n%{ endif ~}\n%{ for item in items ~}\nitem=${item}\n%{ endfor ~}\n<\/code><\/pre>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>%{ if condition }<\/code>\u00a0begins a conditional;\u00a0<code>%{ else }<\/code>\u00a0is optional;\u00a0<code>%{ endif }<\/code>\u00a0closes it.<\/li>\n\n\n\n<li><code>%{ for item in items }<\/code>\u00a0iterates over a collection;\u00a0<code>%{ endfor }<\/code>\u00a0closes it.<\/li>\n\n\n\n<li><code>%{ for key, value in settings }<\/code>\u00a0iterates through map entries.<\/li>\n\n\n\n<li>The\u00a0<code>~<\/code>\u00a0immediately before\u00a0<code>}<\/code>\u00a0consumes whitespace\u00a0<strong>after<\/strong>\u00a0the sequence. A\u00a0<code>~<\/code>\u00a0immediately after\u00a0<code>%{<\/code>\u00a0consumes whitespace\u00a0<strong>before<\/strong>\u00a0the sequence. Both can help remove directive-only blank lines.<\/li>\n\n\n\n<li>Do not confuse template directives with\u00a0<code>for_each<\/code>,\u00a0<code>count<\/code>, or dynamic resource blocks.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Lab 04 \u2014 Conditional&nbsp;<code>%{ if }<\/code>&nbsp;and&nbsp;<code>%{ else }<\/code><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Estimated classroom slot:<\/strong>&nbsp;15 min<br><strong>Execution:<\/strong>&nbsp;<strong>Local only \u2014 no cloud costs<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Objective:<\/strong>&nbsp;Render exactly one of two configuration lines based on a boolean input.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What changed from the previous lab:<\/strong>&nbsp;Keeps the Lab 03&nbsp;<code>app<\/code>&nbsp;object and adds&nbsp;<code>enable_logging<\/code>&nbsp;with&nbsp;<code>%{ if }<\/code>,&nbsp;<code>%{ else }<\/code>, and&nbsp;<code>%{ endif }<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 1 \u2014 Understand the folder<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>labs\/04-if-else-directives\/\n  main.tf\n  templates\/app.conf.tftpl\n  README.md\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 2 \u2014 Create\/review each file<\/h3>\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\nvariable \"app\" {\n  type = object({ name = string, environment = string })\n  default = { name = \"demoapp\", environment = \"dev\" }\n}\n\nvariable \"enable_logging\" {\n  type    = bool\n  default = true\n}\n\noutput \"config\" {\n  value = templatefile(\"${path.module}\/templates\/app.conf.tftpl\", {\n    app            = var.app\n    enable_logging = var.enable_logging\n  })\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>templates\/app.conf.tftpl<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>name=${app.name}\nenvironment=${app.environment}\n%{ if enable_logging ~}\nlogging=enabled\n%{ else ~}\nlogging=disabled\n%{ endif ~}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 3 \u2014 Run and inspect<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">From the lab folder, use the following commands. Replace placeholder IDs for cloud labs.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform init -backend=false\nterraform validate\nterraform apply -auto-approve\nterraform output -raw config\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Expected:<\/strong>&nbsp;name=demoapp; env=dev; logging=enabled (on separate lines)<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 4 \u2014 Student practice<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Set&nbsp;<code>-var=\"enable_logging=false\"<\/code>, apply, and confirm the template produces&nbsp;<code>logging=disabled<\/code>&nbsp;instead.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Lab 05 \u2014 Generate repeated lines with&nbsp;<code>%{ for }<\/code><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Estimated classroom slot:<\/strong>&nbsp;15 min<br><strong>Execution:<\/strong>&nbsp;<strong>Local only \u2014 no cloud costs<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Objective:<\/strong>&nbsp;Use a list to render a package entry for every item instead of repeating code.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What changed from the previous lab:<\/strong>&nbsp;The conditional configuration from Lab 04 stays. Add&nbsp;<code>packages<\/code>&nbsp;and one&nbsp;<code>%{ for package in packages }<\/code>&nbsp;directive.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 1 \u2014 Understand the folder<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>labs\/05-for-list-directive\/\n  main.tf\n  templates\/app.conf.tftpl\n  README.md\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 2 \u2014 Create\/review each file<\/h3>\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\nvariable \"app\" {\n  type = object({ name = string, environment = string })\n  default = { name = \"demoapp\", environment = \"dev\" }\n}\n\nvariable \"enable_logging\" {\n  type    = bool\n  default = true\n}\n\nvariable \"packages\" {\n  type    = list(string)\n  default = &#91;\"nginx\", \"curl\"]\n}\n\noutput \"config\" {\n  value = templatefile(\"${path.module}\/templates\/app.conf.tftpl\", {\n    app            = var.app\n    enable_logging = var.enable_logging\n    packages       = var.packages\n  })\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>templates\/app.conf.tftpl<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>name=${app.name}\nenvironment=${app.environment}\n%{ if enable_logging ~}\nlogging=enabled\n%{ else ~}\nlogging=disabled\n%{ endif ~}\n%{ for package in packages ~}\npackage=${package}\n%{ endfor ~}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 3 \u2014 Run and inspect<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">From the lab folder, use the following commands. Replace placeholder IDs for cloud labs.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform init -backend=false\nterraform validate\nterraform apply -auto-approve\nterraform output -raw config\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Expected:<\/strong>&nbsp;Includes&nbsp;<code>package=nginx<\/code>&nbsp;and&nbsp;<code>package=curl<\/code>, one item per line.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 4 \u2014 Student practice<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Add&nbsp;<code>\"git\"<\/code>&nbsp;to&nbsp;<code>packages<\/code>, rerun the lab, and verify exactly three&nbsp;<code>package=<\/code>&nbsp;entries.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Lab 06 \u2014 Loop through a map and control whitespace<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Estimated classroom slot:<\/strong>&nbsp;15 min<br><strong>Execution:<\/strong>&nbsp;<strong>Local only \u2014 no cloud costs<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Objective:<\/strong>&nbsp;Add key\/value pairs and remove extra blank lines using&nbsp;<code>~<\/code>&nbsp;without changing the configuration design.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What changed from the previous lab:<\/strong>&nbsp;The previous list loop stays. Add&nbsp;<code>settings = map(string)<\/code>&nbsp;and&nbsp;<code>%{ for key, value in settings ~}<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 1 \u2014 Understand the folder<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>labs\/06-maps-and-whitespace\/\n  main.tf\n  templates\/app.conf.tftpl\n  README.md\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 2 \u2014 Create\/review each file<\/h3>\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\nvariable \"app\" {\n  type = object({ name = string, environment = string })\n  default = { name = \"demoapp\", environment = \"dev\" }\n}\n\nvariable \"enable_logging\" {\n  type    = bool\n  default = true\n}\n\nvariable \"packages\" {\n  type    = list(string)\n  default = &#91;\"nginx\", \"curl\"]\n}\n\nvariable \"settings\" {\n  type = map(string)\n  default = { log_level = \"info\", workers = \"2\" }\n}\n\noutput \"config\" {\n  value = templatefile(\"${path.module}\/templates\/app.conf.tftpl\", {\n    app            = var.app\n    enable_logging = var.enable_logging\n    packages       = var.packages\n    settings       = var.settings\n  })\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>templates\/app.conf.tftpl<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>name=${app.name}\nenvironment=${app.environment}\n%{ if enable_logging ~}\nlogging=enabled\n%{ else ~}\nlogging=disabled\n%{ endif ~}\n%{ for package in packages ~}\npackage=${package}\n%{ endfor ~}\n%{ for key, value in settings ~}\n${key}=${value}\n%{ endfor ~}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 3 \u2014 Run and inspect<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">From the lab folder, use the following commands. Replace placeholder IDs for cloud labs.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform init -backend=false\nterraform validate\nterraform apply -auto-approve\nterraform output -raw config\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Expected:<\/strong>&nbsp;Includes&nbsp;<code>log_level=info<\/code>,&nbsp;<code>workers=2<\/code>, and no blank line created by directive-only lines.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 4 \u2014 Student practice<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Remove&nbsp;<code>~<\/code>&nbsp;from the&nbsp;<code>for<\/code>&nbsp;line and apply. Compare the output; then restore it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Instructor note:<\/strong>&nbsp;Map keys are traversed in lexical order for Terraform map\/object values. Do not use a set when output order is a requirement.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 class=\"wp-block-heading\">Module 4 \u2014&nbsp;<code>templatefile()<\/code>&nbsp;in Depth<\/h1>\n\n\n\n<h2 class=\"wp-block-heading\">API, files, and constraints<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>templatefile(path, vars)\n<\/code><\/pre>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>path<\/code>\u00a0is an existing local UTF-8 text file, typically referenced using\u00a0<code>${path.module}\/templates\/...<\/code>.<\/li>\n\n\n\n<li><code>vars<\/code>\u00a0is an object\/map.\u00a0<strong>Only its top-level keys<\/strong>\u00a0are in scope in the\u00a0<code>.tftpl<\/code>\u00a0file; their values can be nested objects, lists and maps.<\/li>\n\n\n\n<li>Template keys must start with a letter, then contain only letters, digits or underscores.<\/li>\n\n\n\n<li><code>templatefile()<\/code>\u00a0cannot call itself recursively and it cannot read a file created later during the same Terraform run. It is not a cloud service or a Terraform resource.<\/li>\n\n\n\n<li><code>file(path)<\/code>\u00a0reads literal text unchanged, while\u00a0<code>templatefile()<\/code>\u00a0renders template expressions. If you need a literal\u00a0<code>${...}<\/code>\u00a0in a rendered template, write\u00a0<code>$${...}<\/code>\u00a0in the source; for literal\u00a0<code>%{...}<\/code>, write\u00a0<code>%%{...}<\/code>.<\/li>\n\n\n\n<li><strong>Avoid the legacy<\/strong>\u00a0<code>data \"template_file\"<\/code>\u00a0from the archived\u00a0<code>hashicorp\/template<\/code>\u00a0provider. Modern Terraform has a built-in\u00a0<code>templatefile()<\/code>\u00a0function, requiring no extra template provider.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Troubleshooting table<\/strong><\/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\">Error \/ symptom<\/th><th class=\"has-text-align-left\" data-align=\"left\">Why<\/th><th class=\"has-text-align-left\" data-align=\"left\">Fix<\/th><\/tr><\/thead><tbody><tr><td><code>Invalid value for \"path\"<\/code><\/td><td>File missing or wrong relative directory<\/td><td>Use&nbsp;<code>${path.module}<\/code>&nbsp;and verify location<\/td><\/tr><tr><td><code>vars map does not contain key ...<\/code><\/td><td>Template references a key you did not pass<\/td><td>Add it to the&nbsp;<code>vars<\/code>&nbsp;object or fix its spelling<\/td><\/tr><tr><td><code>vars map does not contain key \"var\"<\/code><\/td><td>Wrote&nbsp;<code>${var.environment}<\/code>&nbsp;inside&nbsp;<code>.tftpl<\/code><\/td><td>Pass&nbsp;<code>environment = var.environment<\/code>&nbsp;and refer to&nbsp;<code>${environment}<\/code><\/td><\/tr><tr><td><code>Invalid function argument<\/code>&nbsp;or malformed content<\/td><td>Invalid UTF-8, function use or interpolated value<\/td><td>Validate source encoding and expression syntax<\/td><\/tr><tr><td>Output literally contains unexpected&nbsp;<code>$${...}<\/code><\/td><td>Escaped one extra level<\/td><td>Distinguish Terraform interpolation from shell interpolation<\/td><\/tr><tr><td>JSON\/YAML syntax invalid<\/td><td>Manually interpolated strings without escaping<\/td><td>Use&nbsp;<code>jsonencode<\/code>&nbsp;\/&nbsp;<code>yamlencode<\/code><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Preflight habit:<\/strong>&nbsp;<code>terraform fmt<\/code>,&nbsp;<code>terraform validate<\/code>, and&nbsp;<code>terraform console<\/code>; then plan\/apply only when necessary. For JSON results, pipe&nbsp;<code>terraform output -raw<\/code>&nbsp;into&nbsp;<code>python3 -m json.tool<\/code>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Lab 07 \u2014 Render JSON safely and debug&nbsp;<code>templatefile()<\/code><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Estimated classroom slot:<\/strong>&nbsp;15 min<br><strong>Execution:<\/strong>&nbsp;<strong>Local only \u2014 no cloud costs<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Objective:<\/strong>&nbsp;Pass objects and lists through the&nbsp;<code>vars<\/code>&nbsp;map while using&nbsp;<code>jsonencode()<\/code>&nbsp;for correct quoting and escaping.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What changed from the previous lab:<\/strong>&nbsp;Switches output format from ad hoc text to JSON. This is a new format, not a new provider or resource.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 1 \u2014 Understand the folder<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>labs\/07-json-and-debugging\/\n  main.tf\n  templates\/app.json.tftpl\n  README.md\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 2 \u2014 Create\/review each file<\/h3>\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\nvariable \"app\" {\n  type = object({ name = string, environment = string, port = number })\n  default = { name = \"demoapp\", environment = \"dev\", port = 8080 }\n}\n\nvariable \"features\" {\n  type    = list(string)\n  default = &#91;\"metrics\", \"healthcheck\"]\n}\n\nvariable \"settings\" {\n  type    = map(string)\n  default = { workers = \"2\", log_level = \"info\" }\n}\n\noutput \"config_json\" {\n  value = templatefile(\"${path.module}\/templates\/app.json.tftpl\", {\n    app      = var.app\n    features = var.features\n    settings = var.settings\n  })\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>templates\/app.json.tftpl<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>${jsonencode({\n  name        = app.name\n  environment = app.environment\n  port        = app.port\n  features    = features\n  settings    = settings\n})}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 3 \u2014 Run and inspect<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">From the lab folder, use the following commands. Replace placeholder IDs for cloud labs.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform init -backend=false\nterraform validate\nterraform apply -auto-approve\nterraform output -raw config_json | python3 -m json.tool\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Expected:<\/strong>&nbsp;Valid formatted JSON with name, environment, port,&nbsp;<code>features<\/code>&nbsp;array, and&nbsp;<code>settings<\/code>&nbsp;object.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 4 \u2014 Student practice<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Change the app name to&nbsp;<code>demo \"quoted\"<\/code>; validate the result using&nbsp;<code>python3 -m json.tool<\/code>&nbsp;rather than inserting quotes manually.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Instructor note:<\/strong>&nbsp;<code>jsonencode()<\/code>&nbsp;handles quote and newline escaping. Inside the template only the keys passed to&nbsp;<code>templatefile<\/code>&nbsp;exist; root-level&nbsp;<code>var.app<\/code>&nbsp;is not in scope.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 class=\"wp-block-heading\">Module 5 \u2014 Templates for Cloud Automation<\/h1>\n\n\n\n<h2 class=\"wp-block-heading\">Three formats, one concept<\/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\">Content<\/th><th class=\"has-text-align-left\" data-align=\"left\">Typical source<\/th><th class=\"has-text-align-left\" data-align=\"left\">How attached<\/th><th class=\"has-text-align-left\" data-align=\"left\">Execution moment<\/th><\/tr><\/thead><tbody><tr><td>Linux shell script<\/td><td><code>startup.sh.tftpl<\/code><\/td><td>AWS&nbsp;<code>user_data = templatefile(...)<\/code><\/td><td>Usually first boot<\/td><\/tr><tr><td>Cloud-init YAML<\/td><td><code>cloud-init.tftpl<\/code><\/td><td>Azure&nbsp;<code>custom_data = base64encode(templatefile(...))<\/code><\/td><td>Usually first boot<\/td><\/tr><tr><td>App\/server config<\/td><td><code>app.conf.tftpl<\/code><\/td><td>Embedded inside cloud-init&nbsp;<code>write_files<\/code><\/td><td>At VM initialization<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Key difference:<\/strong>&nbsp;AzureRM&nbsp;<code>azurerm_linux_virtual_machine.custom_data<\/code>&nbsp;expects Base64-encoded text. AWS Terraform&nbsp;<code>aws_instance.user_data<\/code>&nbsp;accepts ordinary script text; its provider handles API encoding. Do not double-encode the AWS field.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Security default:<\/strong>&nbsp;Azure lab VMs have only a private NIC and SSH key login; we use Azure Run Command for inspection instead of adding a public IP, NSG, bastion or provisioning connection. The demo cloud-init writes files and&nbsp;<strong>does not require package downloads<\/strong>, avoiding additional outbound-networking topics. On AWS the instance also gets no public IP from this configuration, and you must already have a working subnet and management access to inspect guest files.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Lab 08 \u2014 Render a Linux shell startup script<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Estimated classroom slot:<\/strong>&nbsp;15 min<br><strong>Execution:<\/strong>&nbsp;<strong>Local only \u2014 no cloud costs<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Objective:<\/strong>&nbsp;Move from configuration text to a practical Linux startup script; learn to escape shell&nbsp;<code>${NAME}<\/code>&nbsp;safely.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What changed from the previous lab:<\/strong>&nbsp;Reuses the same&nbsp;<code>app_name<\/code>&nbsp;and&nbsp;<code>environment<\/code>&nbsp;idea. File extension stays&nbsp;<code>.tftpl<\/code>, but the rendered result is a shell script.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 1 \u2014 Understand the folder<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>labs\/08-shell-script-template\/\n  main.tf\n  templates\/startup.sh.tftpl\n  README.md\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 2 \u2014 Create\/review each file<\/h3>\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\nvariable \"app_name\" {\n  type    = string\n  default = \"demoapp\"\n}\n\nvariable \"environment\" {\n  type    = string\n  default = \"dev\"\n}\n\noutput \"startup_script\" {\n  value = templatefile(\"${path.module}\/templates\/startup.sh.tftpl\", {\n    app_name    = var.app_name\n    environment = var.environment\n  })\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>templates\/startup.sh.tftpl<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>#!\/bin\/sh\nset -eu\nAPP_NAME=\"${app_name}\"\nENVIRONMENT=\"${environment}\"\nprintf 'Starting %s in %s\\n' \"$${APP_NAME}\" \"$${ENVIRONMENT}\"\nprintf '%s\\n' \"$${APP_NAME}:$${ENVIRONMENT}\" &gt; \/tmp\/tf-template-bootstrap.txt\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 3 \u2014 Run and inspect<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">From the lab folder, use the following commands. Replace placeholder IDs for cloud labs.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform init -backend=false\nterraform validate\nterraform apply -auto-approve\nterraform output -raw startup_script &gt; \/tmp\/tf-template-startup.sh\nsh -n \/tmp\/tf-template-startup.sh\nsh \/tmp\/tf-template-startup.sh\ncat \/tmp\/tf-template-bootstrap.txt\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Expected:<\/strong>&nbsp;The script prints&nbsp;<code>Starting demoapp in dev<\/code>; the file contains&nbsp;<code>demoapp:dev<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 4 \u2014 Student practice<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Replace the environment with&nbsp;<code>qa<\/code>&nbsp;and rerender. Verify that the printed script contains literal shell&nbsp;<code>${APP_NAME}<\/code>&nbsp;after template expansion.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Instructor note:<\/strong>&nbsp;The template uses Terraform&nbsp;<code>$${<\/code>&nbsp;escaping for the&nbsp;<em>literal shell<\/em>&nbsp;<code>${...}<\/code>. This shell script is intentionally harmless and requires no root privileges.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Lab 09 \u2014 Build cloud-init without creating a VM<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Estimated classroom slot:<\/strong>&nbsp;20 min<br><strong>Execution:<\/strong>&nbsp;<strong>Local only \u2014 no cloud costs<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Objective:<\/strong>&nbsp;Reuse&nbsp;<code>templatefile()<\/code>&nbsp;to produce a complete&nbsp;<code>#cloud-config<\/code>&nbsp;document and preview the YAML before provisioning.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What changed from the previous lab:<\/strong>&nbsp;Keeps the app\/environment inputs from Lab 08. The output is now cloud-init YAML encoded from an HCL object.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 1 \u2014 Understand the folder<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>labs\/09-cloud-init-preview\/\n  main.tf\n  templates\/cloud-init.tftpl\n  README.md\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 2 \u2014 Create\/review each file<\/h3>\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\nvariable \"app_name\" {\n  type    = string\n  default = \"demoapp\"\n}\n\nvariable \"environment\" {\n  type    = string\n  default = \"dev\"\n}\n\noutput \"cloud_init\" {\n  value = templatefile(\"${path.module}\/templates\/cloud-init.tftpl\", {\n    app_name    = var.app_name\n    environment = var.environment\n  })\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>templates\/cloud-init.tftpl<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>#cloud-config\n${yamlencode({\n  write_files = &#91;{\n    path        = \"\/etc\/tf-template\/app.conf\"\n    permissions = \"0644\"\n    content     = format(\"name=%s\\nenvironment=%s\\n\", app_name, environment)\n  }]\n  runcmd = &#91;\n    &#91;\"sh\", \"-c\", \"echo READY &gt; \/var\/tmp\/tf-template-ready\"]\n  ]\n})}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 3 \u2014 Run and inspect<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">From the lab folder, use the following commands. Replace placeholder IDs for cloud labs.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform init -backend=false\nterraform validate\nterraform apply -auto-approve\nterraform output -raw cloud_init &gt; \/tmp\/tf-cloud-init-preview.yaml\nhead -15 \/tmp\/tf-cloud-init-preview.yaml\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Expected:<\/strong>&nbsp;Starts with&nbsp;<code>#cloud-config<\/code>, declares&nbsp;<code>\/etc\/tf-template\/app.conf<\/code>, and writes&nbsp;<code>\/var\/tmp\/tf-template-ready<\/code>&nbsp;on first boot.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 4 \u2014 Student practice<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Add a second nonsecret&nbsp;<code>write_files<\/code>&nbsp;entry to the array, preview the YAML, and check the indentation.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Instructor note:<\/strong>&nbsp;This lab does not require Azure\/AWS credentials. It teaches cloud-init format without VM setup cost. The cloud-init document is a preview, not something executed locally.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Lab 10 \u2014 Azure Ubuntu VM from the cloud-init template<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Estimated classroom slot:<\/strong>&nbsp;40 min<br><strong>Execution:<\/strong>&nbsp;<strong>Azure deploy \u2014 chargeable<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Objective:<\/strong>&nbsp;Attach the exact Lab 09 template to an actual Azure Linux VM using&nbsp;<code>custom_data = base64encode(templatefile(...))<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What changed from the previous lab:<\/strong>&nbsp;Same template as Lab 09; only now Azure resources are added: resource group, VNet, subnet, NIC and one Ubuntu VM (5 resources).<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 1 \u2014 Understand the folder<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>labs\/10-azure-ubuntu-cloud-init\/\n  main.tf\n  templates\/cloud-init.tftpl\n  README.md\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 2 \u2014 Create\/review each file<\/h3>\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  required_providers {\n    azurerm = {\n      source  = \"hashicorp\/azurerm\"\n      version = \"~&gt; 5.9.0\"\n    }\n  }\n}\n\nprovider \"azurerm\" {\n  features {}\n}\n\nvariable \"location\" {\n  type    = string\n  default = \"eastus\"\n}\n\nvariable \"name_prefix\" {\n  type    = string\n  default = \"tpl-basic\"\n}\n\nvariable \"ssh_public_key_path\" {\n  type    = string\n  default = \"~\/.ssh\/id_ed25519.pub\"\n}\n\nvariable \"app_name\" {\n  type    = string\n  default = \"demoapp\"\n}\n\nvariable \"environment\" {\n  type    = string\n  default = \"dev\"\n}\n\nresource \"azurerm_resource_group\" \"lab\" {\n  name     = \"${var.name_prefix}-rg\"\n  location = var.location\n}\n\nresource \"azurerm_virtual_network\" \"lab\" {\n  name                = \"${var.name_prefix}-vnet\"\n  location            = azurerm_resource_group.lab.location\n  resource_group_name = azurerm_resource_group.lab.name\n  address_space       = &#91;\"10.80.0.0\/16\"]\n}\n\nresource \"azurerm_subnet\" \"lab\" {\n  name                 = \"${var.name_prefix}-subnet\"\n  resource_group_name  = azurerm_resource_group.lab.name\n  virtual_network_name = azurerm_virtual_network.lab.name\n  address_prefixes     = &#91;\"10.80.1.0\/24\"]\n}\n\nresource \"azurerm_network_interface\" \"lab\" {\n  name                = \"${var.name_prefix}-nic\"\n  location            = azurerm_resource_group.lab.location\n  resource_group_name = azurerm_resource_group.lab.name\n\n  ip_configuration {\n    name                          = \"internal\"\n    subnet_id                     = azurerm_subnet.lab.id\n    private_ip_address_allocation = \"Dynamic\"\n  }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"lab\" {\n  name                  = \"${var.name_prefix}-vm\"\n  location              = azurerm_resource_group.lab.location\n  resource_group_name   = azurerm_resource_group.lab.name\n  size                  = \"Standard_B2s_v2\"\n  admin_username        = \"labadmin\"\n  network_interface_ids = &#91;azurerm_network_interface.lab.id]\n\n  disable_password_authentication = true\n  admin_ssh_key {\n    username   = \"labadmin\"\n    public_key = file(pathexpand(var.ssh_public_key_path))\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  custom_data = base64encode(templatefile(\"${path.module}\/templates\/cloud-init.tftpl\", {\n    app_name    = var.app_name\n    environment = var.environment\n  }))\n}\n\noutput \"resource_group_name\" {\n  value = azurerm_resource_group.lab.name\n}\n\noutput \"vm_name\" {\n  value = azurerm_linux_virtual_machine.lab.name\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>templates\/cloud-init.tftpl<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>#cloud-config\n${yamlencode({\n  write_files = &#91;{\n    path        = \"\/etc\/tf-template\/app.conf\"\n    permissions = \"0644\"\n    content     = format(\"name=%s\\nenvironment=%s\\n\", app_name, environment)\n  }]\n  runcmd = &#91;\n    &#91;\"sh\", \"-c\", \"echo READY &gt; \/var\/tmp\/tf-template-ready\"]\n  ]\n})}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 3 \u2014 Run and inspect<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">From the lab folder, use the following commands. Replace placeholder IDs for cloud labs.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>az login\naz account set --subscription \"YOUR_SUBSCRIPTION_ID\"\nexport ARM_SUBSCRIPTION_ID=\"$(az account show --query id -o tsv)\"\ntest -f \"$HOME\/.ssh\/id_ed25519.pub\"  <em># Use the preflight key-setup instructions if missing<\/em>\nterraform init\nterraform validate\nterraform plan -out=tfplan\nterraform apply tfplan\naz vm run-command invoke -g \"$(terraform output -raw resource_group_name)\" -n \"$(terraform output -raw vm_name)\" --command-id RunShellScript --scripts \"cloud-init status --wait; cat \/etc\/tf-template\/app.conf; cat \/var\/tmp\/tf-template-ready\"\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Expected:<\/strong>&nbsp;One private Ubuntu VM. Run Command should show&nbsp;<code>name=demoapp<\/code>,&nbsp;<code>environment=dev<\/code>&nbsp;and&nbsp;<code>READY<\/code>&nbsp;after cloud-init completes.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 4 \u2014 Student practice<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Change&nbsp;<code>app_name<\/code>&nbsp;to&nbsp;<code>training-app<\/code>&nbsp;and run&nbsp;<code>terraform plan<\/code>&nbsp;only. Identify the VM replacement that&nbsp;<code>custom_data<\/code>&nbsp;would trigger; do not apply an unintended replacement.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Instructor note:<\/strong>&nbsp;This VM intentionally has no public IP or inbound SSH rule. Azure Run Command is the verification method. The cloud-init example does not install packages and therefore does not depend on outbound internet.&nbsp;<code>custom_data<\/code>&nbsp;changes normally replace the VM.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Cleanup (once your verification is complete):<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform plan -destroy\nterraform destroy\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Lab 11 \u2014 AWS EC2 user-data with the same shell template<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Estimated classroom slot:<\/strong>&nbsp;25 min<br><strong>Execution:<\/strong>&nbsp;<strong>AWS deploy \u2014 optional and chargeable<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Objective:<\/strong>&nbsp;Compare the provider interfaces:&nbsp;<code>aws_instance.user_data<\/code>&nbsp;accepts the rendered shell script without wrapping it in&nbsp;<code>base64encode()<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What changed from the previous lab:<\/strong>&nbsp;Uses the shell template from Lab 08. AWS VPC\/subnet and AMI are external inputs to avoid unrelated networking code.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 1 \u2014 Understand the folder<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>labs\/11-aws-ec2-user-data\/\n  main.tf\n  templates\/startup.sh.tftpl\n  README.md\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 2 \u2014 Create\/review each file<\/h3>\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  required_providers {\n    aws = {\n      source  = \"hashicorp\/aws\"\n      version = \"~&gt; 6.0\"\n    }\n  }\n}\n\nprovider \"aws\" {\n  region = var.aws_region\n}\n\nvariable \"aws_region\" {\n  type    = string\n  default = \"ap-northeast-1\"\n}\n\nvariable \"subnet_id\" {\n  description = \"Existing subnet ID in the selected AWS region.\"\n  type        = string\n}\n\nvariable \"ami_id\" {\n  description = \"Linux AMI ID compatible with the chosen instance type.\"\n  type        = string\n}\n\nvariable \"app_name\" {\n  type    = string\n  default = \"demoapp\"\n}\n\nvariable \"environment\" {\n  type    = string\n  default = \"dev\"\n}\n\nresource \"aws_instance\" \"lab\" {\n  ami                         = var.ami_id\n  instance_type               = \"t3.micro\"\n  subnet_id                   = var.subnet_id\n  associate_public_ip_address = false\n\n  user_data = templatefile(\"${path.module}\/templates\/startup.sh.tftpl\", {\n    app_name    = var.app_name\n    environment = var.environment\n  })\n\n  tags = { Name = \"tf-template-demo\" }\n}\n\noutput \"instance_id\" {\n  value = aws_instance.lab.id\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>templates\/startup.sh.tftpl<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>#!\/bin\/sh\nset -eu\nAPP_NAME=\"${app_name}\"\nENVIRONMENT=\"${environment}\"\nprintf '%s\\n' \"$${APP_NAME}:$${ENVIRONMENT}\" &gt; \/var\/tmp\/tf-template-bootstrap.txt\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 3 \u2014 Run and inspect<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">From the lab folder, use the following commands. Replace placeholder IDs for cloud labs.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>aws sts get-caller-identity\naws ssm get-parameter --region ap-northeast-1 --name \/aws\/service\/ami-amazon-linux-latest\/al2023-ami-kernel-default-x86_64 --query Parameter.Value --output text\nterraform init\nterraform validate\nterraform plan -var=\"subnet_id=subnet-REPLACE\" -var=\"ami_id=ami-REPLACE\"\n<em># After replacing IDs: terraform apply -var=\"subnet_id=...\" -var=\"ami_id=...\"<\/em>\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Expected:<\/strong>&nbsp;An EC2 instance using a rendered startup script. No public IP or incoming rules are provisioned by this lab.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 4 \u2014 Student practice<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Compare Azure&nbsp;<code>custom_data = base64encode(templatefile(...))<\/code>&nbsp;with AWS&nbsp;<code>user_data = templatefile(...)<\/code>. Explain why only Azure is encoded explicitly.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Instructor note:<\/strong>&nbsp;Optional AWS companion. Requires a compatible Linux AMI and an existing subnet where an instance can launch. Verification inside the guest requires pre-existing management access such as SSM; this lab does not configure a key pair, SSM role or inbound security group.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Cleanup (once your verification is complete):<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform plan -destroy\nterraform destroy\n<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 class=\"wp-block-heading\">Module 6 \u2014 Best Practices and Real-World Design<\/h1>\n\n\n\n<h2 class=\"wp-block-heading\">Design principles for production-quality templates<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Put\u00a0<code>.tftpl<\/code>\u00a0files in a predictable\u00a0<code>templates\/<\/code>\u00a0directory, separate from\u00a0<code>main.tf<\/code>.<\/li>\n\n\n\n<li>Render text in\u00a0<code>locals<\/code>\u00a0where multiple templates need to be composed; avoid nested or recursive\u00a0<code>templatefile()<\/code>\u00a0calls.<\/li>\n\n\n\n<li>Use descriptive keys such as\u00a0<code>environment<\/code>,\u00a0<code>app_config<\/code>, or\u00a0<code>health_script<\/code>. Don&#8217;t pass everything simply because it exists.<\/li>\n\n\n\n<li>Prefer\u00a0<code>jsonencode<\/code>\u00a0or\u00a0<code>yamlencode<\/code>\u00a0for machine-readable formats. Let Terraform escape strings and build valid syntax.<\/li>\n\n\n\n<li>Keep secrets out of templates, scripts, logs,\u00a0<code>terraform output<\/code>, user data and local previews. Terraform state and saved plans can contain rendered secret values even when CLI output is hidden.<\/li>\n\n\n\n<li>Validate the generated artifact, not just the HCL syntax: JSON parser for JSON, YAML parser for cloud-init,\u00a0<code>sh -n<\/code>\u00a0for shell scripts and actual guest\/agent checks after provisioning.<\/li>\n\n\n\n<li>Make first-boot scripts safe to retry where possible. User data\/cloud-init normally runs only at first boot, and changes can have provider-specific replacement behavior.<\/li>\n\n\n\n<li>Add a focused test case for both sides of conditionals, empty and nonempty lists\/maps, and strings containing quotes or special characters.<\/li>\n\n\n\n<li>Minimize unnecessary complexity: a one-line string does not need a\u00a0<code>.tftpl<\/code>\u00a0file; avoid creating modules for a 5-line one-off template.<\/li>\n\n\n\n<li>Keep templates small and content-based. Do not turn them into alternative infrastructure management or put access credentials inside them.<\/li>\n<\/ol>\n\n\n\n<h2 class=\"wp-block-heading\">Final scenario<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A team needs to configure a newly provisioned Ubuntu VM with a small application configuration file and a health-check script. Three templates accomplish the content generation, while Terraform still creates just five Azure resources.&nbsp;<strong>The main teaching point is template composition.<\/strong><\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Lab 12 \u2014 Capstone: compose three templates into one Azure VM<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Estimated classroom slot:<\/strong>&nbsp;45 min<br><strong>Execution:<\/strong>&nbsp;<strong>Azure deploy \u2014 chargeable<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Objective:<\/strong>&nbsp;Generate application config and a health-check script, then embed both in cloud-init while keeping Azure infrastructure unchanged.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What changed from the previous lab:<\/strong>&nbsp;Extends the Lab 10 VM pattern with three small&nbsp;<code>.tftpl<\/code>&nbsp;files and one&nbsp;<code>locals<\/code>&nbsp;block. It still creates only the same five kinds of Azure resources.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 1 \u2014 Understand the folder<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>labs\/12-azure-capstone-composition\/\n  main.tf\n  templates\/app.conf.tftpl\n  templates\/health.sh.tftpl\n  templates\/cloud-init.tftpl\n  README.md\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 2 \u2014 Create\/review each file<\/h3>\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  required_providers {\n    azurerm = {\n      source  = \"hashicorp\/azurerm\"\n      version = \"~&gt; 5.9.0\"\n    }\n  }\n}\n\nprovider \"azurerm\" {\n  features {}\n}\n\nvariable \"location\" {\n  type    = string\n  default = \"eastus\"\n}\n\nvariable \"name_prefix\" {\n  type    = string\n  default = \"tpl-final\"\n}\n\nvariable \"ssh_public_key_path\" {\n  type    = string\n  default = \"~\/.ssh\/id_ed25519.pub\"\n}\n\nvariable \"app_name\" {\n  type    = string\n  default = \"demoapp\"\n}\n\nvariable \"environment\" {\n  type    = string\n  default = \"dev\"\n}\n\nvariable \"port\" {\n  type    = number\n  default = 8080\n}\n\nvariable \"enable_monitoring\" {\n  type    = bool\n  default = true\n}\n\nvariable \"settings\" {\n  type    = map(string)\n  default = { log_level = \"info\", workers = \"2\" }\n}\n\nlocals {\n  app_config = templatefile(\"${path.module}\/templates\/app.conf.tftpl\", {\n    name              = var.app_name\n    environment       = var.environment\n    port              = var.port\n    enable_monitoring = var.enable_monitoring\n    settings          = var.settings\n  })\n\n  health_script = templatefile(\"${path.module}\/templates\/health.sh.tftpl\", {\n    name = var.app_name\n  })\n\n  cloud_init = templatefile(\"${path.module}\/templates\/cloud-init.tftpl\", {\n    app_config    = local.app_config\n    health_script = local.health_script\n  })\n}\n\nresource \"azurerm_resource_group\" \"lab\" {\n  name     = \"${var.name_prefix}-rg\"\n  location = var.location\n}\n\nresource \"azurerm_virtual_network\" \"lab\" {\n  name                = \"${var.name_prefix}-vnet\"\n  location            = azurerm_resource_group.lab.location\n  resource_group_name = azurerm_resource_group.lab.name\n  address_space       = &#91;\"10.80.0.0\/16\"]\n}\n\nresource \"azurerm_subnet\" \"lab\" {\n  name                 = \"${var.name_prefix}-subnet\"\n  resource_group_name  = azurerm_resource_group.lab.name\n  virtual_network_name = azurerm_virtual_network.lab.name\n  address_prefixes     = &#91;\"10.80.1.0\/24\"]\n}\n\nresource \"azurerm_network_interface\" \"lab\" {\n  name                = \"${var.name_prefix}-nic\"\n  location            = azurerm_resource_group.lab.location\n  resource_group_name = azurerm_resource_group.lab.name\n\n  ip_configuration {\n    name                          = \"internal\"\n    subnet_id                     = azurerm_subnet.lab.id\n    private_ip_address_allocation = \"Dynamic\"\n  }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"lab\" {\n  name                  = \"${var.name_prefix}-vm\"\n  location              = azurerm_resource_group.lab.location\n  resource_group_name   = azurerm_resource_group.lab.name\n  size                  = \"Standard_B2s_v2\"\n  admin_username        = \"labadmin\"\n  network_interface_ids = &#91;azurerm_network_interface.lab.id]\n\n  disable_password_authentication = true\n  admin_ssh_key {\n    username   = \"labadmin\"\n    public_key = file(pathexpand(var.ssh_public_key_path))\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  custom_data = base64encode(local.cloud_init)\n}\n\noutput \"resource_group_name\" {\n  value = azurerm_resource_group.lab.name\n}\n\noutput \"vm_name\" {\n  value = azurerm_linux_virtual_machine.lab.name\n}\n\noutput \"config_preview\" {\n  value = local.app_config\n}\n\noutput \"cloud_init_sha256\" {\n  value = sha256(local.cloud_init)\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>templates\/app.conf.tftpl<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>name=${name}\nenvironment=${environment}\nport=${port}\n%{ if enable_monitoring ~}\nmonitoring=enabled\n%{ else ~}\nmonitoring=disabled\n%{ endif ~}\n%{ for key, value in settings ~}\n${key}=${value}\n%{ endfor ~}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>templates\/health.sh.tftpl<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>#!\/bin\/sh\nset -eu\ntest -f \/etc\/tf-template\/app.conf\nprintf '%s\\n' 'healthy: ${name}' &gt; \/var\/tmp\/tf-template-health\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>templates\/cloud-init.tftpl<\/code><\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>#cloud-config\n${yamlencode({\n  write_files = &#91;\n    {\n      path        = \"\/etc\/tf-template\/app.conf\"\n      permissions = \"0644\"\n      content     = app_config\n    },\n    {\n      path        = \"\/usr\/local\/bin\/tf-template-health.sh\"\n      permissions = \"0755\"\n      content     = health_script\n    }\n  ]\n  runcmd = &#91;&#91;\"\/usr\/local\/bin\/tf-template-health.sh\"]]\n})}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 3 \u2014 Run and inspect<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">From the lab folder, use the following commands. Replace placeholder IDs for cloud labs.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>az login\nexport ARM_SUBSCRIPTION_ID=\"$(az account show --query id -o tsv)\"\nterraform init\nterraform validate\nterraform console  <em># inspect local.app_config and local.cloud_init; type exit to quit<\/em>\nterraform plan -out=tfplan\nterraform apply tfplan\naz vm run-command invoke -g \"$(terraform output -raw resource_group_name)\" -n \"$(terraform output -raw vm_name)\" --command-id RunShellScript --scripts \"cloud-init status --wait; cat \/etc\/tf-template\/app.conf; cat \/var\/tmp\/tf-template-health\"\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Expected:<\/strong>&nbsp;Azure VM has&nbsp;<code>\/etc\/tf-template\/app.conf<\/code>&nbsp;with map entries and conditionals; cloud-init runs the script and writes&nbsp;<code>healthy: demoapp<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 4 \u2014 Student practice<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Change&nbsp;<code>enable_monitoring<\/code>&nbsp;to&nbsp;<code>false<\/code>&nbsp;and add&nbsp;<code>owner = \"platform\"<\/code>&nbsp;to&nbsp;<code>settings<\/code>. Use&nbsp;<code>terraform console<\/code>&nbsp;to review rendered content&nbsp;<em>before<\/em>&nbsp;any apply. In a real deployed VM this custom-data change can require replacement.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Instructor note:<\/strong>&nbsp;The three templates are composed by Terraform&nbsp;<code>locals<\/code>; the templates do not recursively call&nbsp;<code>templatefile<\/code>. Two separate previews help verify content before cloud creation. The example deliberately keeps package installation and inbound networking outside scope.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Cleanup (once your verification is complete):<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>terraform plan -destroy\nterraform destroy\n<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 class=\"wp-block-heading\">Review, validation, assessment and common pitfalls<\/h1>\n\n\n\n<h2 class=\"wp-block-heading\">End-to-end step-by-step runbook<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Extract the ZIP and read this guide plus\u00a0<code>README.md<\/code>.<\/li>\n\n\n\n<li>Start with\u00a0<code>labs\/01-inline-interpolation<\/code>. Execute\u00a0<code>terraform init -backend=false<\/code>,\u00a0<code>terraform validate<\/code>,\u00a0<code>terraform apply<\/code>, and\u00a0<code>terraform output<\/code>.<\/li>\n\n\n\n<li>Move through Labs 02\u201307. Focus on the exact\u00a0<strong>new line(s)<\/strong>\u00a0added in each folder. Use\u00a0<code>terraform console<\/code>\u00a0to examine expressions.<\/li>\n\n\n\n<li>Run Lab 08; render the script locally; syntax-check it with\u00a0<code>sh -n<\/code>; run only this harmless demo in your own shell.<\/li>\n\n\n\n<li>Run Lab 09; preview cloud-init output. Do not apply any cloud resources until you have inspected the generated YAML.<\/li>\n\n\n\n<li>Run Azure Lab 10 only if you have a budget and permission. Verify via Azure Run Command; destroy it afterward.<\/li>\n\n\n\n<li>Optionally run AWS Lab 11 using your own existing subnet and matching AMI. Do not assume it has SSH or internet.<\/li>\n\n\n\n<li>Run the final composition Lab 12 if you wish to do the complete cloud exercise. Verify the generated configuration in the guest, then destroy it.<\/li>\n\n\n\n<li>Ask students to explain where string rendering happens versus where a VM&#8217;s first-boot agent executes scripts.<\/li>\n<\/ol>\n\n\n\n<h2 class=\"wp-block-heading\">Quick reference: template syntax<\/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\">Intent<\/th><th class=\"has-text-align-left\" data-align=\"left\">Syntax<\/th><th class=\"has-text-align-left\" data-align=\"left\">Example<\/th><\/tr><\/thead><tbody><tr><td>Insert a value<\/td><td><code>${...}<\/code><\/td><td><code>name=${app.name}<\/code><\/td><\/tr><tr><td>Apply a function<\/td><td><code>${...}<\/code><\/td><td><code>${upper(name)}<\/code><\/td><\/tr><tr><td>Conditional<\/td><td><code>%{ if }<\/code>&nbsp;\/&nbsp;<code>%{ else }<\/code>&nbsp;\/&nbsp;<code>%{ endif }<\/code><\/td><td>See Lab 04<\/td><\/tr><tr><td>Repeat a list<\/td><td><code>%{ for item in items }<\/code>&nbsp;\/&nbsp;<code>%{ endfor }<\/code><\/td><td>See Lab 05<\/td><\/tr><tr><td>Repeat a map<\/td><td><code>%{ for key, value in map }<\/code>&nbsp;\/&nbsp;<code>%{ endfor }<\/code><\/td><td>See Lab 06<\/td><\/tr><tr><td>Strip whitespace after sequence<\/td><td><code>~}<\/code><\/td><td><code>%{ for item in items ~}<\/code><\/td><\/tr><tr><td>Strip whitespace before sequence<\/td><td><code>%{~<\/code><\/td><td><code>%{~ endif }<\/code><\/td><\/tr><tr><td>Literal shell&nbsp;<code>${HOME}<\/code>&nbsp;in output<\/td><td><code>$${HOME}<\/code><\/td><td>See Lab 08<\/td><\/tr><tr><td>Literal&nbsp;<code>%{ if ... }<\/code>&nbsp;in output<\/td><td><code>%%{ if ... }<\/code><\/td><td>Only when emitting template-like text literally<\/td><\/tr><tr><td>Render a file<\/td><td><code>templatefile(path, vars)<\/code><\/td><td>See Lab 02<\/td><\/tr><tr><td>Safe JSON\/YAML<\/td><td><code>jsonencode<\/code>,&nbsp;<code>yamlencode<\/code><\/td><td>See Labs 07, 09<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Artifact inspection examples<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">After running the corresponding local labs:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em># Lab 07: structured JSON check<\/em>\nterraform output -raw config_json | python3 -m json.tool\n\n<em># Lab 08: shell syntax check (after changing to Lab 08 folder)<\/em>\nterraform output -raw startup_script &gt; \/tmp\/tf-template-startup.sh\nsh -n \/tmp\/tf-template-startup.sh\n\n<em># Lab 09: rendered cloud-init header<\/em>\nterraform output -raw cloud_init | head -20\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">For a YAML parser, use your environment&#8217;s existing YAML tooling; Python&#8217;s&nbsp;<code>yaml.safe_load()<\/code>&nbsp;is available only when the optional&nbsp;<code>PyYAML<\/code>&nbsp;package is installed. Plain&nbsp;<code>terraform validate<\/code>&nbsp;<strong>cannot<\/strong>&nbsp;prove that a rendered script will execute correctly or that a guest can access a network package repository.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Example trainer demonstration: the exact Azure vs AWS difference<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code># Azure (Linux VM): Base64-encode the rendered cloud-init file.\ncustom_data = base64encode(templatefile(\"${path.module}\/templates\/cloud-init.tftpl\", {\n  app_name    = var.app_name\n  environment = var.environment\n}))\n\n# AWS (EC2): Supply the plain rendered script to user_data.\nuser_data = templatefile(\"${path.module}\/templates\/startup.sh.tftpl\", {\n  app_name    = var.app_name\n  environment = var.environment\n})\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Azure migration warning if you reuse your OLD 13-resource lab<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Your earlier state managed a Windows VM and an Ubuntu VM plus networking.&nbsp;<strong>Do not paste the new five-resource lab into that state<\/strong>: names, Terraform addresses, CIDRs and authentication assumptions are different. If you instead add&nbsp;<code>custom_data<\/code>&nbsp;to an&nbsp;<strong>already-created<\/strong>&nbsp;<code>azurerm_linux_virtual_machine.ubuntu<\/code>&nbsp;resource, AzureRM typically treats the custom-data change as&nbsp;<strong>ForceNew<\/strong>, which leads to VM replacement; that is not an innocuous in-place text update. Export\/secure your old state and review every proposed change carefully. For teaching, use the supplied standalone Lab 10 or Lab 12 directory.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Common mistakes and repair strategies<\/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\">Symptom<\/th><th class=\"has-text-align-left\" data-align=\"left\">Likely cause<\/th><th class=\"has-text-align-left\" data-align=\"left\">Resolution<\/th><\/tr><\/thead><tbody><tr><td>Terraform says file not found<\/td><td>Wrong path or filename<\/td><td>Check&nbsp;<code>templates\/*.tftpl<\/code>; use&nbsp;<code>${path.module}<\/code><\/td><\/tr><tr><td>Template says key&nbsp;<code>env<\/code>&nbsp;missing<\/td><td>Passed&nbsp;<code>environment<\/code>&nbsp;instead of&nbsp;<code>env<\/code><\/td><td>Make keys match exactly<\/td><\/tr><tr><td><code>${HOME}<\/code>&nbsp;fails or becomes Terraform syntax<\/td><td>Bash expansion was mistaken for Terraform<\/td><td>Write&nbsp;<code>$${HOME}<\/code>&nbsp;in a&nbsp;<code>.tftpl<\/code>&nbsp;file<\/td><\/tr><tr><td><code>${var.app_name}<\/code>&nbsp;fails in a&nbsp;<code>.tftpl<\/code>&nbsp;file<\/td><td><code>var<\/code>&nbsp;object was never passed<\/td><td>Use&nbsp;<code>${app_name}<\/code>&nbsp;after passing&nbsp;<code>app_name = var.app_name<\/code><\/td><\/tr><tr><td>Weird empty lines in config<\/td><td>Directive lines add literal newlines<\/td><td>Use whitespace trim markers like&nbsp;<code>%{ for x in xs ~}<\/code><\/td><\/tr><tr><td>JSON\/YAML parser rejects output<\/td><td>Handwritten quoting, indentation, escaping<\/td><td>Use&nbsp;<code>jsonencode()<\/code>&nbsp;or&nbsp;<code>yamlencode()<\/code><\/td><\/tr><tr><td>Azure VM plan shows&nbsp;<code>-\/+<\/code><\/td><td>Changed&nbsp;<code>custom_data<\/code>&nbsp;\/ image \/ other ForceNew field<\/td><td>Stop; inspect replacement before applying<\/td><\/tr><tr><td>Cloud-init file missing after deploy<\/td><td>Cloud-init not finished or script failed<\/td><td>Use&nbsp;<code>cloud-init status --wait<\/code>, inspect guest logs<\/td><\/tr><tr><td>AWS user-data didn&#8217;t run on reboot<\/td><td>First-boot behavior is the default<\/td><td>Don&#8217;t confuse configuration with a recurring service<\/td><\/tr><tr><td>Terraform state contains rendered credentials<\/td><td>Secrets were interpolated into files\/plan<\/td><td>Remove secrets; use cloud-native secret delivery and protect\/revoke exposed credentials<\/td><\/tr><tr><td>Ubuntu VM image or size rejected<\/td><td>Not available in selected Azure region\/subscription<\/td><td>Check&nbsp;<code>az vm image list<\/code>,&nbsp;<code>az vm list-skus<\/code>, quota\/policy; choose an approved image\/SKU<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Security, operations and cost notes<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>The cloud labs create real chargeable VMs;\u00a0<code>terraform destroy<\/code>\u00a0stops Terraform-managed resources, but confirm the provider released everything.<\/li>\n\n\n\n<li>Azure lab uses\u00a0<code>Canonical:ubuntu-26_04-lts:server:latest<\/code>\u00a0and\u00a0<code>Standard_B2s_v2<\/code>, matching the earlier familiar Linux VM pattern. Availability varies by region, subscription, image\/policy and capacity.<\/li>\n\n\n\n<li>Azure network configuration has\u00a0<strong>no public IP and no NSG inbound rule<\/strong>, so you cannot SSH from your laptop without designing a separate private access route. Use Azure Run Command for this training exercise.<\/li>\n\n\n\n<li>AWS Lab 11 does not provision VPC resources or grant guest access: the subnet and AMI are intentionally required inputs. It similarly avoids public SSH exposure.<\/li>\n\n\n\n<li><code>sensitive = true<\/code>\u00a0hides some CLI displays but\u00a0<strong>does not remove secret values from state, saved plans, custom data or guest metadata<\/strong>. Do not use secrets in this tutorial&#8217;s templates.<\/li>\n\n\n\n<li>Template files must be present before the run; do not ask\u00a0<code>templatefile()<\/code>\u00a0to consume files produced by a later resource in the same apply.<\/li>\n\n\n\n<li>Templates create\u00a0<em>text<\/em>; providers and cloud-init\/user-data determine when and how that text is delivered and executed.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Two short assignments (20\u201330 minutes)<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Assignment 1 \u2014 Safe config generation (no cloud):<\/strong>&nbsp;Start from Lab 06. Set&nbsp;<code>environment = \"stage\"<\/code>, turn&nbsp;<code>enable_logging = false<\/code>, add&nbsp;<code>package=git<\/code>&nbsp;and&nbsp;<code>owner=platform<\/code>, then produce clean, nonblank config lines.&nbsp;<strong>Acceptance:<\/strong>&nbsp;correct condition, three packages, two or more settings, no duplicate blank lines.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Assignment 2 \u2014 Azure cloud-init customization:<\/strong>&nbsp;Start from Lab 12. Add another safe setting named&nbsp;<code>team = \"training\"<\/code>; change the health script marker filename to&nbsp;<code>\/var\/tmp\/tf-template-training<\/code>. Preview the final&nbsp;<code>local.cloud_init<\/code>, validate, deploy&nbsp;<em>only after a clean plan<\/em>&nbsp;and confirm the new file using Run Command.&nbsp;<strong>Acceptance:<\/strong>&nbsp;exactly five planned Azure resources in a new environment; rendered text is correct; cleanup succeeds.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Trainer comprehension questions and answers<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>What is the difference between variables and templates?<\/strong>\u00a0Variables are typed inputs; templates generate formatted output text from input values.<\/li>\n\n\n\n<li><strong>Does\u00a0<code>templatefile<\/code>\u00a0deploy anything?<\/strong>\u00a0No. It renders a string; a resource argument such as\u00a0<code>custom_data<\/code>\u00a0or\u00a0<code>user_data<\/code>\u00a0makes it useful to cloud automation.<\/li>\n\n\n\n<li><strong>Why not use\u00a0<code>${var.app_name}<\/code>\u00a0inside a\u00a0<code>.tftpl<\/code>\u00a0file?<\/strong>\u00a0Only names supplied by the\u00a0<code>vars<\/code>\u00a0object are in scope.<\/li>\n\n\n\n<li><strong>How do we safely render JSON\/YAML?<\/strong>\u00a0Use\u00a0<code>jsonencode()<\/code>\/<code>yamlencode()<\/code>\u00a0from an object instead of manually concatenating punctuation and quotes.<\/li>\n\n\n\n<li><strong>What does\u00a0<code>$${VAR}<\/code>\u00a0do?<\/strong>\u00a0Renders literal\u00a0<code>${VAR}<\/code>\u00a0for a later interpreter such as a shell.<\/li>\n\n\n\n<li><strong>Why does Azure encode but AWS does not?<\/strong>\u00a0The AzureRM Linux VM\u00a0<code>custom_data<\/code>\u00a0schema expects Base64 content; the AWS EC2 provider&#8217;s\u00a0<code>user_data<\/code>\u00a0argument expects plain text.<\/li>\n\n\n\n<li><strong>Does changed cloud-init automatically rerun on an existing VM?<\/strong>\u00a0No; it is typically a first-boot mechanism and AzureRM custom-data changes typically force VM replacement.<\/li>\n<\/ol>\n\n\n\n<h2 class=\"wp-block-heading\">Packaged content and optional automated checks<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>Terraform_Templates_Training_2026\/\n\u251c\u2500\u2500 Terraform_Templates_Step_by_Step_Training.md\n\u251c\u2500\u2500 README.md\n\u251c\u2500\u2500 .gitignore\n\u251c\u2500\u2500 labs\/\n\u2502   \u251c\u2500\u2500 01-inline-interpolation\/\n\u2502   \u251c\u2500\u2500 02-first-templatefile\/\n\u2502   \u251c\u2500\u2500 03-objects-and-scope\/\n\u2502   \u251c\u2500\u2500 04-if-else-directives\/\n\u2502   \u251c\u2500\u2500 05-for-list-directive\/\n\u2502   \u251c\u2500\u2500 06-maps-and-whitespace\/\n\u2502   \u251c\u2500\u2500 07-json-and-debugging\/\n\u2502   \u251c\u2500\u2500 08-shell-script-template\/\n\u2502   \u251c\u2500\u2500 09-cloud-init-preview\/\n\u2502   \u251c\u2500\u2500 10-azure-ubuntu-cloud-init\/\n\u2502   \u251c\u2500\u2500 11-aws-ec2-user-data\/\n\u2502   \u2514\u2500\u2500 12-azure-capstone-composition\/\n\u2514\u2500\u2500 scripts\/\n    \u251c\u2500\u2500 validate_labs.sh\n    \u2514\u2500\u2500 test_local_labs.sh\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">After installing Terraform and giving the machine internet access for provider downloads:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>bash scripts\/validate_labs.sh   <em># fmt + init + validate; cloud providers download but no apply<\/em>\nbash scripts\/test_local_labs.sh <em># apply LOCAL labs 01-09; no cloud provisioning<\/em>\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Do not mistake format\/validation checks for a cloud deployment test. Verify account, quota, policy, image SKU and guest initialization in your own environment.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Official sources<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/developer.hashicorp.com\/terraform\/language\/expressions\/strings\">Terraform: Strings and Templates<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/developer.hashicorp.com\/terraform\/language\/functions\/templatefile\">Terraform:\u00a0<code>templatefile()<\/code><\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/registry.terraform.io\/providers\/hashicorp\/template\/latest\/docs\">Archived\u00a0<code>hashicorp\/template<\/code>\u00a0provider and its replacement<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/developer.hashicorp.com\/terraform\/language\/functions\/yamlencode\">Terraform:\u00a0<code>jsonencode()<\/code>\u00a0and\u00a0<code>yamlencode()<\/code><\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/developer.hashicorp.com\/terraform\/language\/functions\/file\">Terraform:\u00a0<code>file()<\/code><\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/registry.terraform.io\/providers\/hashicorp\/azurerm\/latest\/docs\/resources\/linux_virtual_machine\">AzureRM Linux VM resource \u2014\u00a0<code>custom_data<\/code><\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/learn.microsoft.com\/en-us\/azure\/virtual-machines\/linux\/cloudinit-bash-script\">Microsoft: cloud-init on Azure Linux VMs<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/learn.microsoft.com\/en-us\/azure\/virtual-machines\/linux\/run-command\">Microsoft: Azure VM Run Command<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/docs.aws.amazon.com\/AWSEC2\/latest\/UserGuide\/user-data.html\">Amazon EC2: Linux user data<\/a><\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Validation transparency:<\/strong>&nbsp;This course was assembled as local files and inspected for packaging, source\/code consistency and script syntax. It was&nbsp;<strong>not<\/strong>&nbsp;deployed against live Azure\/AWS accounts. Terraform CLI\/provider validation is documented as a learner-side step and requires the CLI plus provider downloads.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Level:&nbsp;Beginner to IntermediateFormat:&nbsp;Tutorial + concept guide + practical lab workbookModules:&nbsp;6 |&nbsp;Labs:&nbsp;12 |&nbsp;Cloud emphasis:&nbsp;Azure Linux VM, AWS EC2 comparisonBaseline:&nbsp;Terraform 1.15.9+ (within 1.x); AzureRM 5.9.x; AWS provider 6.xUpdated:&nbsp;14 October&#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-1237","post","type-post","status-publish","format-standard","hentry","category-uncategorized"],"_links":{"self":[{"href":"https:\/\/www.devopsschool.com\/tutorials\/wp-json\/wp\/v2\/posts\/1237","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=1237"}],"version-history":[{"count":1,"href":"https:\/\/www.devopsschool.com\/tutorials\/wp-json\/wp\/v2\/posts\/1237\/revisions"}],"predecessor-version":[{"id":1238,"href":"https:\/\/www.devopsschool.com\/tutorials\/wp-json\/wp\/v2\/posts\/1237\/revisions\/1238"}],"wp:attachment":[{"href":"https:\/\/www.devopsschool.com\/tutorials\/wp-json\/wp\/v2\/media?parent=1237"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.devopsschool.com\/tutorials\/wp-json\/wp\/v2\/categories?post=1237"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.devopsschool.com\/tutorials\/wp-json\/wp\/v2\/tags?post=1237"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}