<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ipadeola Taiwo</title>
    <description>The latest articles on DEV Community by Ipadeola Taiwo (@highpee1991).</description>
    <link>https://dev.to/highpee1991</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1894849%2F48eadcb7-7cf7-4299-8728-58c535f1e6bd.jpeg</url>
      <title>DEV Community: Ipadeola Taiwo</title>
      <link>https://dev.to/highpee1991</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/highpee1991"/>
    <language>en</language>
    <item>
      <title>Building a Terraform CI/CD Pipeline With GitHub Actions (And Debugging Every Single Thing That Broke)</title>
      <dc:creator>Ipadeola Taiwo</dc:creator>
      <pubDate>Tue, 11 Aug 2026 09:03:45 +0000</pubDate>
      <link>https://dev.to/highpee1991/building-a-terraform-cicd-pipeline-with-github-actions-and-debugging-every-single-thing-that-15bf</link>
      <guid>https://dev.to/highpee1991/building-a-terraform-cicd-pipeline-with-github-actions-and-debugging-every-single-thing-that-15bf</guid>
      <description>&lt;p&gt;After getting comfortable with Terraform modules across multiple environments, I gave myself a new scenario. Imagine the CTO of the company sends down a new rule: no engineer runs terraform apply manually against production ever again. Every infrastructure change goes through a pull request. A pipeline validates it, plans it, and only applies once that change is actually merged to main. If something is wrong, it fails inside the pipeline, not in front of customers.&lt;/p&gt;

&lt;p&gt;This post is the build of that pipeline, and honestly it turned into one of the most instructive things I have done, mostly because of how many small, sharp mistakes I made along the way and had to actually understand before I could fix them. If you are trying to build your first real CI/CD pipeline for Terraform, I want this to save you some of the pain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plan
&lt;/h2&gt;

&lt;p&gt;Two jobs matter most here. A validate and plan job that runs on every pull request, and an apply job that only runs after code lands on main. I reused the module and one environment, payments, from an earlier project of mine, copied into a fresh repo built specifically for this pipeline.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;terraform-cicd-pipeline/
├── .github/workflows/terraform.yml
├── environments/payments/
│   ├── providers.tf
│   ├── main.tf
│   └── terraform.tfvars
└── modules/infrastructure/
    ├── main.tf
    ├── variables.tf
    ├── outputs.tf
    └── cloud-init.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Setting up the backend and authentication
&lt;/h2&gt;

&lt;p&gt;Same manual backend pattern as always, since Terraform cannot create the storage it depends on.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az group create &lt;span class="nt"&gt;--name&lt;/span&gt; ci-cd-pipeline-backend-rg &lt;span class="nt"&gt;--location&lt;/span&gt; eastus
az storage account create &lt;span class="nt"&gt;--name&lt;/span&gt; cicdpipestorageacc &lt;span class="nt"&gt;--resource-group&lt;/span&gt; ci-cd-pipeline-backend-rg &lt;span class="nt"&gt;--location&lt;/span&gt; eastus &lt;span class="nt"&gt;--sku&lt;/span&gt; Standard_LRS &lt;span class="nt"&gt;--kind&lt;/span&gt; StorageV2
az storage container create &lt;span class="nt"&gt;--name&lt;/span&gt; ci-cd-container &lt;span class="nt"&gt;--account-name&lt;/span&gt; cicdpipestorageacc &lt;span class="nt"&gt;--auth-mode&lt;/span&gt; login
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;For authentication, a pipeline should never use a personal login. It needs its own identity, a Service Principal, scoped to what it actually needs to do.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;MSYS_NO_PATHCONV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 az ad sp create-for-rbac &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"github-actions-terraform"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role&lt;/span&gt; Contributor &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--scopes&lt;/span&gt; /subscriptions/&amp;lt;subscription-id&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--sdk-auth&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That MSYS_NO_PATHCONV bit came from a genuinely confusing early mistake. On Git Bash for Windows, any argument starting with a single slash gets silently rewritten into a Windows filesystem path before Azure CLI even sees it. So /subscriptions/xxxx quietly became C:/Program Files/Git/subscriptions/xxxx, and Azure returned a MissingSubscription error that had nothing obviously to do with slashes. That one environment variable prefix tells Git Bash to leave this specific command alone.&lt;/p&gt;

&lt;p&gt;The four values that command outputs go into GitHub as repository secrets.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcyk4853qnalt0rqksiqm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcyk4853qnalt0rqksiqm.png" alt=" " width="800" height="421"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Two different kinds of authentication living in the same workflow
&lt;/h2&gt;

&lt;p&gt;This tripped me up conceptually before it tripped me up technically. There are two separate things reading those same four secrets, doing two different jobs.&lt;/p&gt;

&lt;p&gt;The azure/login action logs the Azure CLI itself into the runner. Useful if any step needs to run a plain az command.&lt;/p&gt;

&lt;p&gt;Separately, Terraform's own provider and backend need ARM_CLIENT_ID, ARM_CLIENT_SECRET, ARM_SUBSCRIPTION_ID, and ARM_TENANT_ID set as environment variables. Terraform does not automatically inherit whatever the Azure CLI is logged in as. Without these four set explicitly, terraform init fails with a very specific error: authenticating using the Azure CLI is only supported as a user, not a service principal. Both pieces are needed, and neither replaces the other.&lt;/p&gt;
&lt;h2&gt;
  
  
  The workflow file, and everything wrong with my first draft
&lt;/h2&gt;

&lt;p&gt;I want to be honest about how many small mistakes stacked up in the first version of this file, because I think seeing the actual failures is more useful than seeing a clean finished workflow with no context.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgsk0s0s2o41c80akocut.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgsk0s0s2o41c80akocut.png" alt=" " width="800" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The very first error was a missing comma inside the JSON block that azure/login reads. Plain JSON needs a comma after every property except the last one, and I had none. GitHub Actions caught this before the workflow even ran, which is honestly the best possible time for it to fail.&lt;/p&gt;

&lt;p&gt;Other things I found and fixed one at a time across several pushes: capitalized Terraform subcommands like terraform Init that Terraform's CLI does not recognize since subcommands are lowercase only, a case mismatch between a job named Terraform-validate and another job's needs field referencing terraform-validate lowercase, since job IDs are case sensitive, and a typo in an action name, acttions/download-artifact instead of actions, which fails to resolve entirely since GitHub reads that as a literal, nonexistent repository.&lt;/p&gt;
&lt;h2&gt;
  
  
  The six minute silent hang
&lt;/h2&gt;

&lt;p&gt;This one was the strangest to diagnose because there was no error at all, just a plan step that sat there doing nothing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftuj1da2f19jw2e1a8nfh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftuj1da2f19jw2e1a8nfh.png" alt=" " width="800" height="394"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The cause was a typo in one of my TF_VAR environment variables, admin_usename instead of admin_username. Since that variable had deliberately been given no default value, based on a rule I had already learned about identity related variables never defaulting silently, Terraform tried to do the right thing and ask for a value interactively. On my own terminal that would show a prompt I could just answer. On a GitHub Actions runner, there is no one there to answer it, so it just waits, forever, with zero error output.&lt;/p&gt;

&lt;p&gt;The fix was adding -input=false to both the plan and apply commands. This tells Terraform never to wait for interactive input, ever, and instead fail immediately and loudly if something required is missing. That single flag turned an invisible hang into an instant, readable error the next time a typo happened.&lt;/p&gt;
&lt;h2&gt;
  
  
  Cancelling that hung run left a real problem behind
&lt;/h2&gt;

&lt;p&gt;Cancelling a stuck workflow does not run Terraform's normal cleanup. It just kills the process. Whatever lock Terraform had acquired on the state file right before it started waiting for input never got released.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs02mdtkd2s4ezml870qs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs02mdtkd2s4ezml870qs.png" alt=" " width="800" height="397"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The important thing to understand here is that the lock lives on the actual blob in Azure Storage, not inside GitHub Actions itself. So clearing it does not require anything special about CI, it just requires a client authenticated to the same backend.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform force-unlock &amp;lt;lock-id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Run locally, against the same backend config, and the lock clears the same way it would if the stuck operation had come from a teammate's laptop instead of a runner.&lt;/p&gt;
&lt;h2&gt;
  
  
  A file that only existed on my machine
&lt;/h2&gt;

&lt;p&gt;Once the workflow started actually running plan correctly, it immediately failed on missing required variables I definitely had values for. address_space, subnet_prefix, environment, backend_state_key, all of them living happily in my local terraform.tfvars.&lt;/p&gt;

&lt;p&gt;The lesson here is one of the most important things to understand about any CI system. GitHub Actions runs on a completely fresh, disposable machine that has never seen your laptop and never will. It only ever sees what is actually committed and pushed to the repository. My terraform.tfvars was sitting quietly excluded by a default *.tfvars rule in .gitignore, so it existed locally and nowhere else. Since nothing in that particular file was sensitive, just config values, the fix was removing that line from .gitignore and actually committing the file.&lt;/p&gt;
&lt;h2&gt;
  
  
  SSH keys, and the difference between a path and content
&lt;/h2&gt;

&lt;p&gt;The VM needed an SSH public key, and my module originally read it with file(pathexpand(var.ssh_public_key_path)), the same approach that worked fine on my own machine. It cannot work in CI. That function reads a real file from disk at plan time, and the disposable runner has no ssh folder with my keys in it at all.&lt;/p&gt;

&lt;p&gt;The fix was changing the module to accept the key's actual text content as a variable, rather than a path to read. The public key itself is safe to store as a GitHub secret and pass in directly as TF_VAR_ssh_public_key, no file reading involved.&lt;/p&gt;
&lt;h2&gt;
  
  
  Getting the pipeline to actually deploy a live website
&lt;/h2&gt;

&lt;p&gt;For the VM's own provisioning, update, upgrade, install nginx, clone a portfolio site, I used cloud-init rather than having the pipeline SSH in after the fact. Cloud-init runs automatically the moment the VM boots, which keeps the whole thing declarative instead of needing a private key sitting in my pipeline's secrets.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;#cloud-config&lt;/span&gt;
&lt;span class="na"&gt;package_update&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;package_upgrade&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;nginx&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;git&lt;/span&gt;

&lt;span class="na"&gt;runcmd&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;rm -rf /var/www/html&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;git clone https://github.com/highpee1991/cloud-devops-portfolio-repo.git /var/www/html&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;systemctl enable nginx&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;systemctl restart nginx&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Two small but completely blocking typos hid in here for a while. First, my header line read hash cloud config with a space, which cloud-init reads as an ordinary comment rather than recognizing the file as a real cloud-config document at all, so it silently did nothing. Second, once that was fixed, I had package-update and package-upgrade with hyphens. Cloud-init's real key names use underscores. That mismatch failed schema validation and quietly skipped the whole final provisioning stage, no nginx, no clone, with almost nothing useful in the general log to point at why.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Furesbfugkm2dkjs6e17u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Furesbfugkm2dkjs6e17u.png" alt=" " width="799" height="439"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Tracing an invisible failure all the way to its root cause
&lt;/h2&gt;

&lt;p&gt;Even after fixing both typos, the site still would not load. This is the part I am most proud of in this whole project, because instead of stopping at the first plausible explanation, I actually traced the failure the whole way down.&lt;/p&gt;

&lt;p&gt;Browser, to nginx, to an empty var www html folder, to no git repository present, to a cloud-init schema error, to a systemd failure, to the actual root cause: the Linux kernel's OOM killer.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2mfszg079a6cbrs8tv1z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2mfszg079a6cbrs8tv1z.png" alt=" " width="800" height="649"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;My VM size, Standard B1ls, only has about 393 megabytes of RAM. Asking it to run a full apt upgrade and install two packages unattended, all in one shot, was simply too much. The kernel killed the heaviest process mid operation to protect the system, which is exactly why cloud-init reported finishing with no obvious errors while accomplishing nothing. Sizing up to Standard B2s, with about 3.8 gigabytes of RAM, gave it enough headroom to actually finish the job.&lt;/p&gt;
&lt;h2&gt;
  
  
  It finally worked
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq7l4e2s8fdx775mqspc0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq7l4e2s8fdx775mqspc0.png" alt=" " width="800" height="463"&gt;&lt;/a&gt;&lt;br&gt;
A pipeline that validates, plans, and applies entirely through pull requests and merges, provisioning a real VM that installs its own software and serves a real website, with zero manual terraform apply anywhere in the loop. I tore the infrastructure down after confirming it worked, since there was no reason to keep it running once the point was proven.&lt;/p&gt;
&lt;h2&gt;
  
  
  What actually stuck with me
&lt;/h2&gt;

&lt;p&gt;Almost every failure in this build came down to the same underlying idea in different clothes. A CI runner is a stranger to your machine. It cannot see your files, your SSH keys, your local Azure login, or anything you have not explicitly given it through a commit or a secret. Once that idea genuinely clicked, most of these bugs stopped being mysterious and started being predictable.&lt;/p&gt;

&lt;p&gt;If you want to see the full pipeline, including the workflow file and the cloud-init script:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/highpee1991" rel="noopener noreferrer"&gt;
        highpee1991
      &lt;/a&gt; / &lt;a href="https://github.com/highpee1991/terraform-cicd-pipeline" rel="noopener noreferrer"&gt;
        terraform-cicd-pipeline
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;terraform-cicd-pipeline — Runbook&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;A GitHub Actions pipeline that manages Azure infrastructure through Terraform, so no engineer ever runs &lt;code&gt;terraform apply&lt;/code&gt; by hand against production. Every infrastructure change goes through a pull request; the pipeline validates and plans it, and only applies once that change is merged to &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Scenario&lt;/h2&gt;
&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;No engineer manually runs &lt;code&gt;terraform apply&lt;/code&gt; in production ever again. Every infrastructure change goes through a pull request. The pipeline validates it, posts the plan as a comment, and applies it only when the PR is merged to main. If it fails, it fails in the pipeline, not in production.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Repository layout&lt;/h2&gt;
&lt;/div&gt;

&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;
&lt;pre class="notranslate"&gt;&lt;code&gt;terraform-cicd-pipeline/
├── .github/
│   └── workflows/
│       └── terraform.yml
├── environments/
│   └── payments/
│       ├── providers.tf      # backend + provider blocks
│       ├── main.tf           # module call + variable declarations + outputs
│       └── terraform.tfvars  # non-sensitive config values
└── modules/
    └── infrastructure/
        ├── main.tf           # resource&lt;/code&gt;&lt;/pre&gt;…&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/highpee1991/terraform-cicd-pipeline" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;



&lt;p&gt;If you are building something similar and hit a wall, drop a comment, happy to help however I can.&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>azure</category>
      <category>devops</category>
      <category>githubactions</category>
    </item>
    <item>
      <title>Refactoring Terraform Infrastructure Into Reusable Modules (Multi-Environment, One Codebase)</title>
      <dc:creator>Ipadeola Taiwo</dc:creator>
      <pubDate>Thu, 06 Aug 2026 11:12:13 +0000</pubDate>
      <link>https://dev.to/highpee1991/refactoring-terraform-infrastructure-into-reusable-modules-multi-environment-one-codebase-39a</link>
      <guid>https://dev.to/highpee1991/refactoring-terraform-infrastructure-into-reusable-modules-multi-environment-one-codebase-39a</guid>
      <description>&lt;p&gt;After building out the first version of my Azure infrastructure with Terraform, I ran into the problem every team eventually runs into. I needed the same infrastructure in three different places, a payments environment, an analytics environment, and a platform environment, and my first instinct was to just copy the same main.tf into three folders and tweak a few values.&lt;/p&gt;

&lt;p&gt;That works until it doesn't. The moment you need to change one thing, add a tag, fix a rule, adjust a size, you're now updating it in three places by hand. Forget one, and you've got three environments quietly drifting apart from each other. This post is how I refactored that same infrastructure into a single reusable module that all three environments call, what broke along the way, and the two real recovery situations I had to work through when things genuinely went wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of it
&lt;/h2&gt;

&lt;p&gt;Instead of one flat project, this became two things. A module, which holds the actual resource definitions written once. And three environments, which each call that module with their own values.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsd2t5zave9qmlc7ufurt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsd2t5zave9qmlc7ufurt.png" alt=" " width="690" height="573"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The module lives in &lt;code&gt;modules/infrastructure/&lt;/code&gt; with a &lt;code&gt;main.tf&lt;/code&gt;, &lt;code&gt;variables.tf&lt;/code&gt;, and &lt;code&gt;outputs.tf&lt;/code&gt;, same idea as before just without hardcoding anything specific to one environment. Each environment folder under &lt;code&gt;environments/&lt;/code&gt; gets its own &lt;code&gt;providers.tf&lt;/code&gt;, &lt;code&gt;main.tf&lt;/code&gt;, and &lt;code&gt;terraform.tfvars&lt;/code&gt;. The environment's &lt;code&gt;main.tf&lt;/code&gt; doesn't define any resources itself anymore, it just calls the module and passes in values.&lt;/p&gt;

&lt;p&gt;Here's the flow, end to end:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmn4q65pm2utgifc3dowb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmn4q65pm2utgifc3dowb.png" alt=" " width="578" height="624"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You run &lt;code&gt;terraform apply&lt;/code&gt; inside an environment folder. That folder's &lt;code&gt;providers.tf&lt;/code&gt; and &lt;code&gt;terraform.tfvars&lt;/code&gt; feed into a module call, which reaches into &lt;code&gt;modules/infrastructure&lt;/code&gt; where the resource group, virtual network, subnet, NSG, public IP, network interface, and VM are actually defined. Terraform hands the final plan to Azure ARM, and that's what creates the real infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  One backend, three isolated state files
&lt;/h2&gt;

&lt;p&gt;The backend gets created manually, same as before, since Terraform can't create the backend it depends on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az group create &lt;span class="nt"&gt;--name&lt;/span&gt; backend-rg &lt;span class="nt"&gt;--location&lt;/span&gt; eastus
az storage account create &lt;span class="nt"&gt;--name&lt;/span&gt; backendstorageaccs &lt;span class="nt"&gt;--resource-group&lt;/span&gt; backend-rg &lt;span class="nt"&gt;--location&lt;/span&gt; eastus &lt;span class="nt"&gt;--sku&lt;/span&gt; Standard_LRS &lt;span class="nt"&gt;--kind&lt;/span&gt; StorageV2
az storage container create &lt;span class="nt"&gt;--name&lt;/span&gt; backend-container &lt;span class="nt"&gt;--account-name&lt;/span&gt; backendstorageaccs &lt;span class="nt"&gt;--auth-mode&lt;/span&gt; login
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important decision here was not sharing one state file across all three environments. If payments, analytics, and platform all wrote to the same tfstate, a mistake in one could accidentally touch the other two. So each environment points at the same storage account and container, but a different key.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6kypal8h9yjwsvtpwogo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6kypal8h9yjwsvtpwogo.png" alt=" " width="589" height="185"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"azurerm"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"backend-rg"&lt;/span&gt;
  &lt;span class="nx"&gt;storage_account_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"backendstorageaccs"&lt;/span&gt;
  &lt;span class="nx"&gt;container_name&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"backend-container"&lt;/span&gt;
  &lt;span class="nx"&gt;key&lt;/span&gt;                    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"payments.tfstate"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzlo8ztbm6x5xo9w9a6ln.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzlo8ztbm6x5xo9w9a6ln.png" alt=" " width="800" height="477"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Small note on structure. I originally planned separate &lt;code&gt;backend.tf&lt;/code&gt; and &lt;code&gt;provider.tf&lt;/code&gt; files per environment, but ended up combining both into one &lt;code&gt;providers.tf&lt;/code&gt; instead. Functionally identical, Terraform doesn't care which file a block lives in, it reads every &lt;code&gt;.tf&lt;/code&gt; file in the folder as one combined config. Just a naming choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design question that actually mattered
&lt;/h2&gt;

&lt;p&gt;Before writing the module's variables, I got stuck on something that seemed small but wasn't. Some variables had default values, some didn't, and I couldn't articulate why.&lt;/p&gt;

&lt;p&gt;The answer turned out to be a real principle, not just a style preference. Any variable that's environment specific, security related, or identity related should never have a default. If &lt;code&gt;admin_username&lt;/code&gt; silently defaults to someone else's name because a new engineer forgot to set it, that new engineer can't log into their own server, and the person whose name it defaulted to now has access to a server they didn't know existed. If &lt;code&gt;project_name&lt;/code&gt; silently defaults, someone on the analytics team could end up deploying into the payments resource group. If &lt;code&gt;environment&lt;/code&gt; silently defaults, dev work could land in what's meant to be staging.&lt;/p&gt;

&lt;p&gt;Compare that to something like &lt;code&gt;location&lt;/code&gt; or &lt;code&gt;vm_size&lt;/code&gt;. If those default to the wrong value, you notice immediately, the resource shows up in the wrong region or the VM is a bit underpowered, and you just fix it. No security incident, no confusion about who owns what.&lt;/p&gt;

&lt;p&gt;So the rule I landed on: infrastructure should fail loudly with a clear error rather than succeed silently with the wrong value. Anything where a wrong guess could hurt someone gets no default, full stop.&lt;/p&gt;

&lt;h2&gt;
  
  
  A copy-paste bug, twice
&lt;/h2&gt;

&lt;p&gt;Copying the module call from one environment to the next introduced two separate bugs, both worth mentioning because they're the kind of thing that's easy to miss and easy to fix once you see them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcze9bbok1qin7xeatub6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcze9bbok1qin7xeatub6.png" alt=" " width="800" height="481"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;First, a line assigning the wrong variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;backend_storage_account_name&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;backend_container_name&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Right variable name on the left, wrong one on the right. Small thing to catch, easy to miss when everything else validates fine.&lt;/p&gt;

&lt;p&gt;Second, one environment's module call was literally named differently from the other two:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"enviroment"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;against&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"infrastructure"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;in the other two. Both work, Terraform lets you name a module call whatever you want locally, but having one out of three be inconsistent is exactly the kind of thing that makes a shared codebase harder to reason about later. Renamed it to match, reran &lt;code&gt;terraform init&lt;/code&gt;, and it fell in line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Applying all three
&lt;/h2&gt;

&lt;p&gt;Once the module and all three environments were consistent, applying each one followed the same pattern: &lt;code&gt;terraform validate&lt;/code&gt;, &lt;code&gt;terraform plan&lt;/code&gt;, &lt;code&gt;terraform apply&lt;/code&gt;. Ten resources each, matching what the earlier single-project version had, just parameterized this time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq3l0bq81cwbmmyhd4glc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq3l0bq81cwbmmyhd4glc.png" alt=" " width="800" height="419"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each environment ended up SSH accessible on its own public IP once applied.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft6sne0suq1jbl08h3xfv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft6sne0suq1jbl08h3xfv.png" alt=" " width="800" height="487"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And in the portal, all three environments plus the backend show up as separate resource groups, each with their own network interface, NSG, public IP, VM, disk, and virtual network.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7h4hlvdq0ywqk80zxgzw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7h4hlvdq0ywqk80zxgzw.png" alt=" " width="800" height="418"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When the network actually dropped
&lt;/h2&gt;

&lt;p&gt;This is the part worth reading carefully if you're doing anything with a remote backend, because it will happen to you eventually.&lt;/p&gt;

&lt;p&gt;Midway through applying the &lt;code&gt;analytics&lt;/code&gt; environment, my network dropped for a few minutes right as Terraform finished creating the VM and tried to write the final state back to the storage account. The apply itself succeeded, every resource was really created, but the state write failed with a DNS lookup error, since my machine briefly couldn't resolve the storage account's hostname.&lt;/p&gt;

&lt;p&gt;Because it couldn't reach the backend, Terraform did the safe thing. It saved the correct, complete state to a local file called &lt;code&gt;errored.tfstate&lt;/code&gt; and told me exactly what to do next. It also tried to release its lock on the remote state and failed at that too, since the same connection was down, leaving an orphaned lock behind.&lt;/p&gt;

&lt;p&gt;Every apply after that failed with "state blob is already locked," which looked alarming but wasn't actually a conflict with anyone else, just a lock nobody could release because the connection that was supposed to release it was gone. Recovery was three commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform force-unlock &amp;lt;lock-id&amp;gt;
terraform state push errored.tfstate
terraform plan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The force-unlock cleared the stuck lock. Pushing &lt;code&gt;errored.tfstate&lt;/code&gt; uploaded the correct, complete state that Terraform had already built locally. The follow-up plan came back with "No changes, your infrastructure matches the configuration," confirming everything reconciled correctly. Nothing was lost, nothing had to be rebuilt, and the VM that got created during the outage was sitting there working the whole time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp3qao94iha23h5xjbmr0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp3qao94iha23h5xjbmr0.png" alt=" " width="800" height="462"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tearing it all down
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;terraform destroy&lt;/code&gt; on each environment removed exactly what that environment's module call had created, in the correct dependency order. One of the three hit a second, smaller network hiccup, this time a connection reset while deleting the VM's managed disk mid-teardown. No stuck lock this time, just a straightforward second &lt;code&gt;terraform destroy&lt;/code&gt; picked up exactly where the first one left off and finished the job.&lt;/p&gt;

&lt;p&gt;One thing worth knowing: &lt;code&gt;terraform destroy&lt;/code&gt; only removes what Terraform manages. It doesn't touch the backend itself, since that was created manually and Terraform was never given authority over it. After destroying all three environments, the three state files were still sitting in the container, just now describing empty environments. Removing the backend for good meant one more manual step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az group delete &lt;span class="nt"&gt;--name&lt;/span&gt; backend-rg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That took the resource group, the storage account, the container, and every state file inside it down in one shot.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmm99erwjv8o1q3iehz51.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmm99erwjv8o1q3iehz51.png" alt=" " width="800" height="410"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What this actually bought me
&lt;/h2&gt;

&lt;p&gt;Going from one flat project to a module plus three environments wasn't just about avoiding copy and paste. It meant a single change to &lt;code&gt;modules/infrastructure/main.tf&lt;/code&gt; would ripple out to payments, analytics, and platform automatically, instead of needing to be applied three separate times by hand and risking one getting missed. It also meant each environment's blast radius was contained, its own resource group, its own state file, its own lock, so a mistake in one couldn't reach into another.&lt;/p&gt;

&lt;p&gt;If you want to see the actual code, including the module and all three environments:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/highpee1991/terraform-enterprise" rel="noopener noreferrer"&gt;https://github.com/highpee1991/terraform-enterprise&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you're building something similar and want to talk through the module structure or the state recovery, drop a comment.&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>azure</category>
      <category>devops</category>
      <category>cloud</category>
    </item>
    <item>
      <title>Building Enterprise Azure Infrastructure With Terraform and Remote State</title>
      <dc:creator>Ipadeola Taiwo</dc:creator>
      <pubDate>Sat, 01 Aug 2026 05:58:12 +0000</pubDate>
      <link>https://dev.to/highpee1991/building-enterprise-azure-infrastructure-with-terraform-and-remote-state-5bm2</link>
      <guid>https://dev.to/highpee1991/building-enterprise-azure-infrastructure-with-terraform-and-remote-state-5bm2</guid>
      <description>&lt;p&gt;A few weeks ago I set myself a scenario. Pretend I joined a company called Big 8 Integrated LLC as a Cloud Engineer. The company has decided that from now on, all infrastructure gets managed as code with Terraform. And because more than one engineer touches the infrastructure, the Terraform state can never sit on anyone's laptop. It has to live somewhere shared, somewhere that locks itself while someone is working, so two people can't step on each other's changes.&lt;/p&gt;

&lt;p&gt;That one requirement, state must never be local, ended up teaching me more than any tutorial I had read before it. This post is that journey, from an empty Azure subscription to a live VM serving a website, written the way it actually happened, mistakes included. If you're new to Terraform and Azure, you should be able to follow this from scratch and end up with the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plan
&lt;/h2&gt;

&lt;p&gt;Before touching any code, I sketched out what I was building.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9axgv5lxpys76n2wo1ii.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9axgv5lxpys76n2wo1ii.png" alt=" " width="258" height="505"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two resource groups. One holds the backend, just a storage account with a blob container where the Terraform state file lives. The other holds the actual workload, a virtual network, a subnet, a network security group, a public IP, and a Linux VM. The backend group gets created by hand. Everything in the second group gets created by Terraform.&lt;/p&gt;

&lt;p&gt;Why two groups, and why create the first one by hand? Because Terraform can't create the backend it depends on. It needs somewhere to store its state before it can even start managing anything, so that first piece has to exist before Terraform ever runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting up the backend manually
&lt;/h2&gt;

&lt;p&gt;I already had the Azure CLI installed and was logged in, which I confirmed with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az account show
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I created the resource group for the backend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az group create &lt;span class="nt"&gt;--name&lt;/span&gt; big8-rg &lt;span class="nt"&gt;--location&lt;/span&gt; eastus
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one worked first try. The storage account did not.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az storage account create &lt;span class="nt"&gt;--name&lt;/span&gt; big8-backend-store &lt;span class="nt"&gt;--resource-group&lt;/span&gt; big8-rg &lt;span class="nt"&gt;--location&lt;/span&gt; eastus &lt;span class="nt"&gt;--sku&lt;/span&gt; standard_LRS &lt;span class="nt"&gt;--kind&lt;/span&gt; storageV2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkc4z1y4ytkowitswbzhs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkc4z1y4ytkowitswbzhs.png" alt=" " width="797" height="119"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Azure rejected it. Storage account names can't have dashes, or any special characters at all. Lowercase letters and numbers only, between 3 and 24 characters. Small thing, but it's the kind of rule you only learn by hitting it. I dropped the dashes and tried again with &lt;code&gt;big8backendstore&lt;/code&gt;, and it went through.&lt;/p&gt;

&lt;p&gt;Last piece of the manual setup, the blob container that would actually hold the state file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az storage container create &lt;span class="nt"&gt;--name&lt;/span&gt; bigcontainer &lt;span class="nt"&gt;--account-name&lt;/span&gt; big8backendstore &lt;span class="nt"&gt;--auth-mode&lt;/span&gt; login
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn6c4qf4b11fxp6ab06ds.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn6c4qf4b11fxp6ab06ds.png" alt=" " width="800" height="393"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmq4qis62l46ce1i2zjkp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmq4qis62l46ce1i2zjkp.png" alt=" " width="799" height="363"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At this point I had a resource group, a storage account, and an empty container. No state file yet. That part comes automatically the first time Terraform runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing the Terraform config
&lt;/h2&gt;

&lt;p&gt;I set up a &lt;code&gt;providers.tf&lt;/code&gt; file with the provider block and the backend block pointing at what I'd just created by hand.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3u70q2n3bz1a3b6yf8v1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3u70q2n3bz1a3b6yf8v1.png" alt=" " width="800" height="282"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;required_providers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;azurerm&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"hashicorp/azurerm"&lt;/span&gt;
      &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"~&amp;gt; 4.0"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"azurerm"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;resource_group_name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"big8-rg"&lt;/span&gt;
    &lt;span class="nx"&gt;storage_account_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"big8backendstore"&lt;/span&gt;
    &lt;span class="nx"&gt;container_name&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"bigcontainer"&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;                    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"pro/big8store.tfstate"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"azurerm"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;features&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbjw9v25o746yz4528yqd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbjw9v25o746yz4528yqd.png" alt=" " width="696" height="614"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;key&lt;/code&gt; value is just the filename Terraform will use for the state file inside the container. It doesn't have to match anything specific, you choose it. For a bigger setup with multiple environments, you'd usually split this by environment or component, something like &lt;code&gt;dev.tfstate&lt;/code&gt; and &lt;code&gt;prod.tfstate&lt;/code&gt;, or &lt;code&gt;networking/vnet.tfstate&lt;/code&gt; and &lt;code&gt;compute/vm.tfstate&lt;/code&gt;, so nothing shares a state file and nothing risks overwriting something it shouldn't. For this project I kept it simple, one state file for the whole thing.&lt;/p&gt;

&lt;p&gt;Then I ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8n4pb2xecmju8mst2t3c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8n4pb2xecmju8mst2t3c.png" alt=" " width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the moment the backend actually gets used. Terraform connected to the storage account, configured itself to read and write state there, and installed the azurerm provider. And sure enough, checking the container in the Azure portal right after, there it was, a tiny 181 byte file called &lt;code&gt;big8store.tfstate&lt;/code&gt; sitting exactly where I told it to go.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fywrtico115ayhh3vvhsa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fywrtico115ayhh3vvhsa.png" alt=" " width="799" height="413"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that finally made variables click
&lt;/h2&gt;

&lt;p&gt;I want to be honest about this part because it tripped me up longer than I expected. I understood that &lt;code&gt;variables.tf&lt;/code&gt; declares a variable exists, and &lt;code&gt;terraform.tfvars&lt;/code&gt; gives it a value, but I couldn't picture how Terraform actually connects the two.&lt;/p&gt;

&lt;p&gt;What clicked for me eventually is that it's just name matching. If &lt;code&gt;variables.tf&lt;/code&gt; declares a variable called &lt;code&gt;location&lt;/code&gt;, and &lt;code&gt;terraform.tfvars&lt;/code&gt; has a line &lt;code&gt;location = "East US"&lt;/code&gt;, Terraform pairs them up because the names match exactly. Nothing more clever than that. Then anywhere in &lt;code&gt;main.tf&lt;/code&gt; you write &lt;code&gt;var.location&lt;/code&gt;, Terraform swaps in the value before it ever talks to Azure. Run &lt;code&gt;terraform plan&lt;/code&gt; and what you're looking at is that swap already done, the fully resolved version of your config.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8b6fzxofz8nwu01ry7mf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8b6fzxofz8nwu01ry7mf.png" alt=" " width="800" height="432"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyrj22umwbs0p1cio7jqu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyrj22umwbs0p1cio7jqu.png" alt=" " width="800" height="426"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once that landed, locals made sense as the natural next step. Variables are values you set from outside your config, tfvars, command line flags, environment variables. Locals are values you compute inside your config from other values, and you don't set them from outside at all. I used a locals block to build one tags object from three variables, so instead of retyping the same three tags on every single resource, I write it once and reference &lt;code&gt;local.tags&lt;/code&gt; everywhere.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzbvn5q7yjiaxr3anoqkw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzbvn5q7yjiaxr3anoqkw.png" alt=" " width="800" height="423"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;Project&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project_name&lt;/span&gt;
    &lt;span class="nx"&gt;Environment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;environment&lt;/span&gt;
    &lt;span class="nx"&gt;Owner&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;admin_username&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I actually mixed this up once, declared &lt;code&gt;tags&lt;/code&gt; as both a variable with no default value and a local, and Terraform got confused trying to figure out which one I meant, prompting me for a value on every plan. Deleting the leftover variable declaration and keeping only the local fixed it in one shot. Good practical example of the difference between the two.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing main.tf
&lt;/h2&gt;

&lt;p&gt;With variables and locals sorted, the actual resource definitions were fairly mechanical. Resource group, then a virtual network sitting inside it, a subnet inside the network, a network security group with rules allowing inbound traffic on port 22 for SSH and port 80 for HTTP, a public IP, a network interface tying the subnet and public IP together, and finally the VM itself.&lt;/p&gt;

&lt;p&gt;Two mistakes came up here worth mentioning. First, my NSG rules were written with &lt;code&gt;destination_address_prefixes&lt;/code&gt; (plural) set to a single string, &lt;code&gt;"*"&lt;/code&gt;. Terraform wants a list for the plural version. The fix was switching to &lt;code&gt;destination_address_prefix&lt;/code&gt; (singular), which takes a plain string. Second, my SSH key path was wrong, I had it pointing somewhere that didn't exist on my machine. Checking my actual &lt;code&gt;.ssh&lt;/code&gt; folder and correcting the path with &lt;code&gt;pathexpand("~/.ssh/id_ed25519.pub")&lt;/code&gt; sorted that out.&lt;/p&gt;

&lt;p&gt;After both fixes, &lt;code&gt;terraform validate&lt;/code&gt; finally came back clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plan, and a real deploy error
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;terraform plan&lt;/code&gt; showed exactly ten resources ready to create, matching the diagram I'd sketched at the start. Everything lined up, tags flowing automatically onto the resource group, the network, and the public IP from that one locals block.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;terraform apply&lt;/code&gt; got nine of those ten up without issue. The tenth, the VM itself, failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: creating Linux Virtual Machine: unexpected status 404 (404 Not Found)
PlatformImageNotFound: The platform image 'Canonical:0001-com-ubuntu-server-jammy:22_04_lts:latest' is not available.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I'd written the sku as &lt;code&gt;22_04_lts&lt;/code&gt;, with underscores. Running a quick image list against Azure showed the real current sku uses a hyphen instead, &lt;code&gt;22_04-lts&lt;/code&gt;. One character fix, and re-running apply picked up right where it left off, the nine resources already created stayed untouched, and only the VM got added this time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjtj5xyn0a6lx6zj9p71u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjtj5xyn0a6lx6zj9p71u.png" alt=" " width="800" height="469"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting onto the box and putting something real on it
&lt;/h2&gt;

&lt;p&gt;With a public IP in hand, I connected over SSH:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &amp;lt;admin_username&amp;gt;@&amp;lt;public-ip&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff575h47ojs5cqze1nrl9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff575h47ojs5cqze1nrl9.png" alt=" " width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Ran the usual updates, installed nginx, and visiting the IP in a browser showed the default nginx welcome page, proof the whole networking chain actually worked end to end.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5spstd4syo3yda3bxs97.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5spstd4syo3yda3bxs97.png" alt=" " width="800" height="463"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then I copied a small static site over with &lt;code&gt;scp&lt;/code&gt;, moved it into &lt;code&gt;/var/www/html&lt;/code&gt;, and reloaded the page.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh2su6sgy8fq1z1rez0ez.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh2su6sgy8fq1z1rez0ez.png" alt=" " width="799" height="459"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That was the moment this stopped feeling like an exercise. A real site, served from a VM I had provisioned entirely through code, sitting behind a network I had also defined entirely through code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving the state lock actually works
&lt;/h2&gt;

&lt;p&gt;The whole reason this project insists on remote state is to make collaboration safe. Two engineers, one shared state file, no local copies going stale. I wanted to actually see that protection in action instead of just taking it on faith, so I opened two terminals and simulated it.&lt;/p&gt;

&lt;p&gt;In one terminal, I kicked off &lt;code&gt;terraform apply&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb0zrj6fnoipec558q1mj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb0zrj6fnoipec558q1mj.png" alt=" " width="800" height="492"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;While that was still running, I switched to the second terminal and ran &lt;code&gt;terraform plan&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fumw2rvf9dp2aj012vy58.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fumw2rvf9dp2aj012vy58.png" alt=" " width="800" height="448"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Error acquiring the state lock
Error message: state blob is already locked
Lock Info:
  Operation: OperationTypeApply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the mechanism working exactly as intended. Azure Storage puts a lease on the state blob the moment an operation starts writing to it, and any other operation trying to touch that same file gets refused until the lease is released. Without this, two engineers running Terraform at the same time could easily corrupt the state or fight over the same resources. With it, one of them simply gets told to wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleaning up
&lt;/h2&gt;

&lt;p&gt;Once I was done, tearing it all down was just:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform destroy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten resources removed, in the correct order, associations and rules first, then the VM and its NIC, then the network, and the resource group last since everything else lived inside it.&lt;/p&gt;

&lt;p&gt;The backend pieces were created by hand, so they had to go the same way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az group delete &lt;span class="nt"&gt;--name&lt;/span&gt; big8-rg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A quick &lt;code&gt;az group list&lt;/code&gt; afterward confirmed only Azure's own auto-created &lt;code&gt;NetworkWatcherRG&lt;/code&gt; was left. Clean slate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually stuck with me
&lt;/h2&gt;

&lt;p&gt;Reading Terraform state as a genuine record of reality, not just a lock file, changed how I think about every command. &lt;code&gt;plan&lt;/code&gt; isn't guessing, it's comparing your code against a document that describes what's really out there. &lt;code&gt;apply&lt;/code&gt; doesn't blindly create things, it reconciles the two. And the locking isn't a formality, it's the one thing standing between a team and a corrupted state file.&lt;/p&gt;

&lt;p&gt;If you want to follow along with the actual code, it's all here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/highpee1991/terraform-IAC" rel="noopener noreferrer"&gt;https://github.com/highpee1991/terraform-IAC&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you're working through something similar and get stuck, drop a comment, happy to help however I can.&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>azure</category>
      <category>devops</category>
      <category>cloud</category>
    </item>
    <item>
      <title>Building And Securing An Azure Virtual Machine With Terraform, From Zero To A Working Website</title>
      <dc:creator>Ipadeola Taiwo</dc:creator>
      <pubDate>Sun, 12 Jul 2026 11:19:54 +0000</pubDate>
      <link>https://dev.to/highpee1991/building-and-securing-an-azure-virtual-machine-with-terraform-from-zero-to-a-working-website-4n17</link>
      <guid>https://dev.to/highpee1991/building-and-securing-an-azure-virtual-machine-with-terraform-from-zero-to-a-working-website-4n17</guid>
      <description>&lt;p&gt;This is a full walkthrough of a real Terraform project, provisioning a Linux virtual machine on Azure, securing it properly, and deploying a working web server on it. Every command in this post is one I actually ran, every error is one I actually hit, and every fix is exactly how I solved it. My goal here is not just to show you a finished project, my goal is to teach you how to build this yourself, line by line, understanding why each piece exists and what happens when something goes wrong.&lt;/p&gt;

&lt;p&gt;If you follow this post from top to bottom, you will end with your own Linux server running on Azure, provisioned entirely as code, reachable over SSH, and serving a real webpage to anyone who visits its public address.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Are Building
&lt;/h2&gt;

&lt;p&gt;A single Ubuntu virtual machine, sitting inside its own virtual network, protected by a firewall that only allows the two ports it actually needs, SSH for management and HTTP for web traffic, with a static public IP address so that address never changes once assigned.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;Terraform installed on your machine, version one point zero or higher. The Azure CLI installed and signed in with &lt;code&gt;az login&lt;/code&gt;. An active Azure subscription. And an SSH key pair already generated on your machine, most systems already have one sitting at &lt;code&gt;~/.ssh/id_rsa.pub&lt;/code&gt;, if not, generate one with &lt;code&gt;ssh-keygen&lt;/code&gt; before continuing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Project Structure
&lt;/h2&gt;

&lt;p&gt;Four files make up this entire project.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;providers.tf
variables.tf
main.tf
outputs.tf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Terraform does not care how you split your code across files. Every file in the same folder is automatically treated as one combined configuration. Splitting things apart like this is purely for readability, so let's go through each file and understand what it is doing.&lt;/p&gt;
&lt;h2&gt;
  
  
  providers.tf, Telling Terraform Which Cloud We Are Talking To
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="k"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;required_providers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;azurerm&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"hashicorp/azurerm"&lt;/span&gt;
      &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"4.80.0"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"azurerm"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;features&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This file has one job, telling Terraform that this project talks to Azure, using a specific, pinned version of the provider. Pinning the version matters more than it might look like at first, if you leave this open ended, your project could quietly behave differently every time you run it, depending on whatever the latest provider release happens to be that day. Pinning it to &lt;code&gt;4.80.0&lt;/code&gt; means this project behaves exactly the same way every single time, on any machine, forever.&lt;/p&gt;
&lt;h2&gt;
  
  
  variables.tf, The Values That Drive Everything Else
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="k"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"project"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"project name"&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform_vm"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"location"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"azure region"&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"East US"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"owner"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"owner tag"&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Hagital"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"vm-size"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vm size"&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Standard_B1ls"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Four variables, the project name, the Azure region, an owner tag for organizing resources, and the size of the virtual machine. Every resource in &lt;code&gt;main.tf&lt;/code&gt; pulls its values from here rather than having anything typed directly into it, so changing the region or the VM size later means editing one line here, not hunting through every resource individually.&lt;/p&gt;

&lt;p&gt;One thing worth explaining clearly, since it genuinely confused me the first time I wrote a Terraform project. If you come from a general programming background, you are used to variables needing to be exported from one file and imported into another. Terraform does not work that way at all. There is no export keyword, no import statement, nothing to wire together. Once a variable is declared anywhere in this folder, it is available anywhere else in this same folder, simply by writing &lt;code&gt;var.project&lt;/code&gt;, or &lt;code&gt;var.location&lt;/code&gt;, or whichever name you gave it. The folder itself is the boundary, not the individual file.&lt;/p&gt;
&lt;h2&gt;
  
  
  main.tf, The Actual Infrastructure
&lt;/h2&gt;

&lt;p&gt;This is the heart of the project. Nine resources, built up in the order they logically depend on each other. Let's go through each one.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Resource Group
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_resource_group"&lt;/span&gt; &lt;span class="s2"&gt;"hagital-rg"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_hagital_rg"&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;

  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;Environment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Hagital-vm"&lt;/span&gt;
    &lt;span class="nx"&gt;Owner&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;owner&lt;/span&gt;
    &lt;span class="nx"&gt;Project&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Every resource in Azure needs a container to live inside, called a resource group. This is the first thing created, and every other resource in this file references it, either directly or indirectly.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Virtual Network And Subnet
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_virtual_network"&lt;/span&gt; &lt;span class="s2"&gt;"hagital-vnet"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_hagital_vnet"&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hagital-rg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hagital-rg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;address_space&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"10.0.0.0/16"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;Environment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Hagital_vnet"&lt;/span&gt;
    &lt;span class="nx"&gt;Owner&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;owner&lt;/span&gt;
    &lt;span class="nx"&gt;Project&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_subnet"&lt;/span&gt; &lt;span class="s2"&gt;"hagital-subnet"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_hagital_subnet"&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hagital-rg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;virtual_network_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_virtual_network&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hagital-vnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;address_prefixes&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"10.0.1.0/24"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;A VM needs a network to sit inside before it can exist. The virtual network defines the overall address space, and the subnet carves out a smaller section of it for this specific machine to live in. Notice how &lt;code&gt;resource_group_name&lt;/code&gt; here is not typed as a plain string, it directly references &lt;code&gt;azurerm_resource_group.hagital-rg.name&lt;/code&gt;. This is how Terraform understands dependency order automatically, it sees that the subnet needs the resource group to exist first, and builds everything in the correct sequence without you having to say so explicitly.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Firewall, And Its Rules
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_network_security_group"&lt;/span&gt; &lt;span class="s2"&gt;"NSG"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_hagital_NSG"&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hagital-rg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hagital-rg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_network_security_rule"&lt;/span&gt; &lt;span class="s2"&gt;"NSG_rule_port_80"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;nsg_port_80"&lt;/span&gt;
  &lt;span class="nx"&gt;priority&lt;/span&gt;                    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
  &lt;span class="nx"&gt;direction&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Inbound"&lt;/span&gt;
  &lt;span class="nx"&gt;access&lt;/span&gt;                      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
  &lt;span class="nx"&gt;protocol&lt;/span&gt;                    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Tcp"&lt;/span&gt;
  &lt;span class="nx"&gt;source_port_range&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
  &lt;span class="nx"&gt;destination_port_range&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"80"&lt;/span&gt;
  &lt;span class="nx"&gt;source_address_prefix&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
  &lt;span class="nx"&gt;destination_address_prefix&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hagital-rg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;network_security_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_network_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NSG&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_network_security_rule"&lt;/span&gt; &lt;span class="s2"&gt;"NSG_rule_port_22"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;nsg_port-22"&lt;/span&gt;
  &lt;span class="nx"&gt;priority&lt;/span&gt;                    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;101&lt;/span&gt;
  &lt;span class="nx"&gt;direction&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Inbound"&lt;/span&gt;
  &lt;span class="nx"&gt;access&lt;/span&gt;                      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
  &lt;span class="nx"&gt;protocol&lt;/span&gt;                    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Tcp"&lt;/span&gt;
  &lt;span class="nx"&gt;source_port_range&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
  &lt;span class="nx"&gt;destination_port_range&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"22"&lt;/span&gt;
  &lt;span class="nx"&gt;source_address_prefix&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
  &lt;span class="nx"&gt;destination_address_prefix&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hagital-rg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;network_security_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_network_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NSG&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;A network security group, NSG for short, is Azure's firewall. On its own it does nothing, it is just a container for rules. Each rule below it defines exactly one allowed pattern of traffic.&lt;/p&gt;

&lt;p&gt;Both rules here are inbound, meaning traffic coming into the VM from outside, not traffic leaving it. Port eighty allows web visitors to reach the site once it is running. Port twenty two allows SSH connections in, so you can actually manage the server. Notice each rule has its own priority number, one hundred and one hundred one. Two inbound rules in Azure can never share the same priority, each one needs a unique number, and spacing them out, rather than using consecutive numbers, leaves room to insert new rules later without renumbering everything.&lt;/p&gt;

&lt;p&gt;Notice too, &lt;code&gt;source_port_range&lt;/code&gt; is set to a wildcard on both rules, while &lt;code&gt;destination_port_range&lt;/code&gt; is the actual port being protected. This trips a lot of people up at first. The source port is whatever random, temporary port your browser or SSH client happens to open the connection from, you cannot predict or control that, so it is always a wildcard. The destination port is the one you actually care about, the port on the server itself.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Public IP
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_public_ip"&lt;/span&gt; &lt;span class="s2"&gt;"public-ip"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_public_ip"&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hagital-rg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hagital-rg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;
  &lt;span class="nx"&gt;allocation_method&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Static"&lt;/span&gt;

  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;Environment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"public_ip"&lt;/span&gt;
    &lt;span class="nx"&gt;Owner&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;owner&lt;/span&gt;
    &lt;span class="nx"&gt;Projects&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Without this, the VM would only be reachable on a private, internal address, nothing you could actually connect to from your own laptop. &lt;code&gt;allocation_method&lt;/code&gt; is set to &lt;code&gt;Static&lt;/code&gt; rather than &lt;code&gt;Dynamic&lt;/code&gt;, deliberately. A static IP stays the same even if the VM is stopped and restarted, it only changes if the public IP resource itself is deleted. A dynamic IP, by contrast, can change every time the VM restarts, which becomes a real annoyance once you are actively developing against a known address.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Network Interface
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_network_interface"&lt;/span&gt; &lt;span class="s2"&gt;"NIC"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_NIC"&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hagital-rg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hagital-rg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;

  &lt;span class="nx"&gt;ip_configuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;                          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"internal"&lt;/span&gt;
    &lt;span class="nx"&gt;subnet_id&lt;/span&gt;                     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hagital-subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
    &lt;span class="nx"&gt;private_ip_address_allocation&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Dynamic"&lt;/span&gt;
    &lt;span class="nx"&gt;public_ip_address_id&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_public_ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;public-ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This is the piece that physically connects everything together. The network interface card, NIC for short, is what actually plugs the VM into the subnet, and it is also where the public IP gets attached. A VM cannot connect directly to a subnet or a public IP on its own, it always goes through a network interface in between.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Virtual Machine Itself
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_linux_virtual_machine"&lt;/span&gt; &lt;span class="s2"&gt;"linux-vm"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_linux_vm"&lt;/span&gt;
  &lt;span class="nx"&gt;computer_name&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"hagital-vm"&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hagital-rg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hagital-rg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;
  &lt;span class="nx"&gt;size&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vm-size&lt;/span&gt;
  &lt;span class="nx"&gt;admin_username&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;owner&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;user"&lt;/span&gt;
  &lt;span class="nx"&gt;network_interface_ids&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nx"&gt;azurerm_network_interface&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NIC&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="nx"&gt;admin_ssh_key&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;username&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;owner&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;user"&lt;/span&gt;
    &lt;span class="nx"&gt;public_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"~/.ssh/id_rsa.pub"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;os_disk&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;caching&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ReadWrite"&lt;/span&gt;
    &lt;span class="nx"&gt;storage_account_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Standard_LRS"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;source_image_reference&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;publisher&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Canonical"&lt;/span&gt;
    &lt;span class="nx"&gt;offer&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"0001-com-ubuntu-server-jammy"&lt;/span&gt;
    &lt;span class="nx"&gt;sku&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"22_04-lts"&lt;/span&gt;
    &lt;span class="nx"&gt;version&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"latest"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This resource references everything built above it, the network interface for connectivity, an Ubuntu twenty two image from Canonical for the operating system, and an SSH key read directly from your local machine using the &lt;code&gt;file()&lt;/code&gt; function, rather than pasting the key as text. One detail worth calling out, &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;computer_name&lt;/code&gt; are two different things here, &lt;code&gt;name&lt;/code&gt; is the label Azure uses for the resource itself, while &lt;code&gt;computer_name&lt;/code&gt; becomes the actual hostname inside the running Linux machine, and Linux hostnames follow much stricter naming rules than Azure resource names do, more on that shortly when we get to the errors.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Missing Piece Most Tutorials Skip
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_network_interface_security_group_association"&lt;/span&gt; &lt;span class="s2"&gt;"nsg_association"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;network_interface_id&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_network_interface&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NIC&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;network_security_group_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_network_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NSG&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This resource genuinely tripped me up, and I want to explain it clearly because I suspect it will trip other people up too. Creating a network security group and writing rules for it does not automatically apply those rules to anything. The NSG just sits there, unused, until it is explicitly attached to something, in this case, the network interface. Without this association resource, your firewall rules exist in Azure but affect nothing at all, and Azure falls back to blocking inbound internet traffic by default. This one resource is what actually switches your firewall rules on.&lt;/p&gt;
&lt;h2&gt;
  
  
  outputs.tf, Getting Useful Information Back
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="k"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"resource_group_created"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"resource group name"&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hagital-rg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"Vnet"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vnet name"&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_virtual_network&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hagital-vnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"subnet"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"subnet name"&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hagital-subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"NSG"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"NSG name"&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_network_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NSG&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"port_80_rule"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Inbound port 80 created"&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_network_security_rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NSG_rule_port_80&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;destination_port_range&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"port_22_rule"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Inbound port 22 created"&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_network_security_rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NSG_rule_port_22&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;destination_port_range&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"public_ip"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"public ip"&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_public_ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;public-ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"vm_name"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"azure vm name"&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_linux_virtual_machine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;linux-vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"vm_public_ip"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vm attached public ip"&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_linux_virtual_machine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;linux-vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;public_ip_address&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Outputs print useful information to the terminal automatically after every apply, so you are not stuck manually looking things up in the Azure Portal afterward. The most useful one here by far is &lt;code&gt;vm_public_ip&lt;/code&gt;, since that is the exact address needed to actually SSH into the machine once it exists.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Errors I Ran Into, And How They Were Fixed
&lt;/h2&gt;
&lt;h1&gt;
  
  
  A quick note before this section, the code shown above already reflects every correction. You do not need to change anything, this section exists purely to walk through what went wrong the first time and how each issue was diagnosed.
&lt;/h1&gt;

&lt;p&gt;This is the part most tutorials skip entirely, and it is the part I think actually matters most. Nothing here worked perfectly on the first attempt. Every error below is real, and understanding each one teaches you something a clean, error free run never would.&lt;/p&gt;
&lt;h3&gt;
  
  
  Running terraform init
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This command downloads the azurerm provider and prepares the working directory. The very first time I ran this, it failed.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Failed to query available provider packages
Could not retrieve the list of available versions for provider hashcorp/azurerm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Read that carefully. It says &lt;code&gt;hashcorp&lt;/code&gt;, missing the letter i. The real provider is published as &lt;code&gt;hashicorp&lt;/code&gt;, with the i. Somewhere in my configuration, that name had been typed incorrectly, and Terraform was searching for a provider that simply does not exist. Correcting the spelling and running &lt;code&gt;terraform init&lt;/code&gt; again resolved it immediately.&lt;/p&gt;

&lt;p&gt;A second, completely different failure showed up on another attempt.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Failed to install provider
Error while installing hashicorp/azurerm v4.80.0: releases.hashicorp.com: local error: tls: bad record MAC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This one had nothing to do with my code at all, it was a network connection problem, an interrupted or unstable connection while downloading the provider. The fix here was simply waiting a moment and retrying the same command again.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Once the connection stabilized, it installed cleanly.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Subscription ID Requirement
&lt;/h3&gt;

&lt;p&gt;Running &lt;code&gt;terraform plan&lt;/code&gt; for the first time in this particular project produced a new error I had not seen in earlier Terraform projects.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: `subscription_id` is a required provider property when performing a plan/apply operation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This turned out to be a documented change, starting with version four point zero of the azurerm provider, explicitly setting a subscription ID in the provider block became mandatory. In an earlier Terraform project of mine, this had not come up, likely due to how that specific environment resolved it implicitly. The fix here was reverting cleanly to provider version &lt;code&gt;4.80.0&lt;/code&gt; after clearing out the previously cached &lt;code&gt;.terraform&lt;/code&gt; folder and lock file, forcing Terraform to properly resolve the version fresh, which sidestepped the issue entirely for this setup.&lt;/p&gt;
&lt;h3&gt;
  
  
  Reviewing The Plan
&lt;/h3&gt;

&lt;p&gt;Once initialization succeeded, &lt;code&gt;terraform plan&lt;/code&gt; produced a full, detailed preview of every resource about to be created, nine in total, resource group, virtual network, subnet, NSG, two security rules, public IP, network interface, and the VM itself.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Plan: 9 to add, 0 to change, 0 to destroy.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This is the single most important habit in Terraform, always read the plan before applying it. It tells you exactly what is about to happen before it actually happens, and it is your last chance to catch a mistake before real infrastructure gets created and starts costing money.&lt;/p&gt;
&lt;h3&gt;
  
  
  Running terraform apply, And The First Real Failure
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform apply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Typing &lt;code&gt;yes&lt;/code&gt; at the confirmation prompt kicked off creation. Eight resources succeeded cleanly, resource group, network, subnet, NSG, both rules, public IP, and network interface, each completing within seconds. Then the VM itself failed.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: unable to assume default computer name "computer_name" cannot contain the special characters:
`\/"[]:|&amp;lt;&amp;gt;+=;,?*@&amp;amp;~!#$%^()_{}'`. Please adjust the `name`, or specify an explicit `computer_name`
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This is a genuinely useful error to understand. Azure resource names and Linux hostnames follow completely different rule sets. My VM's &lt;code&gt;name&lt;/code&gt; value contained underscores, which Azure resource names allow without issue, that is exactly why the resource group, VNet, and NSG all created successfully with underscores in their own names. But without an explicit &lt;code&gt;computer_name&lt;/code&gt; set, Terraform tries to generate one automatically from the resource name, and a Linux hostname cannot contain underscores at all. The fix was adding an explicit, separate &lt;code&gt;computer_name&lt;/code&gt; value using only letters, numbers, and hyphens, which is exactly what you see in the finished &lt;code&gt;main.tf&lt;/code&gt; above, &lt;code&gt;computer_name = "hagital-vm"&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Second Failure, An Invalid VM Size
&lt;/h3&gt;

&lt;p&gt;After fixing the computer name and running &lt;code&gt;terraform plan&lt;/code&gt; again, a different error appeared, this time about the VM size itself.&lt;/p&gt;

&lt;p&gt;The originally typed size, &lt;code&gt;Standard_B1_ls&lt;/code&gt;, is not a valid Azure size string. Azure VM sizes follow a fixed format defined by Azure itself, and this one had an extra underscore that did not belong. The correct value is &lt;code&gt;Standard_B1ls&lt;/code&gt;, no separator between B one and ls, one continuous string. You can always confirm exactly which sizes are valid and available in your specific region directly from the CLI, rather than typing one from memory:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az vm list-sizes &lt;span class="nt"&gt;--location&lt;/span&gt; eastus &lt;span class="nt"&gt;--output&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Correcting &lt;code&gt;variables.tf&lt;/code&gt; to &lt;code&gt;Standard_B1ls&lt;/code&gt; and running &lt;code&gt;terraform plan&lt;/code&gt; again confirmed it was accepted.&lt;/p&gt;
&lt;h3&gt;
  
  
  Applying Again, Successfully This Time
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform apply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This time, all nine resources built successfully.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Apply complete! Resources: 9 added, 0 changed, 0 destroyed.

Outputs:

NSG = "terraform_vm_hagital_NSG"
Vnet = "terraform_vm_hagital_vnet"
port_22_rule = "22"
port_80_rule = "80"
public_ip = "/subscriptions/.../publicIPAddresses/terraform_vm_public_ip"
resource_group_created = "terraform_vm_hagital_rg"
subnet = "terraform_vm_hagital_subnet"
vm_name = "terraform_vm_linux_vm"
vm_public_ip = "20.169.155.100"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Every output printed automatically, exactly as designed, including the actual public IP address, ready to copy straight into an SSH command.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Connection That Timed Out
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh Hagitaluser@20.169.155.100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh: connect to host 20.169.155.100 port 22: Connection timed out
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This is the error that taught me the most out of this entire project. Everything in the plan had applied successfully, no errors, nine resources created, yet SSH could not reach the machine at all. A connection timeout like this, rather than an outright refusal, is the signature of a firewall silently dropping the request rather than actively rejecting it.&lt;/p&gt;

&lt;p&gt;The cause traced back to something I had genuinely overlooked. Creating a network security group and writing rules for it does not automatically apply those rules to anything. I had built the NSG, and I had written both inbound rules, port twenty two and port eighty, but I had never actually attached that NSG to the network interface. The rules existed, but they were not switched on.&lt;/p&gt;

&lt;p&gt;The fix was adding one more resource, exactly the association block shown earlier in &lt;code&gt;main.tf&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_network_interface_security_group_association"&lt;/span&gt; &lt;span class="s2"&gt;"nsg_association"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;network_interface_id&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_network_interface&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NIC&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;network_security_group_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_network_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NSG&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform plan
terraform apply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;One resource added. Trying SSH again immediately after:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh Hagitaluser@20.169.155.100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;The authenticity of host '20.169.155.100 (20.169.155.100)' can't be established.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '20.169.155.100' (ED25519) to the list of known hosts.
Welcome to Ubuntu 22.04.5 LTS (GNU/Linux 6.8.0-1059-azure x86_64)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Connected. That fingerprint warning the first time is completely normal, it is simply your machine confirming, for the first time, that it trusts this brand new server's identity, the same thing happens connecting to any new server for the first time, not just this one.&lt;/p&gt;
&lt;h2&gt;
  
  
  Configuring The Server
&lt;/h2&gt;

&lt;p&gt;With SSH access working, I updated the system and installed Nginx, the actual web server this whole project was built to run.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;nginx &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Once installed, I confirmed the service was genuinely running, not just installed.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl status nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight systemd"&gt;&lt;code&gt;&lt;span class="err"&gt;●&lt;/span&gt; &lt;span class="err"&gt;nginx.service&lt;/span&gt; &lt;span class="err"&gt;-&lt;/span&gt; &lt;span class="err"&gt;A&lt;/span&gt; &lt;span class="err"&gt;high&lt;/span&gt; &lt;span class="err"&gt;performance&lt;/span&gt; &lt;span class="err"&gt;web&lt;/span&gt; &lt;span class="err"&gt;server&lt;/span&gt; &lt;span class="err"&gt;and&lt;/span&gt; &lt;span class="err"&gt;a&lt;/span&gt; &lt;span class="err"&gt;reverse&lt;/span&gt; &lt;span class="err"&gt;proxy&lt;/span&gt; &lt;span class="err"&gt;server&lt;/span&gt;
     &lt;span class="err"&gt;Active:&lt;/span&gt; &lt;span class="err"&gt;active&lt;/span&gt; &lt;span class="err"&gt;(running)&lt;/span&gt; &lt;span class="err"&gt;since&lt;/span&gt; &lt;span class="err"&gt;Sun&lt;/span&gt; &lt;span class="err"&gt;2026-07-12&lt;/span&gt; &lt;span class="err"&gt;10:30:47&lt;/span&gt; &lt;span class="err"&gt;UTC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Then verified it was actually serving content, first from directly inside the VM itself.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl 20.169.155.100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;!DOCTYPE html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;head&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;Welcome to nginx!&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Verifying From The Outside World
&lt;/h2&gt;

&lt;p&gt;The final and most satisfying check, opening the VM's public IP address directly in a browser from my own laptop, completely outside of Azure and outside of the VM itself.&lt;/p&gt;

&lt;p&gt;The page loaded correctly, the default Nginx welcome page, confirmed reachable from the open internet. That "not secure" warning next to the address bar is expected and not a problem, it simply means the connection is running over plain HTTP rather than HTTPS, adding a TLS certificate would be the natural next step to remove that warning, a good project on its own.&lt;/p&gt;
&lt;h2&gt;
  
  
  What This Project Actually Proves
&lt;/h2&gt;

&lt;p&gt;Every piece of this was provisioned as code, version controlled, and repeatable, not clicked together by hand. Five distinct, real errors were hit along the way, a provider name typo, a network connectivity drop, a Linux hostname naming rule, an invalid Azure VM size string, and a missing NSG association that silently left the firewall rules inactive despite a fully successful apply. Every one of them was diagnosed by actually reading the error message closely and understanding what it was describing, not by guessing or copying a fix blindly.&lt;/p&gt;

&lt;p&gt;The result is a real, working, secured Linux server, reachable over SSH, serving a live webpage to anyone on the internet, built entirely from four Terraform files and a lot of careful debugging.&lt;/p&gt;

&lt;p&gt;If you followed along and built this yourself, or if you hit a different error than the ones documented here, I would genuinely like to hear about it in the comments. Debugging infrastructure is a skill built entirely through hitting exactly this kind of wall and working through it, and comparing notes with someone else doing the same thing is one of the fastest ways to get better at it.&lt;/p&gt;

&lt;p&gt;Github Link:&lt;br&gt;
&lt;/p&gt;
&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/highpee1991" rel="noopener noreferrer"&gt;
        highpee1991
      &lt;/a&gt; / &lt;a href="https://github.com/highpee1991/Terraform-Virtual-Machine-Deployment" rel="noopener noreferrer"&gt;
        Terraform-Virtual-Machine-Deployment
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
&lt;/div&gt;



</description>
    </item>
    <item>
      <title>My First Real Terraform Project, Two Environments, One Storage Account, And A Few Lessons I Did Not See Coming</title>
      <dc:creator>Ipadeola Taiwo</dc:creator>
      <pubDate>Sat, 11 Jul 2026 15:52:14 +0000</pubDate>
      <link>https://dev.to/highpee1991/my-first-real-terraform-project-two-environments-one-storage-account-and-a-few-lessons-i-did-not-392k</link>
      <guid>https://dev.to/highpee1991/my-first-real-terraform-project-two-environments-one-storage-account-and-a-few-lessons-i-did-not-392k</guid>
      <description>&lt;p&gt;A few days ago I said I was starting Infrastructure as Code. Today I actually sat down and wrote my first proper Terraform project, and I want to walk you through exactly what I built, what confused me at first, and what broke along the way, because the broken parts taught me more than the parts that just worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Set Out To Build
&lt;/h2&gt;

&lt;p&gt;The idea was simple on paper. A small payments project needs two separate environments, one for development and one for staging, each represented by its own resource group in Azure. On top of that, the development environment needed a storage account with a blob container inside it, the kind of setup you would actually see on a real team, where dev and staging are kept apart on purpose.&lt;/p&gt;

&lt;p&gt;Nothing here was complicated in terms of what Azure resources were involved. What made it worth doing was doing it the Terraform way, writing it once as code, and letting Terraform figure out how to create everything in the right order.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Part That Actually Confused Me At First, Variables
&lt;/h2&gt;

&lt;p&gt;Before I even got to writing resources, I had to deal with variables, and this is the part I really want to talk about, because it caught me off guard in a way I was not expecting.&lt;/p&gt;

&lt;p&gt;If you come from a general programming background, even a light one, you are used to variables needing to be exported and imported somehow. You define something in one file, and if you want to use it somewhere else, you usually have to explicitly export it, then import it wherever you need it. That is just how most languages work.&lt;/p&gt;

&lt;p&gt;So naturally, when I created a separate file just for my variables, my first thought was, how do I export this so my other files can actually see it. I genuinely sat there for a moment trying to figure out the Terraform equivalent of an export statement.&lt;/p&gt;

&lt;p&gt;Turns out, there isn't one, and there does not need to be.&lt;/p&gt;

&lt;p&gt;In Terraform, every file inside the same working directory is automatically part of the same configuration. It does not matter if your variables live in &lt;code&gt;variables.tf&lt;/code&gt;, your resources live in &lt;code&gt;main.tf&lt;/code&gt;, and your outputs live in &lt;code&gt;outputs.tf&lt;/code&gt;. Terraform reads every &lt;code&gt;.tf&lt;/code&gt; file in that folder as one combined configuration. Once a variable is declared, anywhere in that folder, you can reference it anywhere else in that same folder, just by calling &lt;code&gt;var.whatever_you_named_it&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;No import statement. No export keyword. No file path linking one file to another. The folder itself is the boundary, not the individual file.&lt;/p&gt;

&lt;p&gt;Once that clicked, it actually made the whole language feel simpler than I expected. Splitting files apart in Terraform is purely for organization and readability, not because the tool needs you to wire files together the way you would in something like JavaScript or Python.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting The Foundation, Provider And Variables
&lt;/h2&gt;

&lt;p&gt;Every Terraform project needs to know which provider it is talking to, in this case Azure, and which version of that provider to use. I pinned mine to a specific version rather than leaving it open ended, so the project behaves the same way every time it runs, regardless of what the latest provider release happens to be on a given day.&lt;/p&gt;

&lt;p&gt;Then came the three variables that would drive the whole project, the project name, the Azure region, and an owner tag, each with a sensible default so I would not have to retype the same values across every resource.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building The Actual Infrastructure
&lt;/h2&gt;

&lt;p&gt;With the foundation in place, I defined four resources. Two resource groups, one for dev and one for staging, a storage account sitting inside the dev resource group, and a storage container sitting inside that storage account.&lt;/p&gt;

&lt;p&gt;What I liked here is how naturally Terraform expresses the relationship between resources. I did not have to manually tell the storage account which resource group it belonged to using some ID I copied from somewhere. I just referenced the resource group resource directly, and Terraform understood the dependency and the order to create things in, entirely on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where It Actually Broke, And What Each Break Taught Me
&lt;/h2&gt;

&lt;p&gt;I want to be upfront about this part, because I think it is the most useful part of this whole post. Nothing here worked perfectly on the first attempt, and I think that is normal, and worth documenting honestly rather than pretending it all went smoothly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first error showed up right at &lt;code&gt;terraform init&lt;/code&gt;.&lt;/strong&gt; Terraform told me it could not find a provider called &lt;code&gt;hashcorp/azurerm&lt;/code&gt;. I stared at that for longer than I want to admit before I noticed the actual issue, the error was missing a letter. The real provider is &lt;code&gt;hashicorp&lt;/code&gt;, not &lt;code&gt;hashcorp&lt;/code&gt;. One missing letter somewhere in my configuration was enough to send Terraform looking for a provider that does not exist. Fixing the spelling and running init again solved it immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The second error happened during &lt;code&gt;terraform plan&lt;/code&gt;.&lt;/strong&gt; This one looked scarier than it actually was. Terraform said it ran into a problem while trying to automatically register an Azure resource provider called Microsoft.Kusto, something I was not even using directly, and the process got interrupted. I simply ran the plan again, and it went through cleanly the second time. Turns out Terraform tries to register a whole set of Azure resource providers behind the scenes the first time you use certain resource types, and on a lighter subscription that step can be slow enough to get cut off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The third error was the most straightforward, but the easiest to overlook.&lt;/strong&gt; Azure rejected my storage account name because it contained a hyphen. Storage account names in Azure have to be lowercase letters and numbers only, no hyphens allowed, which is different from resource group names, where hyphens are perfectly fine. I had used the same naming pattern across both without realizing storage accounts play by different rules. Removing the hyphen fixed it right away.&lt;/p&gt;

&lt;p&gt;None of these were difficult once I understood what was actually happening, but each one forced me to slow down and actually read the error message properly instead of assuming I already knew what went wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirming It Actually Worked
&lt;/h2&gt;

&lt;p&gt;Once everything applied successfully, I did not just trust the terminal output. I went into the Azure Portal directly and checked every piece, both resource groups showing up correctly under the right region, the storage account sitting inside the correct resource group, and the container present inside that storage account with the access level I had configured. Seeing it match exactly what Terraform said it created was a genuinely satisfying moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tearing It Down
&lt;/h2&gt;

&lt;p&gt;Since this was a lab and not something meant to run permanently, I finished by running a full destroy, and watched Terraform take everything down in the correct reverse order, the container first, then the storage account, then both resource groups, all cleanly removed with a single command and a typed confirmation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Project Actually Taught Me
&lt;/h2&gt;

&lt;p&gt;Beyond the individual errors, the biggest shift for me was realizing how much Terraform handles on its own once you describe what you want clearly. I did not have to think about ordering. I did not have to manually track dependencies between resources. I described the end state, and Terraform figured out how to get there, and how to safely undo it later.&lt;/p&gt;

&lt;p&gt;I also learned something small but important while wrapping up, my working folder had files sitting there that should never end up in version control, the local state files and the provider cache directory in particular, since state files can end up holding sensitive information about the resources they track. That is going straight into a &lt;code&gt;.gitignore&lt;/code&gt; before this project touches GitHub properly.&lt;/p&gt;

&lt;p&gt;This is only the first project in what I expect will be a much longer run with Terraform. If you have worked with it longer than I have, and there is something you wish someone had told you this early on, I would genuinely like to hear it in the comments.&lt;/p&gt;

&lt;p&gt;Below is link to my github repo&lt;br&gt;
&lt;/p&gt;
&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/highpee1991" rel="noopener noreferrer"&gt;
        highpee1991
      &lt;/a&gt; / &lt;a href="https://github.com/highpee1991/azure-terraform-lab" rel="noopener noreferrer"&gt;
        azure-terraform-lab
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      terraform
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Understand the 4 Core Files&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Every Terraform project has this structure:&lt;/h1&gt;

&lt;/div&gt;
&lt;p&gt;azure-terraform-lab/
├── main.tf           # Your resources live here
├── variables.tf      # Input variables (reusable values)
├── outputs.tf        # What Terraform prints after running
└── providers.tf      # Which cloud you are connecting to&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Terraform command to use daily&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;1. Download the Azure provider plugin&lt;/h1&gt;

&lt;/div&gt;
&lt;p&gt;terraform init&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;2. Preview what Terraform WILL do — never skip this&lt;/h1&gt;

&lt;/div&gt;
&lt;p&gt;terraform plan&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;3. Actually build the infrastructure&lt;/h1&gt;

&lt;/div&gt;
&lt;p&gt;terraform apply&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;4. Destroy everything safely when done&lt;/h1&gt;

&lt;/div&gt;
&lt;p&gt;terraform destroy&lt;/p&gt;
&lt;/div&gt;



&lt;/div&gt;
&lt;br&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/highpee1991/azure-terraform-lab" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;br&gt;
&lt;/div&gt;
&lt;br&gt;


</description>
    </item>
    <item>
      <title>I Deployed a Live Website on Azure Using Only the CLI, No Portal, No Clicking</title>
      <dc:creator>Ipadeola Taiwo</dc:creator>
      <pubDate>Mon, 06 Jul 2026 19:46:15 +0000</pubDate>
      <link>https://dev.to/highpee1991/i-deployed-a-live-website-on-azure-using-only-the-cli-no-portal-no-clicking-4men</link>
      <guid>https://dev.to/highpee1991/i-deployed-a-live-website-on-azure-using-only-the-cli-no-portal-no-clicking-4men</guid>
      <description>&lt;p&gt;There is a certain kind of confidence you only get when you type a command into a terminal and watch an actual server come to life in another part of the world. No dashboard, no wizard, no clicking through screens. Just you, a terminal, and a set of decisions you have to make yourself.&lt;/p&gt;

&lt;p&gt;That is exactly what I set out to do. The brief was simple to read but not simple to execute: deploy a Linux server on Azure, connect to it, configure it, secure it properly, and document the whole process the way a real DevOps engineer would, as a runbook someone else could follow and trust.&lt;/p&gt;

&lt;p&gt;I want to walk you through how I did it, the mistakes I made along the way, and how you can follow the same path yourself if you are trying to build this skill.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I Did This the Hard Way
&lt;/h2&gt;

&lt;p&gt;It is tempting to open the Azure portal, click a few buttons, and have a server running in minutes. That works, but it does not teach you anything that scales. The moment you need to provision ten servers, or repeat the same setup for a client, clicking through a UI becomes a liability rather than a convenience.&lt;/p&gt;

&lt;p&gt;So I chose the terminal instead. Every resource group, every network, every rule was created with a command I typed myself, so that I would actually understand what was happening underneath.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step One, Giving the Project a Home
&lt;/h2&gt;

&lt;p&gt;Every resource in Azure needs a container to live in, called a resource group. Think of it as the folder that keeps everything related to one project together, so you can manage it, monitor it, or delete it as a single unit later.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az group create &lt;span class="nt"&gt;--name&lt;/span&gt; hagital-rg &lt;span class="nt"&gt;--location&lt;/span&gt; eastus
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That one line created a logical home for everything that followed.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step Two, Building the Network Before the Server
&lt;/h2&gt;

&lt;p&gt;Before you can have a server, you need a network for it to live inside. This is one of those things that feels backwards if you are used to clicking through a portal, where the network gets created for you automatically. On the CLI, you build it yourself, which forces you to actually understand how the pieces fit together.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az network vnet create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; hagital-rg &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; provion-server-vnet &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--address-prefix&lt;/span&gt; 10.0.0.0/16 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--subnet-name&lt;/span&gt; provison-server-subnet &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--subnet-prefix&lt;/span&gt; 10.0.0.0/24
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;I then double checked everything before moving forward, because catching a mistake here is far easier than catching it after a server is already attached to the wrong network.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az network vnet show &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; hagital-rg &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; provion-server-vnet &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"{VNet:addressSpace.addressPrefixes, Subnets:subnets[].addressPrefix}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Step Three, Bringing the Server to Life
&lt;/h2&gt;

&lt;p&gt;With the network ready, I created the virtual machine itself, an Ubuntu server, placed directly inside the network I had just built.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az vm create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; hagital-rg &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; hagital-server &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt; Ubuntu2204 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--size&lt;/span&gt; Standard_B1ls &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--vnet-name&lt;/span&gt; provion-server-vnet &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--subnet&lt;/span&gt; provison-server-subnet &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--admin-username&lt;/span&gt; azureuser &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--generate-ssh-keys&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;One detail worth mentioning here, something that genuinely surprised me. When you create a server through the Azure portal, you are usually handed a private key file to download and guard carefully. On the CLI, the &lt;code&gt;generate-ssh-keys&lt;/code&gt; flag handles that for you. Azure generates the key pair locally and wires up the public key automatically, so there is no separate file to lose track of. Small detail, but it changed how I think about secure access.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step Four, Locking the Front Door, Then Only Opening What Is Needed
&lt;/h2&gt;

&lt;p&gt;A server exposed to the entire internet on every port is a server waiting to be compromised. So the very next thing I did was open only the two ports this server actually needed, port twenty two for SSH access, and port eighty for web traffic. Nothing else.&lt;/p&gt;

&lt;p&gt;Opening the first port went smoothly.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az vm open-port &lt;span class="nt"&gt;--resource-group&lt;/span&gt; hagital-rg &lt;span class="nt"&gt;--name&lt;/span&gt; hagital-server &lt;span class="nt"&gt;--port&lt;/span&gt; 22
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Opening the second one did not.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az vm open-port &lt;span class="nt"&gt;--resource-group&lt;/span&gt; hagital-rg &lt;span class="nt"&gt;--name&lt;/span&gt; hagital-server &lt;span class="nt"&gt;--port&lt;/span&gt; 80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Azure responded with an error I had never seen before.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SecurityRuleConflict: Security rule open-port-22 conflicts with rule open-port-80. Rules cannot have the same Priority and Direction.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This is the part of the process that actually taught me something. In Azure, every firewall rule, called a network security group rule, has a priority number, and two inbound rules cannot share the same one. The open port command always tries to use the same default priority, nine hundred, no matter how many times you run it. My SSH rule had already claimed that number, so the HTTP rule had nowhere to go.&lt;/p&gt;

&lt;p&gt;I checked the existing rules to see exactly what was going on.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az network nsg rule list &lt;span class="nt"&gt;--resource-group&lt;/span&gt; hagital-rg &lt;span class="nt"&gt;--nsg-name&lt;/span&gt; hagital-serverNSG &lt;span class="nt"&gt;--output&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Once I understood the cause, the fix was straightforward. I created the HTTP rule manually and gave it its own priority instead of relying on the default.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az network nsg rule create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; hagital-rg &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--nsg-name&lt;/span&gt; hagital-serverNSG &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; AllowHTTP &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--priority&lt;/span&gt; 910 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--direction&lt;/span&gt; Inbound &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--access&lt;/span&gt; Allow &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--protocol&lt;/span&gt; Tcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--destination-port-ranges&lt;/span&gt; 80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That single error taught me more about how Azure firewall rules work than any tutorial had, because I had to actually understand the priority system to solve it myself.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step Five, Walking In Through SSH
&lt;/h2&gt;

&lt;p&gt;With the network secured, I fetched the server's public IP address and connected directly.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az vm show &lt;span class="nt"&gt;--resource-group&lt;/span&gt; hagital-rg &lt;span class="nt"&gt;--name&lt;/span&gt; hagital-server &lt;span class="nt"&gt;--show-details&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; publicIps &lt;span class="nt"&gt;--output&lt;/span&gt; tsv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh azureuser@&amp;lt;public-ip&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;No password prompt, no key file to search for, just a clean connection using the key pair Azure had already set up.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step Six, Giving the Server a Job
&lt;/h2&gt;

&lt;p&gt;A bare server does nothing on its own. I installed Nginx, a lightweight and widely used web server, so the machine could actually serve web pages to anyone who visited its address.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;nginx &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Running an update before the install is a habit worth keeping. It refreshes the list of available packages so you are installing the current version rather than something outdated sitting in a stale cache.&lt;/p&gt;

&lt;p&gt;A quick check confirmed everything was running as expected.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl status nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Step Seven, Getting My Own Project Onto the Server
&lt;/h2&gt;

&lt;p&gt;At this point the server could serve a web page, just not mine yet. I copied a portfolio project, built with plain HTML, CSS and JavaScript, from my local machine onto the server using secure copy.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scp &lt;span class="nt"&gt;-r&lt;/span&gt; ./devops_portfolio azureuser@&amp;lt;public-ip&amp;gt;:/tmp/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The next step introduced two more mistakes, both worth sharing because they are easy to make and easy to learn from.&lt;/p&gt;

&lt;p&gt;The first attempt tried to move the entire project folder directly onto the location of the existing default web page, and Azure's server refused, since you cannot replace a single file with an entire folder.&lt;/p&gt;

&lt;p&gt;The second attempt failed for a different reason, a plain permission error, since the folder that serves web content on a Linux server is owned by the system, not by the everyday user account.&lt;/p&gt;

&lt;p&gt;The working solution handled both issues at once, moving the contents of the folder rather than the folder itself, and running the command with elevated permission.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo mv&lt;/span&gt; /tmp/devops_portfolio/&lt;span class="k"&gt;*&lt;/span&gt; /var/www/html/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;A moment later, visiting the server's public address in a browser showed my actual portfolio, live, served from a machine I had built entirely from a terminal.&lt;/p&gt;
&lt;h2&gt;
  
  
  Turning the Whole Process Into a Script
&lt;/h2&gt;

&lt;p&gt;Doing this manually taught me the mechanics, but repeating it manually every time would not scale, and would not reflect how this is actually done professionally. So I converted the entire workflow into a bash script, with each stage wrapped in its own function, resource group creation, network creation, server creation, and a reusable function for opening ports safely, one that checks whether a rule already exists before attempting to create it again.&lt;/p&gt;

&lt;p&gt;Running the whole provisioning process now takes one command and finishes in a matter of seconds, something that used to take a series of manual steps and careful attention to avoid the exact mistakes I described above.&lt;/p&gt;
&lt;h2&gt;
  
  
  What This Project Actually Demonstrates
&lt;/h2&gt;

&lt;p&gt;If you are a recruiter or a hiring manager reading this, here is what I want you to take away. This was not a copy and paste exercise. Every command here was typed, tested, and in more than one case, broken and then fixed by understanding the actual cause rather than guessing. I hit a real Azure networking conflict and resolved it by reading the error and understanding how security rule priorities work. I hit a real Linux permissions issue and resolved it the same way.&lt;/p&gt;

&lt;p&gt;I also wrote the entire process up as a runbook, the kind of documentation a real engineering team would expect, so that anyone on a team could follow these exact steps and get the same result, safely and predictably.&lt;/p&gt;

&lt;p&gt;The live result of this project is a working website, served entirely from infrastructure I provisioned, secured and configured myself, from the terminal, from the ground up.&lt;/p&gt;

&lt;p&gt;If you would like to see the full runbook, the automation script, or talk through any part of how this was built, I would genuinely welcome the conversation. This is exactly the kind of work I want to keep doing.&lt;/p&gt;

&lt;p&gt;Below is the link to my github repo&lt;br&gt;
&lt;/p&gt;
&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/highpee1991" rel="noopener noreferrer"&gt;
        highpee1991
      &lt;/a&gt; / &lt;a href="https://github.com/highpee1991/azure-cli-lab" rel="noopener noreferrer"&gt;
        azure-cli-lab
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      My Azure CLI learning journey from beginner to pro
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Day 01 — Azure CLI Basics&lt;/h1&gt;
&lt;/div&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;What I Learned&lt;/h2&gt;
&lt;/div&gt;

&lt;p&gt;Azure CLI is a tool that lets you manage Azure resources from the terminal...&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Commands I Ran&lt;/h2&gt;
&lt;/div&gt;

&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;Check version&lt;/h3&gt;

&lt;/div&gt;

&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;az --version&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Output:
azure-cli                         2.87.0&lt;/p&gt;
&lt;p&gt;core                              2.87.0
telemetry                          1.1.0&lt;/p&gt;
&lt;p&gt;Dependencies:
msal                              1.36.0
azure-mgmt-resource               24.0.0&lt;/p&gt;
&lt;p&gt;Python location 'C:\Program Files\Microsoft SDKs\Azure\CLI2\python.exe'
Config directory 'C:\Users\Relitorin Orders.azure'
Extensions directory 'C:\Users\Relitorin Orders.azure\cliextensions'&lt;/p&gt;
&lt;p&gt;Python (Windows) 3.13.13 (tags/v3.13.13:01104ce, Apr  7 2026, 19:25:48) [MSC v.1944 64 bit (AMD64)]&lt;/p&gt;
&lt;p&gt;Legal docs and information: aka.ms/AzureCliLegal&lt;/p&gt;
&lt;p&gt;Your CLI is up-to-date.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;Login to Azure&lt;/h3&gt;

&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;az login
az account show&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Output:
Retrieving tenants and subscriptions for the selection...&lt;/p&gt;
&lt;p&gt;[Tenant and subscription selection]&lt;/p&gt;
&lt;p&gt;No     Subscription name     Subscription ID                       Tenant&lt;/p&gt;

&lt;p&gt;[1] *  Azure subscription 1    Default Directory&lt;/p&gt;
&lt;p&gt;The default is marked with an *; the default tenant is 'Default Directory' and subscription is 'Azure subsc
ription 1' ().&lt;/p&gt;
&lt;p&gt;Select a subscription and tenant (Type a number or Enter for no changes):&lt;/p&gt;
&lt;p&gt;Tenant: Default Directory
Subscription: Azure subscription…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/highpee1991/azure-cli-lab" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
    </item>
    <item>
      <title>Multi-Region VNet Peering + Global Load Balancer on Azure, A Step-by-Step Guide</title>
      <dc:creator>Ipadeola Taiwo</dc:creator>
      <pubDate>Mon, 29 Jun 2026 19:12:04 +0000</pubDate>
      <link>https://dev.to/highpee1991/multi-region-vnet-peering-global-load-balancer-on-azure-a-step-by-step-guide-45ap</link>
      <guid>https://dev.to/highpee1991/multi-region-vnet-peering-global-load-balancer-on-azure-a-step-by-step-guide-45ap</guid>
      <description>&lt;h2&gt;
  
  
  What We're Building
&lt;/h2&gt;

&lt;p&gt;A Nigerian fintech has two offices, &lt;strong&gt;Lagos&lt;/strong&gt; and &lt;strong&gt;Abuja&lt;/strong&gt; and wants:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Both offices on the &lt;strong&gt;same private network&lt;/strong&gt;, even though they sit in different parts of the world&lt;/li&gt;
&lt;li&gt;A web app running on a server in each office&lt;/li&gt;
&lt;li&gt;Traffic automatically routed to whichever office is closest to the user, with &lt;strong&gt;automatic failover&lt;/strong&gt; if one office's server goes down&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To simulate this on Azure, Lagos lives in &lt;strong&gt;East US&lt;/strong&gt; and Abuja lives in &lt;strong&gt;South Africa North&lt;/strong&gt; two genuinely different Azure regions, connected privately, with a global load balancer deciding where to send traffic.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fufsns9lkp6rlxfav5jcm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fufsns9lkp6rlxfav5jcm.png" alt=" " width="799" height="436"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;By the end of this guide, you'll have a working multi-region setup you can test from your own terminal.&lt;/p&gt;

&lt;h3&gt;
  
  
  What you'll need
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;An Azure account (the free tier works fine)&lt;/li&gt;
&lt;li&gt;Basic comfort with the Azure Portal&lt;/li&gt;
&lt;li&gt;A terminal that can run &lt;code&gt;ssh&lt;/code&gt; and &lt;code&gt;curl&lt;/code&gt; (Git Bash, macOS/Linux terminal, or Cloud Shell)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 1: Create a Resource Group
&lt;/h2&gt;

&lt;p&gt;A resource group is just a logical folder that holds everything we build, so it's easy to manage or delete later as one unit.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;In the Azure Portal, search &lt;strong&gt;"Resource groups"&lt;/strong&gt; → &lt;strong&gt;+ Create&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resource group name:&lt;/strong&gt; &lt;code&gt;fintech-rg&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Region:&lt;/strong&gt; pick any region for the resource group itself, it doesn't have to match the resources inside it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review + Create&lt;/strong&gt; → &lt;strong&gt;Create&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F554aaiatbqcy1udvbe6n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F554aaiatbqcy1udvbe6n.png" alt=" " width="800" height="358"&gt;&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 2: Create Two Virtual Networks (VNets)
&lt;/h2&gt;

&lt;p&gt;A VNet is a private network inside Azure. We need one for each branch office, in two different regions.&lt;/p&gt;

&lt;h3&gt;
  
  
  VNet for Lagos (East US)
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Search &lt;strong&gt;"Virtual networks"&lt;/strong&gt; → &lt;strong&gt;+ Create&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Resource group: &lt;code&gt;fintech-rg&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Name: &lt;code&gt;lagos-vnet&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Region: &lt;strong&gt;East US&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Leave the default address space and subnet, or customize if you prefer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review + Create&lt;/strong&gt; → &lt;strong&gt;Create&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  VNet for Abuja (South Africa North)
&lt;/h3&gt;

&lt;p&gt;Repeat the same steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Name: &lt;code&gt;abuja-vnet&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Region: &lt;strong&gt;South Africa North&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review + Create&lt;/strong&gt; → &lt;strong&gt;Create&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsofku5g0b2nfc682kykz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsofku5g0b2nfc682kykz.png" alt=" " width="800" height="361"&gt;&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 3: Peer the Two VNets Together
&lt;/h2&gt;

&lt;p&gt;VNet Peering connects the two networks so resources inside them can talk to each other using &lt;strong&gt;private IP addresses&lt;/strong&gt;, as if they were on the same local network, no public internet involved.&lt;/p&gt;

&lt;p&gt;Peering needs to be set up from &lt;strong&gt;both sides&lt;/strong&gt; once from Lagos's VNet pointing to Abuja, and once from Abuja's VNet pointing to Lagos.&lt;/p&gt;

&lt;h3&gt;
  
  
  From the Lagos VNet
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Open &lt;code&gt;lagos-vnet&lt;/code&gt; → left menu → &lt;strong&gt;Peerings&lt;/strong&gt; → &lt;strong&gt;+ Add&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This virtual network:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;Peering link name: &lt;code&gt;lagos-to-abuja&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Allow traffic to remote virtual network: ✅ enabled&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remote virtual network:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;Peering link name: &lt;code&gt;abuja-to-lagos&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Virtual network: select &lt;code&gt;abuja-vnet&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Allow traffic to remote virtual network: ✅ enabled&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This single step actually creates both sides of the peering at once.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff7hg8qkg7zchkyjbus83.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff7hg8qkg7zchkyjbus83.png" alt=" " width="799" height="357"&gt;&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Confirm the Peering Is Connected
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Go back to &lt;code&gt;lagos-vnet&lt;/code&gt; → &lt;strong&gt;Peerings&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;You should see the peering listed with status: &lt;strong&gt;Connected&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Check &lt;code&gt;abuja-vnet&lt;/code&gt; → &lt;strong&gt;Peerings&lt;/strong&gt; too — it should show &lt;strong&gt;Connected&lt;/strong&gt; from its side as well&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F07wwqtpf9myyhe8z2x08.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F07wwqtpf9myyhe8z2x08.png" alt=" " width="799" height="330"&gt;&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 4: Create a VM in Each VNet
&lt;/h2&gt;

&lt;h3&gt;
  
  
  VM for Lagos
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Search &lt;strong&gt;"Virtual machines"&lt;/strong&gt; → &lt;strong&gt;+ Create&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Resource group: &lt;code&gt;fintech-rg&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;VM name: &lt;code&gt;lagos-vm&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Region: &lt;strong&gt;East US&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Image: &lt;strong&gt;Ubuntu Server&lt;/strong&gt; (any recent LTS version)&lt;/li&gt;
&lt;li&gt;Size: the default/smallest size is fine for this project&lt;/li&gt;
&lt;li&gt;Authentication: &lt;strong&gt;SSH public key&lt;/strong&gt; (recommended) or password&lt;/li&gt;
&lt;li&gt;Username: &lt;code&gt;lagosvm&lt;/code&gt; &lt;em&gt;(or whatever username you prefer, just stay consistent for the rest of the guide)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Networking tab:&lt;/strong&gt; Virtual network → select &lt;code&gt;lagos-vnet&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review + Create&lt;/strong&gt; → &lt;strong&gt;Create&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  VM for Abuja
&lt;/h3&gt;

&lt;p&gt;Repeat with:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;VM name: &lt;code&gt;abuja-vm&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Region: &lt;strong&gt;South Africa North&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Username: &lt;code&gt;abujavm&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Virtual network: &lt;code&gt;abuja-vnet&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8znllk21tio3luhvi5da.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8znllk21tio3luhvi5da.png" alt=" " width="800" height="361"&gt;&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Once both VMs are created, &lt;strong&gt;note down&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each VM's &lt;strong&gt;public IP&lt;/strong&gt; (used to SSH in)&lt;/li&gt;
&lt;li&gt;Each VM's &lt;strong&gt;private IP&lt;/strong&gt; (used for the ping test later)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In this guide, the private IPs are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lagos VM private IP: &lt;code&gt;10.0.0.4&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Abuja VM private IP: &lt;code&gt;10.1.0.4&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;(Yours may be different — always use the actual IP shown in your VM's &lt;strong&gt;Overview&lt;/strong&gt; or &lt;strong&gt;Networking&lt;/strong&gt; tab.)&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Secure SSH Access and Connect to Each VM
&lt;/h2&gt;

&lt;p&gt;If you're using an SSH key pair, make sure your private key file has the correct permissions before connecting, otherwise SSH will refuse to use it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;chmod &lt;/span&gt;700 lagosvm_key.pem
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Do the same for the Abuja key if you're using a separate one.)&lt;/p&gt;

&lt;p&gt;Connect to the Lagos VM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-i&lt;/span&gt; lagosvm_key.pem lagosvm@&amp;lt;lagos-vm-public-ip&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Connect to the Abuja VM in a separate terminal tab:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-i&lt;/span&gt; abujavm_key.pem abujavm@&amp;lt;abuja-vm-public-ip&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk3dep7526pb8il5rdgcg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk3dep7526pb8il5rdgcg.png" alt=" " width="478" height="303"&gt;&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frdp07wudstf6zlcyjqtb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frdp07wudstf6zlcyjqtb.png" alt=" " width="800" height="449"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Install and Start a Web Server on Each VM
&lt;/h2&gt;

&lt;p&gt;Run this on &lt;strong&gt;both&lt;/strong&gt; VMs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;apache2 &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start apache2
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;apache2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;apt update &amp;amp;&amp;amp; upgrade&lt;/code&gt; refreshes the package list and applies any pending updates&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;apache2&lt;/code&gt; is the web server that will respond to HTTP requests&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;enable&lt;/code&gt; makes sure Apache automatically restarts if the VM ever reboots&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Confirm Apache is running on each VM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl status apache2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see &lt;code&gt;active (running)&lt;/code&gt; in green.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnkucyb2uh5y9tryzr82i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnkucyb2uh5y9tryzr82i.png" alt=" " width="800" height="472"&gt;&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 7: Test the Private Network Connection (Ping Test)
&lt;/h2&gt;

&lt;p&gt;This is the moment that proves the VNet peering actually works — two VMs, in two different regions, reaching each other over a private connection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;From the Lagos VM&lt;/strong&gt;, ping Abuja's private IP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ping 10.1.0.4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;From the Abuja VM&lt;/strong&gt;, ping Lagos's private IP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ping 10.0.0.4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If peering is working correctly, you'll see successful replies on both sides, something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;64 bytes from 10.1.0.4: icmp_seq=1 ttl=64 time=142 ms
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Press &lt;code&gt;Ctrl + C&lt;/code&gt; to stop the ping.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmnf6sg3pjx16hxkymk7t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmnf6sg3pjx16hxkymk7t.png" alt=" " width="800" height="419"&gt;&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F980pdazng8qdir16b8oy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F980pdazng8qdir16b8oy.png" alt=" " width="482" height="309"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Note:&lt;/strong&gt; If you don't get replies, double check that each VM's &lt;strong&gt;Network Security Group (NSG)&lt;/strong&gt; allows inbound traffic from the peered VNet (the default &lt;code&gt;AllowVnetInBound&lt;/code&gt; rule usually covers this automatically).&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 8: Customize Each Web Page So You Can Tell Them Apart
&lt;/h2&gt;

&lt;p&gt;To visually confirm which server is responding later (especially once the load balancer is involved), give each VM a distinct homepage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the Lagos VM:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;h1&amp;gt;Hello from Lagos Server&amp;lt;/h1&amp;gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /var/www/html/index.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;On the Abuja VM:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;h1&amp;gt;Hello from Abuja Server&amp;lt;/h1&amp;gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /var/www/html/index.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test it directly using each VM's &lt;strong&gt;public IP&lt;/strong&gt; in a browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://&amp;lt;lagos-vm-public-ip&amp;gt;
http://&amp;lt;abuja-vm-public-ip&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see the matching "Hello from..." message for each one.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuf68w6xesvpowvmjejzd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuf68w6xesvpowvmjejzd.png" alt=" " width="799" height="397"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 9: Open Port 80 on Both VMs
&lt;/h2&gt;

&lt;p&gt;Apache runs on port 80, so each VM's NSG needs to allow inbound HTTP traffic — otherwise external requests (including from the load balancer) won't be able to reach it.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open each VM → &lt;strong&gt;Networking&lt;/strong&gt; tab → &lt;strong&gt;Add inbound port rule&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Settings:

&lt;ul&gt;
&lt;li&gt;Destination port: &lt;code&gt;80&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Protocol: &lt;code&gt;TCP&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Action: &lt;code&gt;Allow&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Give the rule a name (anything descriptive works, e.g. &lt;code&gt;Allow-HTTP&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Repeat for the other VM&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Step 10: Create the Regional Load Balancer for Lagos
&lt;/h2&gt;

&lt;p&gt;Since Lagos and Abuja are in different regions, we can't use one single load balancer to cover both — a standard Azure Load Balancer only operates within a single region. We need &lt;strong&gt;one regional load balancer per region&lt;/strong&gt;, and later, a &lt;strong&gt;Global Load Balancer&lt;/strong&gt; sitting on top of both.&lt;/p&gt;

&lt;h3&gt;
  
  
  10.1 — Create the Public IP
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Search &lt;strong&gt;"Public IP addresses"&lt;/strong&gt; → &lt;strong&gt;+ Create&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Give it a name (e.g. &lt;code&gt;lagos-lb-ip&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;SKU: &lt;strong&gt;Standard&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Tier: &lt;strong&gt;Regional&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Region: &lt;strong&gt;East US&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Assignment: &lt;strong&gt;Static&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review + Create&lt;/strong&gt; → &lt;strong&gt;Create&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  10.2 — Create the Load Balancer
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Search &lt;strong&gt;"Load balancers"&lt;/strong&gt; → &lt;strong&gt;+ Create&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Give it a name (e.g. &lt;code&gt;lagos-lb&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Region: &lt;strong&gt;East US&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;SKU: &lt;strong&gt;Standard&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Type: &lt;strong&gt;Public&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Tier: &lt;strong&gt;Regional&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frontend IP configuration:&lt;/strong&gt; add a frontend IP, give it a name, and select the public IP you just created (&lt;code&gt;lagos-lb-ip&lt;/code&gt;) — this becomes your regional frontend IP, in this guide &lt;code&gt;20.121.242.74&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Skip backend pools and rules for now — we'll add them after creation for a cleaner setup&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review + Create&lt;/strong&gt; → &lt;strong&gt;Create&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fczlfbxgm5ijugj9ep7oy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fczlfbxgm5ijugj9ep7oy.png" alt=" " width="800" height="368"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkh86ospmiwmhcaashe3b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkh86ospmiwmhcaashe3b.png" alt=" " width="800" height="328"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  10.3 — Add the Backend Pool
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Open your Lagos load balancer → &lt;strong&gt;Backend pools&lt;/strong&gt; → &lt;strong&gt;+ Add&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Give it a name&lt;/li&gt;
&lt;li&gt;Virtual network: select &lt;code&gt;lagos-vnet&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Backend pool configuration: &lt;strong&gt;NIC&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;+ Add&lt;/strong&gt; under the IP configurations table → select &lt;code&gt;lagos-vm&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Save&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fye5rfwc1lm23bq6bxwlz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fye5rfwc1lm23bq6bxwlz.png" alt=" " width="800" height="395"&gt;&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  10.4 — Add a Health Probe
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Health probes&lt;/strong&gt; → &lt;strong&gt;+ Add&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Give it a name&lt;/li&gt;
&lt;li&gt;Protocol: &lt;strong&gt;HTTP&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Port: &lt;code&gt;80&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Path: &lt;code&gt;/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Interval: 5–15 seconds&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  10.5 — Add the Load Balancing Rule
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Load balancing rules&lt;/strong&gt; → &lt;strong&gt;+ Add&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Give it a name&lt;/li&gt;
&lt;li&gt;Frontend IP: select the frontend IP you created&lt;/li&gt;
&lt;li&gt;Backend pool: select the backend pool you created&lt;/li&gt;
&lt;li&gt;Health probe: select the probe you created&lt;/li&gt;
&lt;li&gt;Protocol: &lt;strong&gt;TCP&lt;/strong&gt;, Port: &lt;code&gt;80&lt;/code&gt;, Backend port: &lt;code&gt;80&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  10.6 — Test the Lagos Load Balancer
&lt;/h3&gt;

&lt;p&gt;From your terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://20.121.242.74
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(replace with your actual Lagos load balancer frontend IP)&lt;/p&gt;

&lt;p&gt;You should see the "Hello from Lagos Server" response.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvzoc0fpw2cmvkzcddwbq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvzoc0fpw2cmvkzcddwbq.png" alt=" " width="799" height="302"&gt;&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 11: Create the Regional Load Balancer for Abuja
&lt;/h2&gt;

&lt;p&gt;Repeat &lt;strong&gt;all of Step 10&lt;/strong&gt;, but for the Abuja region:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Public IP: give it a name, Region: &lt;strong&gt;South Africa North&lt;/strong&gt; → in this guide, the resulting frontend IP is &lt;code&gt;20.87.39.70&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Load Balancer: give it a name, Region: &lt;strong&gt;South Africa North&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Backend pool: Virtual network → &lt;code&gt;abuja-vnet&lt;/code&gt;, add &lt;code&gt;abuja-vm&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Health probe and rule: same settings as before (HTTP/80/&lt;code&gt;/&lt;/code&gt;, TCP/80→80)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Test it:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://20.87.39.70
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see "Hello from Abuja Server."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftz984ho19rw2h0ea19ku.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftz984ho19rw2h0ea19ku.png" alt=" " width="800" height="401"&gt;&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;⚠️ &lt;strong&gt;Important:&lt;/strong&gt; Don't move to the next step until both regional load balancers pass this test independently. The Global Load Balancer depends on both of these already working correctly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 12: Create the Global Load Balancer
&lt;/h2&gt;

&lt;p&gt;The Global Load Balancer sits above both regional load balancers and decides which region to send each user's request to, based on which one is geographically closest &lt;strong&gt;and&lt;/strong&gt; healthy.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1173e0ek79vxzng5zx5y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1173e0ek79vxzng5zx5y.png" alt=" " width="800" height="412"&gt;&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  12.1 — Create the Global Public IP
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Search &lt;strong&gt;"Public IP addresses"&lt;/strong&gt; → &lt;strong&gt;+ Create&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Give it a name (e.g. &lt;code&gt;global-lb-ip&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;SKU: &lt;strong&gt;Standard&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Tier: &lt;strong&gt;Global&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Region: must be one of Azure's supported &lt;strong&gt;home regions&lt;/strong&gt; for global resources, examples include &lt;code&gt;East US 2&lt;/code&gt;, &lt;code&gt;West Europe&lt;/code&gt;, &lt;code&gt;Southeast Asia&lt;/code&gt;, &lt;code&gt;West US&lt;/code&gt;, &lt;code&gt;North Europe&lt;/code&gt;, &lt;code&gt;Central US&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Assignment: &lt;strong&gt;Static&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review + Create&lt;/strong&gt; → &lt;strong&gt;Create&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In this guide, the resulting global IP is &lt;code&gt;20.15.1.61&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  12.2 — Create the Global Load Balancer
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Search &lt;strong&gt;"Load balancers"&lt;/strong&gt; → &lt;strong&gt;+ Create&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Give it a name (e.g. &lt;code&gt;global-lb&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Region: the same home region you used for the global IP (e.g. &lt;code&gt;East US 2&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;SKU: &lt;strong&gt;Standard&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Tier: &lt;strong&gt;Global&lt;/strong&gt; ⚠️ this is the setting that matters most here — don't select Regional&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frontend IP configuration:&lt;/strong&gt; add a frontend IP, give it a name, and select your &lt;code&gt;global-lb-ip&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Skip backend pools and rules in the wizard&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review + Create&lt;/strong&gt; → &lt;strong&gt;Create&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  12.3 — Add the Backend Pool
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Open your Global load balancer → &lt;strong&gt;Backend pools&lt;/strong&gt; → &lt;strong&gt;+ Add&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Give it a name&lt;/li&gt;
&lt;li&gt;Under the configuration table, click &lt;strong&gt;+ Add&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Type: &lt;strong&gt;Load Balancer&lt;/strong&gt; (not NIC, not IP address)&lt;/li&gt;
&lt;li&gt;Add your &lt;strong&gt;Lagos&lt;/strong&gt; load balancer&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;+ Add&lt;/strong&gt; again and add your &lt;strong&gt;Abuja&lt;/strong&gt; load balancer&lt;/li&gt;
&lt;li&gt;Confirm both regional load balancers now appear in the table&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Save&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2m4mi1ib8rllhirc0dzl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2m4mi1ib8rllhirc0dzl.png" alt=" " width="800" height="349"&gt;&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  12.4 — Add the Load Balancing Rule
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Load balancing rules&lt;/strong&gt; → &lt;strong&gt;+ Add&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Give it a name&lt;/li&gt;
&lt;li&gt;Frontend IP: your global frontend IP&lt;/li&gt;
&lt;li&gt;Backend pool: your global backend pool&lt;/li&gt;
&lt;li&gt;Protocol: &lt;strong&gt;TCP&lt;/strong&gt;, Port: &lt;code&gt;80&lt;/code&gt;, &lt;strong&gt;Backend port: &lt;code&gt;80&lt;/code&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The backend port here must match the frontend port used in both regional load balancer rules — in this case, both use port 80, so we're consistent.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Add&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Step 13: Test the Global Load Balancer
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://20.15.1.61
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(replace with your actual global load balancer IP)&lt;/p&gt;

&lt;p&gt;You should get back a response from whichever region is closest to you, for example, "Hello from Lagos Server."&lt;/p&gt;

&lt;p&gt;This is expected behavior: the Global Load Balancer uses &lt;strong&gt;geo-proximity routing&lt;/strong&gt;, not round robin. It always routes you to the closest healthy region, not a random alternating split.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdw8afa5pa006o2k8ga1x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdw8afa5pa006o2k8ga1x.png" alt=" " width="647" height="312"&gt;&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 14: Test Automatic Failover
&lt;/h2&gt;

&lt;p&gt;To prove the system can actually fail over between regions, we'll simulate one region going down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SSH into the Lagos VM&lt;/strong&gt; and stop Apache:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl stop apache2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now curl the global load balancer again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://20.15.1.61
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This time, the response should switch to &lt;strong&gt;"Hello from Abuja Server"&lt;/strong&gt; Azure detected that the Lagos region's health probe failed, removed it from rotation, and automatically routed traffic to the healthy Abuja region instead.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgqilnqp2yfx2cy0vi3r2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgqilnqp2yfx2cy0vi3r2.png" alt=" " width="514" height="413"&gt;&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Once you're done testing, restart Apache on the Lagos VM so it's back in rotation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start apache2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Recap: What We Actually Built
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client
  ↓
Global Load Balancer (home region, e.g. East US 2)
  ↓                              ↓
Regional LB - Lagos          Regional LB - Abuja
(East US)                    (South Africa North)
  ↓                              ↓
lagos-vm                     abuja-vm
  ↳ peered privately with abuja-vm via VNet Peering
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This setup demonstrates three real-world cloud concepts at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Private cross-region connectivity&lt;/strong&gt; (VNet Peering)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Regional high availability&lt;/strong&gt; (each region's own load balancer and health probe)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Global resilience and failover&lt;/strong&gt; (the Global Load Balancer routing around an unhealthy region automatically)&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhqlhq9pgtm4aw0c0yjy3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhqlhq9pgtm4aw0c0yjy3.png" alt=" " width="799" height="436"&gt;&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you build this yourself, try the failover test a few times — watching traffic automatically reroute in real time is genuinely satisfying, and it's the same underlying pattern real fintechs use to stay online across regions.&lt;/p&gt;

&lt;p&gt;Got stuck somewhere or have a question about a specific step? Drop a comment below 👇&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Day 2 — I Stopped Being a Student and Started Acting Like a DevOps Engineer</title>
      <dc:creator>Ipadeola Taiwo</dc:creator>
      <pubDate>Sun, 28 Jun 2026 08:32:59 +0000</pubDate>
      <link>https://dev.to/highpee1991/day-2-i-stopped-being-a-student-and-started-acting-like-a-devops-engineer-4kid</link>
      <guid>https://dev.to/highpee1991/day-2-i-stopped-being-a-student-and-started-acting-like-a-devops-engineer-4kid</guid>
      <description>&lt;p&gt;This is Day 2 of my 90-day cloud engineering challenge. Day 1 was about installing tools and getting familiar with the Azure CLI. Day 2 was different — instead of just running commands to learn syntax, I gave myself a real-world scenario and worked through it the way an actual DevOps engineer would on the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Scenario
&lt;/h2&gt;

&lt;p&gt;To push myself past "tutorial mode," I simulated a message from a team lead:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Taiwo, we need a new environment for the payments project. Create a resource group for dev, one for staging. Tag them properly. Create a storage account in dev. List everything and send me a clean output. Then document it."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No step-by-step instructions. No exact commands handed to me — just a goal. That's closer to how real work actually shows up: as an outcome someone needs, not a tutorial to follow. My job was to translate that into the right CLI commands, tag things properly, and produce a clean, professional result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Commands I Used
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Listing and inspecting resource groups
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az group list
az group list &lt;span class="nt"&gt;--output&lt;/span&gt; table
az group list &lt;span class="nt"&gt;--output&lt;/span&gt; tsv
az group list &lt;span class="nt"&gt;--output&lt;/span&gt; jsonc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I started by getting comfortable with different output formats. In a real environment with dozens or hundreds of resources, raw JSON is hard to scan quickly — &lt;code&gt;table&lt;/code&gt; and &lt;code&gt;tsv&lt;/code&gt; make it much easier to read at a glance or pipe into other tools/scripts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Filtering with &lt;code&gt;--query&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az group list &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"[].{Name:name, Location:location}"&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; table
az group list &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"[?location=='eastus'].name"&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; table
az account show &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"name"&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; tsv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where things started feeling like real engineering rather than just running commands. &lt;code&gt;--query&lt;/code&gt; lets you filter and reshape the JSON output before it even hits your screen — instead of scrolling through everything, you ask for exactly what you need. When you're dealing with 200+ resources, this isn't optional; it's how you stay sane.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tagging resources properly
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az group update &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; taiwo-devops-rg &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tags&lt;/span&gt; &lt;span class="nv"&gt;Environment&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Dev &lt;span class="nv"&gt;Owner&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Taiwo &lt;span class="nv"&gt;Project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;AzureCLILab

az group show &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; taiwo-devops-rg &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"tags"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tags seem like a small detail until you realize how much they matter in a real company. Tags answer three questions instantly, for any resource, at any scale:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Who owns it?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What project does it belong to?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What environment is it — dev, staging, or production?&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without consistent tagging, a subscription with hundreds of resources becomes nearly impossible to manage, audit, or clean up safely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Automation Script
&lt;/h2&gt;

&lt;p&gt;Rather than running each command by hand every time I needed an environment, I wrote a Bash script to provision the entire setup — dev resource group, staging resource group, and a storage account — in one run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;

&lt;span class="c"&gt;# ============================================&lt;/span&gt;
&lt;span class="c"&gt;# Azure Environment Provisioner&lt;/span&gt;
&lt;span class="c"&gt;# Author: Taiwo&lt;/span&gt;
&lt;span class="c"&gt;# Description: Provisions dev environment&lt;/span&gt;
&lt;span class="c"&gt;#              for any project automatically&lt;/span&gt;
&lt;span class="c"&gt;# ============================================&lt;/span&gt;

&lt;span class="c"&gt;# Variables — change these for any project&lt;/span&gt;
&lt;span class="nv"&gt;PROJECT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"payments"&lt;/span&gt;
&lt;span class="nv"&gt;OWNER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Taiwo"&lt;/span&gt;
&lt;span class="nv"&gt;LOCATION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"eastus"&lt;/span&gt;
&lt;span class="nv"&gt;STORAGE_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"taiwo&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PROJECT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;dev001"&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"🚀 Starting provisioning for project: &lt;/span&gt;&lt;span class="nv"&gt;$PROJECT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Step 1: Create Dev Resource Group&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"📁 Creating Dev Resource Group..."&lt;/span&gt;
az group create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PROJECT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-dev-rg"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCATION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tags&lt;/span&gt; &lt;span class="nv"&gt;Environment&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Dev &lt;span class="nv"&gt;Owner&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$OWNER&lt;/span&gt; &lt;span class="nv"&gt;Project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT&lt;/span&gt;

&lt;span class="c"&gt;# Step 2: Create Staging Resource Group&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"📁 Creating Staging Resource Group..."&lt;/span&gt;
az group create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PROJECT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-staging-rg"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCATION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tags&lt;/span&gt; &lt;span class="nv"&gt;Environment&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Staging &lt;span class="nv"&gt;Owner&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$OWNER&lt;/span&gt; &lt;span class="nv"&gt;Project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT&lt;/span&gt;

&lt;span class="c"&gt;# Step 3: Create Storage Account&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"💾 Creating Storage Account..."&lt;/span&gt;
az storage account create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STORAGE_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PROJECT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-dev-rg"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCATION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--sku&lt;/span&gt; Standard_LRS &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--kind&lt;/span&gt; StorageV2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tags&lt;/span&gt; &lt;span class="nv"&gt;Environment&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Dev &lt;span class="nv"&gt;Owner&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$OWNER&lt;/span&gt; &lt;span class="nv"&gt;Project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT&lt;/span&gt;

&lt;span class="c"&gt;# Step 4: Confirm everything&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"✅ Provisioning complete. Here is your environment:"&lt;/span&gt;
az group list &lt;span class="nt"&gt;--output&lt;/span&gt; table

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"🎉 Done! Project &lt;/span&gt;&lt;span class="nv"&gt;$PROJECT&lt;/span&gt;&lt;span class="s2"&gt; environment is ready."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script takes a few simple variables — project name, owner, and location — and provisions a complete environment from scratch, consistently, every single time. Change the &lt;code&gt;PROJECT&lt;/code&gt; variable and the same script provisions an environment for a completely different project. That's the difference between "I ran some commands" and "I built something reusable."&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: A Git Workflow Script
&lt;/h2&gt;

&lt;p&gt;Outside of the Azure work itself, I also wrote a small Bash script to speed up my Git workflow — staging, committing, and pushing in a single command instead of typing three separate ones every time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# Exit if any command fails&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt;
&lt;span class="nb"&gt;trap&lt;/span&gt; &lt;span class="s1"&gt;'echo "❌ A command failed. Exiting..."'&lt;/span&gt; ERR

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Current branch: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git branch &lt;span class="nt"&gt;--show-current&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Stage all changes&lt;/span&gt;
git add &lt;span class="nb"&gt;.&lt;/span&gt;

&lt;span class="c"&gt;# Prompt for commit message&lt;/span&gt;
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Enter a commit message: "&lt;/span&gt; commitMessage
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$commitMessage&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        &lt;/span&gt;&lt;span class="nb"&gt;break
    &lt;/span&gt;&lt;span class="k"&gt;fi
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Commit message cannot be empty."&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# Commit and push&lt;/span&gt;
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$commitMessage&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
git push

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"✅ Changes committed and pushed successfully."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Small touches here matter: &lt;code&gt;set -e&lt;/code&gt; and the &lt;code&gt;trap&lt;/code&gt; ensure the script stops cleanly the moment something fails instead of plowing ahead, and the commit-message prompt won't let me accidentally push with an empty message. It's a small script, but it's the kind of "make my own workflow faster" thinking that DevOps is built on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Taught Me About Real DevOps Work
&lt;/h2&gt;

&lt;p&gt;Today reframed how I think about the CLI. It's not just a faster way to click buttons — it's a tool for &lt;strong&gt;repeatability, filtering, and automation&lt;/strong&gt; at a scale where doing things manually simply breaks down.&lt;/p&gt;

&lt;p&gt;In a real company, this is what the day-to-day actually looks like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Spinning up a storage account for a dev team on request&lt;/li&gt;
&lt;li&gt;Checking what resources are currently running in production&lt;/li&gt;
&lt;li&gt;Cleaning up a test environment left over from last sprint&lt;/li&gt;
&lt;li&gt;Standing up a new resource group for a new project&lt;/li&gt;
&lt;li&gt;Filtering out the handful of failed resources from a list of 200+&lt;/li&gt;
&lt;li&gt;Wrapping repeatable tasks into scripts instead of typing them out every time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that works well through a portal UI when you're dealing with real scale. It works through commands, queries, tags, and scripts — exactly what I practiced today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problems I Faced
&lt;/h2&gt;

&lt;p&gt;The biggest challenge wasn't syntax — it was genuinely understanding &lt;em&gt;why&lt;/em&gt; each piece mattered (tagging, querying, scripting) rather than just copying commands. Once I slowed down and researched the reasoning behind each one, things started clicking. I'd rather understand the "why" once than memorize the "what" repeatedly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Clicked Today
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Provisioned a full dev + staging environment from a single scenario, not a tutorial&lt;/li&gt;
&lt;li&gt;Created a Storage Account via CLI inside the correct resource group&lt;/li&gt;
&lt;li&gt;Used &lt;code&gt;--query&lt;/code&gt; to filter and reshape output like an engineer working at scale&lt;/li&gt;
&lt;li&gt;Wrote a reusable Bash automation script for environment provisioning&lt;/li&gt;
&lt;li&gt;Wrote a separate Git workflow script to speed up my own commit/push process&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;Day 1 was about learning the tool. Day 2 was about using it to solve a problem someone else gave me. That shift — from "following steps" to "interpreting a request and deciding the steps myself" — is the part I want to keep practicing for the rest of these 90 days.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Following along with a 90-day hands-on cloud engineering challenge. Catch up on &lt;a href="https://dev.to/highpee1991/day-01-azure-cli-basics-2fh4"&gt;Day 1&lt;/a&gt; here.&lt;/em&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Azure #DevOps #CloudEngineering #100DaysOfCloud #LearningInPublic #BashScripting
&lt;/h1&gt;

</description>
      <category>azure</category>
      <category>career</category>
      <category>devjournal</category>
      <category>devops</category>
    </item>
    <item>
      <title>Day 01 — Azure CLI Basics</title>
      <dc:creator>Ipadeola Taiwo</dc:creator>
      <pubDate>Tue, 23 Jun 2026 16:07:37 +0000</pubDate>
      <link>https://dev.to/highpee1991/day-01-azure-cli-basics-2fh4</link>
      <guid>https://dev.to/highpee1991/day-01-azure-cli-basics-2fh4</guid>
      <description>&lt;p&gt;Welcome to Day 1 of my 90-day cloud engineering challenge! I'm documenting everything I learn as I go — partly to hold myself accountable, and partly because writing things down helps them actually stick.&lt;/p&gt;

&lt;p&gt;Today's focus: getting comfortable with the Azure CLI and creating my first resource entirely from the terminal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I Learned&lt;/strong&gt;&lt;br&gt;
The Azure CLI is a command-line tool that lets you manage Azure resources directly from your terminal, without needing to click through the Azure Portal. Once it's installed and you're logged in, you can create, update, and delete resources with simple commands — which is faster and more repeatable than navigating a UI.&lt;/p&gt;

&lt;p&gt;Commands I Ran&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Install CLI: For me i am on window so i am using below command
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight batchfile"&gt;&lt;code&gt;&lt;span class="kd"&gt;winget&lt;/span&gt; &lt;span class="kd"&gt;install&lt;/span&gt; &lt;span class="na"&gt;--id &lt;/span&gt;&lt;span class="kd"&gt;Microsoft&lt;/span&gt;.AzureCLI
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ol&gt;
&lt;li&gt;Check the installed version
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;az --version
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This confirmed the CLI was installed correctly and showed me the version, dependencies, and Python environment it's running on. Good first sanity check before doing anything else.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Log in to Azure
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;az login
az account show
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This opens a browser-based login flow and connects the CLI to my Azure account. Once logged in, &lt;em&gt;&lt;/em&gt;&lt;/p&gt;&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;&lt;em&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body flex items-center justify-between"&gt;
        &lt;a href="az" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;az&lt;/span&gt;
          

        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/em&gt;&lt;/div&gt;&lt;em&gt;
&lt;/em&gt; confirms which subscription and tenant is currently active — useful for double-checking you're working in the right environment before creating anything.&lt;p&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create my first Resource Group
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;az group create --name taiwo-devops-rg --location eastus
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/subscriptions/&amp;lt;subscription-id&amp;gt;/resourceGroups/taiwo-devops-rg"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eastus"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"managedBy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"taiwo-devops-rg"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"provisioningState"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Succeeded"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Microsoft.Resources/resourceGroups"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Resource Group is basically a container that holds related Azure resources — useful for organizing and managing things together (and for cleaning everything up at once later by just deleting the group).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What This Taught Me&lt;/strong&gt;&lt;br&gt;
This exercise taught me how to create a resource group and control exactly where it lives (in this case, eastus). More importantly, it showed me how much faster the CLI can be compared to the Azure Portal — no waiting on pages to load, no risk of losing your place if your internet connection hiccups. Just a clean command and a clean output. I can already see why CLI/scripting skills matter so much in DevOps and cloud engineering.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problems I Faced&lt;/strong&gt;&lt;br&gt;
Honestly, the biggest challenge today was just remembering the command syntax — this is my first real session using the Azure CLI, so nothing is muscle memory yet. But I expect that to improve quickly with repetition. The more I run these commands, the more naturally they'll come.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why This Matters in DevOps&lt;/strong&gt;&lt;br&gt;
This might look like a small win — installing a CLI and creating one resource group — but it ties directly into how real DevOps workflows operate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Automation over manual clicks&lt;/strong&gt;: DevOps is built on repeatable, scriptable processes. A command like &lt;code&gt;az group create&lt;/code&gt; can be dropped into a CI/CD pipeline, a shell script, or an Infrastructure-as-Code workflow — something you simply can't do with portal clicks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consistency&lt;/strong&gt;: Running the same command always produces the same result. That predictability is exactly what you want when provisioning environments for dev, staging, and production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Foundation for IaC&lt;/strong&gt;: Tools like Terraform, Bicep, and ARM templates all build on the same underlying concepts I practiced today — resource groups, regions, and subscriptions. Getting comfortable with the CLI now makes those tools click faster later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Faster feedback loops&lt;/strong&gt;: Typing a command and getting an immediate JSON response is a much quicker feedback loop than navigating multiple portal pages, which matters when you're iterating quickly or troubleshooting under time pressure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In short: today wasn't just about "I made a resource group." It was about building the muscle memory and mindset that DevOps engineers rely on every day — treating infrastructure as something you script and version, not something you click through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's Next&lt;/strong&gt;&lt;br&gt;
This is Day 1 of 90. Tomorrow I'll be building on this foundation — likely exploring more az commands and starting to provision some actual resources inside this resource group.&lt;/p&gt;

&lt;p&gt;Follow along with the rest of the series as I document 90 days of hands-on cloud engineering learning.&lt;/p&gt;

&lt;p&gt;_#Azure #CloudEngineering #DevOps #100DaysOfCloud #LearningInPublic&lt;/p&gt;

</description>
      <category>azure</category>
      <category>beginners</category>
      <category>cli</category>
      <category>devjournal</category>
    </item>
    <item>
      <title>Building a Multi-Region Load Balancer on Azure for a Nigerian Fintech (Lagos ↔ Abuja) — A Real Troubleshooting Story</title>
      <dc:creator>Ipadeola Taiwo</dc:creator>
      <pubDate>Wed, 17 Jun 2026 13:13:35 +0000</pubDate>
      <link>https://dev.to/highpee1991/building-a-multi-region-load-balancer-on-azure-for-a-nigerian-fintech-lagos-abuja-a-real-dli</link>
      <guid>https://dev.to/highpee1991/building-a-multi-region-load-balancer-on-azure-for-a-nigerian-fintech-lagos-abuja-a-real-dli</guid>
      <description>&lt;p&gt;&lt;strong&gt;The Scenario&lt;/strong&gt;&lt;br&gt;
Imagine a Nigerian fintech — think Flutterwave-style — with offices in &lt;strong&gt;Lagos **and **Abuja&lt;/strong&gt;. Both offices need to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Sit on the same private network (even though they're in different Azure&lt;br&gt;
regions)Serve a web app from two VMs, load-balanced, so if one office's&lt;br&gt;
server goes down, traffic keeps flowing through the other&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;This post walks through exactly how I built this on Azure — including every error I hit along the way, because honestly, the errors taught me more than the parts that worked first try.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fb4wal2rk7dzfjp8t7dnj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fb4wal2rk7dzfjp8t7dnj.png" alt=" " width="800" height="494"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you're following along, you'll need an Azure account (the free tier works) and basic comfort with SSH.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Part 1: Two VNets, Two Regions, One Private Network&lt;/strong&gt;&lt;br&gt;
The first task was creating two Virtual Networks (VNets) in different Azure regions and peering them together so they could talk to each other privately — no public internet involved.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;VNet 1 (Lagos) → South Africa North&lt;/li&gt;
&lt;li&gt;VNet 2 (Abuja) → East US 2&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg0pnvcie8eapji1ojvmz.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg0pnvcie8eapji1ojvmz.PNG" alt=" " width="800" height="157"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After creating both VNets, I set up VNet Peering between them. This is what allows resources in one VNet to reach resources in the other using private IP addresses, as if they were on the same local network — even though they're physically thousands of kilometers apart.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmy7zeugl3jrbg16wnvge.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmy7zeugl3jrbg16wnvge.PNG" alt=" " width="800" height="191"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Part 2: One VM Per Region, Proving They Can Talk&lt;/strong&gt;&lt;br&gt;
Next, I deployed one Linux VM in each VNet:&lt;/p&gt;

&lt;p&gt;vm-lagos in South Africa North&lt;br&gt;
vm-abuja in East US 2&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw4bcodqcclrp14onyg6j.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw4bcodqcclrp14onyg6j.PNG" alt=" " width="800" height="276"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To prove the peering actually worked, I SSH'd into each VM and pinged the other one's private IP address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ping 10.1.0.4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Famwh20shuwga1952ynng.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Famwh20shuwga1952ynng.PNG" alt=" " width="800" height="611"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Faojgkyi3gvn4uvbmstms.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Faojgkyi3gvn4uvbmstms.PNG" alt=" " width="570" height="619"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Getting replies back here was the moment it clicked — two VMs in completely different parts of the world, talking over a private network, no public internet hop required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Part 3: The Load Balancer — Where Things Got Interesting&lt;/strong&gt;&lt;br&gt;
This is the part of the project that taught me the most, because almost nothing went smoothly. I'll walk through it the way it actually happened, mistakes included, because I think that's more useful than a sanitized "perfect setup" guide.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My First Plan (That Didn't Work)&lt;/strong&gt;&lt;br&gt;
My first instinct was: "just create one regular Azure Load Balancer, point it at both VMs, done."&lt;br&gt;
That seemed reasonable until I remembered a key constraint: &lt;strong&gt;a standard Azure Load Balancer only works within a single region&lt;/strong&gt;. My two VMs were in two different regions. A regional load balancer physically can't span across them.&lt;br&gt;
So I had two real options:&lt;br&gt;
&lt;strong&gt;Option 1&lt;/strong&gt;&lt;br&gt;
Fake it by routing cross-region traffic through one region's LB&lt;br&gt;
&lt;strong&gt;What it means&lt;/strong&gt;&lt;br&gt;
Works in theory, but not how Azure actually supports it in production&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 2&lt;/strong&gt;&lt;br&gt;
Use Azure's &lt;strong&gt;Cross-Region (Global) Load Balancer&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;What it means&lt;/strong&gt;&lt;br&gt;
The actual purpose-built solution for exactly this scenario&lt;br&gt;
I went with the proper solution: the &lt;strong&gt;Global Load Balancer&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxstbeoqful5mjg43qto8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxstbeoqful5mjg43qto8.png" alt=" " width="800" height="424"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can't just create one Global LB and point it straight at two VMs. The Global LB sits on top of two regional load balancers — one per region — and routes traffic between regions, while each regional LB handles traffic within its own region.&lt;br&gt;
This means I needed three load balancers total:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A Regional LB in South Africa North (for Lagos)&lt;/li&gt;
&lt;li&gt;A Regional LB in East US (for Abuja)&lt;/li&gt;
&lt;li&gt;One Global LB sitting above both&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Web Server on Both VMs&lt;/strong&gt;&lt;br&gt;
Before touching any load balancer, both VMs needed something to actually serve over HTTP. I installed apache2 on each and customized the homepage so I could visually tell which VM was responding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo apt update &amp;amp;&amp;amp; sudo apt install apache2 -y
sudo systemctl start apache2
sudo systemctl enable apache2
echo "&amp;lt;h1&amp;gt;Lagos Server Loaded&amp;lt;/h1&amp;gt;" | sudo tee /var/www/html/index.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(and the equivalent "Abuja Server Loaded" on the other VM)&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2zbaii6o1gimh2y3p1o9.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2zbaii6o1gimh2y3p1o9.PNG" alt=" " width="800" height="263"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fq8yydhan4soi07o7ieiz.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fq8yydhan4soi07o7ieiz.PNG" alt=" " width="800" height="280"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Open Port 80&lt;/strong&gt;&lt;br&gt;
Each VM's Network Security Group (NSG) needed an inbound rule allowing TCP traffic on port 80, otherwise the web server would be running but unreachable from outside.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbhdjsn0i1jwv5mwf1xno.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbhdjsn0i1jwv5mwf1xno.PNG" alt=" " width="799" height="397"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Build the Two Regional Load Balancers&lt;/strong&gt;&lt;br&gt;
I created one Standard SKU Public Load Balancer in each region, each with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A frontend public IP&lt;/li&gt;
&lt;li&gt;A backend pool containing that region's VM&lt;/li&gt;
&lt;li&gt;A load balancing rule on port 80&lt;/li&gt;
&lt;li&gt;A health probe checking HTTP on port 80&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmvvtlknaknrw9dfhef0m.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmvvtlknaknrw9dfhef0m.PNG" alt=" " width="800" height="401"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At this point, hitting either regional LB's public IP directly in a browser correctly returned that region's "Hello from..." page. Good sign — the foundation was solid.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Part 4: Where I Actually Got Stuck (And How I Fixed Each Thing)&lt;/strong&gt;&lt;br&gt;
This is the part I really want to highlight, because I think showing the actual errors is more useful than pretending everything worked first try.&lt;br&gt;
&lt;strong&gt;🔴 Error 1&lt;/strong&gt;: &lt;strong&gt;"No matching inventory for the requested VIP"&lt;/strong&gt;&lt;br&gt;
When I tried to create the Global Load Balancer for the first time, Azure threw this:&lt;br&gt;
&lt;em&gt;"No matching inventory for the requested VIP of address family: IPv4, sku: Standard and availability zone: non-zonal."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What was actually happening&lt;/strong&gt;: Azure's Cross-Region/Global Load Balancer can only be deployed in specific "Home regions" — a fixed list that includes places like East US 2, West Europe, Southeast Asia, and a handful of others. I had originally tried creating it in a region that wasn't on that list.&lt;br&gt;
&lt;strong&gt;The fix&lt;/strong&gt;: I deleted the failed Public IP and Load Balancer, and recreated both in &lt;strong&gt;East US 2&lt;/strong&gt; specifically, with the Public IP's tier set to &lt;strong&gt;Global&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;🤔 Question I had to clear up: "Does everything need to move to a Home region?"&lt;/strong&gt;&lt;br&gt;
Once I learned about the Home region restriction, I panicked a little — did my Resource Group, VNets, and VMs all need to be in one of these special regions too? That would've defeated the entire point of the assignment (having Lagos and Abuja in genuinely different regions).&lt;br&gt;
Turns out: no. Only the Global Load Balancer itself and its Public IP need to be in a Home region. Everything else — the Resource Group, both VNets, both VMs, and both Regional Load Balancers — can stay exactly where they logically belong. The Home region is just where the Global LB's control plane lives; it doesn't dictate where your actual infrastructure sits.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnmhcwyyo388z9vuq5ldi.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnmhcwyyo388z9vuq5ldi.PNG" alt=" " width="731" height="662"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;🔴 Error 2&lt;/strong&gt;: Global LB deployed, but curl couldn't connect at all&lt;br&gt;
After fixing the region issue, the Global LB deployed successfully. But testing it with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl http:/20.15.2.121
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;...just hung and timed out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What was actually happening&lt;/strong&gt;: When I went to check the regional backend pool configuration, I found the &lt;strong&gt;Virtual Network field was empty&lt;/strong&gt; — meaning the backend pool existed and was attached to a load balancing rule, but had &lt;strong&gt;zero actual VMs inside&lt;/strong&gt; it. It looked configured, but it was an empty shell.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Faamgoajuwhm30btvvif6.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Faamgoajuwhm30btvvif6.PNG" alt=" " width="799" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fix: Selecting the correct VNet from the dropdown revealed an "+ Add" button I hadn't seen before, which let me actually add the VM's NIC to the pool.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6zrbt2oruewqay7bmmmm.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6zrbt2oruewqay7bmmmm.PNG" alt=" " width="800" height="394"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3b5smcrwk3vk2hemj3ky.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3b5smcrwk3vk2hemj3ky.PNG" alt=" " width="800" height="408"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson learned&lt;/strong&gt;: a backend pool being "&lt;strong&gt;attached to a rule" doesn't mean it has members in it&lt;/strong&gt;. Always double check the actual &lt;strong&gt;VM/IP table&lt;/strong&gt;, not just whether the pool exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Final puzzle&lt;/strong&gt;: "Why does my Global LB always show the same region?"&lt;br&gt;
Everything was technically working now — both regional LBs responded correctly, and the Global LB no longer timed out. But running curl against the Global LB's IP over and over only ever returned the Abuja server, never Lagos, no matter how many times I refreshed or switched browsers.&lt;/p&gt;

&lt;p&gt;My first thought: something's broken, it should be alternating like a normal load balancer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What was actually happening&lt;/strong&gt;: This is expected behavior, not a bug. A regional load balancer round-robins between backend VMs. A &lt;strong&gt;Global Load Balancer&lt;/strong&gt; works completely differently — it uses geo-proximity routing, meaning it looks at where the request is physically coming from and always sends it to the closest healthy region. Since I was testing from one physical location every time, it consistently picked the same region — exactly as designed.&lt;/p&gt;

&lt;p&gt;This actually makes total sense for the original use case: a Lagos-based customer should get routed to the Lagos backend, and a US-based customer should get routed to the US backend, automatically, without anyone configuring anything manually.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How I proved both regions actually worked&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Curl each regional LB's IP directly — both returned their correct, distinct pages.&lt;/li&gt;
&lt;li&gt;Simulated a regional failure by stopping apache2 on the Abuja VM, then curled the Global LB IP again — it correctly failed over and served the Lagos page instead.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi55rbrcqzslqejtwuyd2.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi55rbrcqzslqejtwuyd2.PNG" alt=" " width="800" height="268"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# On the Abuja VM
sudo systemctl stop apache2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# From my laptop
curl http:/20.15.2.121
# Now returns "Lagos Server Loaded" instead
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F57i422tj7b0flqenjd1r.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F57i422tj7b0flqenjd1r.PNG" alt=" " width="799" height="288"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Don't forget to restart apache2 afterward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo systemctl start apache2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What I'd Tell Someone Starting This Project&lt;/strong&gt;&lt;br&gt;
If I were doing this again, here's what I'd want to know upfront:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A regional Load Balancer cannot span two regions, full stop&lt;/strong&gt;. If your VMs are in different regions, you need the Global tier sitting on top of two regional LBs underneath it — not one LB trying to do everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Global LB has region restrictions ("Home regions") that your other resources don't&lt;/strong&gt;. Don't panic and try to move your whole architecture — only the Global LB and its Public IP care about this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always verify backend pools actually have members, not just that they exist&lt;/strong&gt;. An empty backend pool with a rule attached looks deceptively "configured."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Global Load Balancer ≠ round robin&lt;/strong&gt;. It's geo-proximity based. If you're testing from one location and only ever see one region respond, that's correct behavior, not a failure — test failover instead to prove redundancy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frgopr5jggvlecini7q6k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frgopr5jggvlecini7q6k.png" alt=" " width="800" height="729"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Final Result&lt;/strong&gt;&lt;br&gt;
By the end, hitting the Global Load Balancer's public IP correctly served traffic from the geographically closest healthy region, and killing nginx on one VM correctly triggered failover to the other — proving genuine multi-region high availability, the same pattern a real fintech would rely on to keep Lagos and Abuja customers online even if one region had issues.&lt;br&gt;
If you're working through something similar, I hope walking through the actual errors (not just the final clean steps) saves you some of the head-scratching it cost me.&lt;br&gt;
Questions or got stuck somewhere different? Drop a comment below 👇&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Creating a Custom RBAC Role in Azure for VM Operations</title>
      <dc:creator>Ipadeola Taiwo</dc:creator>
      <pubDate>Tue, 12 May 2026 04:01:41 +0000</pubDate>
      <link>https://dev.to/highpee1991/creating-a-custom-rbac-role-in-azure-for-vm-operations-ha4</link>
      <guid>https://dev.to/highpee1991/creating-a-custom-rbac-role-in-azure-for-vm-operations-ha4</guid>
      <description>&lt;p&gt;&lt;strong&gt;Introduction&lt;/strong&gt;&lt;br&gt;
In cloud environments, giving users excessive permissions can introduce major security risks. One of the key principles of cloud security is the principle of least privilege, where users receive only the permissions required to perform their tasks.&lt;/p&gt;

&lt;p&gt;In this hands-on project, I implemented a custom Role-Based Access Control (RBAC) role in Microsoft Azure to allow a user manage virtual machines without granting full administrative access.&lt;/p&gt;

&lt;p&gt;The custom role allowed the user to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Start virtual machines&lt;/li&gt;
&lt;li&gt;Stop virtual machines&lt;/li&gt;
&lt;li&gt;View virtual machines&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;While restricting the user from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deleting virtual machines&lt;/li&gt;
&lt;li&gt;Modifying networking resources&lt;/li&gt;
&lt;li&gt;Assigning IAM roles&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This project helped me gain practical experience with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Azure IAM and RBAC&lt;/li&gt;
&lt;li&gt;Custom role creation&lt;/li&gt;
&lt;li&gt;Least privilege security&lt;/li&gt;
&lt;li&gt;Resource Group scoped permissions&lt;/li&gt;
&lt;li&gt;Permission testing and validation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Scenario&lt;/strong&gt;&lt;br&gt;
The DevOps team required a user who could operate virtual machines for daily operational tasks without having permission to delete resources or manage access control settings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Project Objectives&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create a custom RBAC role&lt;/li&gt;
&lt;li&gt;Assign VM-specific permissions&lt;/li&gt;
&lt;li&gt;Restrict unnecessary privileges&lt;/li&gt;
&lt;li&gt;Assign role at Resource Group level&lt;/li&gt;
&lt;li&gt;Validate access using a test user&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Environment Setup&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Resource Group&lt;/li&gt;
&lt;li&gt;Virtual Machine&lt;/li&gt;
&lt;li&gt;Test user (ops-user)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;First we need to create our resource groups, to see guide on RG click the link below on one of our article on creating resource groups&lt;br&gt;
&lt;/p&gt;
&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/highpee1991/how-to-create-a-resource-group-in-azure-step-by-step-guide-4cle" class="crayons-story__hidden-navigation-link"&gt;How to Create a Resource Group in Azure (Step-by-Step Guide)&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/highpee1991" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1894849%2F48eadcb7-7cf7-4299-8728-58c535f1e6bd.jpeg" alt="highpee1991 profile" class="crayons-avatar__image" width="460" height="460"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/highpee1991" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Ipadeola Taiwo
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Ipadeola Taiwo
                
              
              &lt;div id="story-author-preview-content-3363141" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/highpee1991" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1894849%2F48eadcb7-7cf7-4299-8728-58c535f1e6bd.jpeg" class="crayons-avatar__image" alt="" width="460" height="460"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Ipadeola Taiwo&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/highpee1991/how-to-create-a-resource-group-in-azure-step-by-step-guide-4cle" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Mar 17&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/highpee1991/how-to-create-a-resource-group-in-azure-step-by-step-guide-4cle" id="article-link-3363141"&gt;
          How to Create a Resource Group in Azure (Step-by-Step Guide)
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/azure"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;azure&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/cloudcomputing"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;cloudcomputing&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/devops"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;devops&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/microsoft365"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;microsoft365&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/highpee1991/how-to-create-a-resource-group-in-azure-step-by-step-guide-4cle" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/fire-f60e7a582391810302117f987b22a8ef04a2fe0df7e3258a5f49332df1cec71e.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;3&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/highpee1991/how-to-create-a-resource-group-in-azure-step-by-step-guide-4cle#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              &lt;span class="hidden s:inline"&gt;Add&amp;nbsp;Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            3 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


&lt;p&gt;Next is to create our user&lt;br&gt;
we would be brief with this step&lt;br&gt;
first search on the console search bar miscrosoft entra ID&lt;br&gt;
Click on the results &lt;br&gt;
Go to manage and click on users, create users&lt;br&gt;
to see step by step guide on creating a user, click on the link below to see full details on creating a user&lt;br&gt;
&lt;/p&gt;
&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/highpee1991/implementing-rbac-in-azure-for-secure-resource-group-access-39n" class="crayons-story__hidden-navigation-link"&gt;Implementing RBAC in Azure for Secure Resource Group Access&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/highpee1991" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1894849%2F48eadcb7-7cf7-4299-8728-58c535f1e6bd.jpeg" alt="highpee1991 profile" class="crayons-avatar__image" width="460" height="460"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/highpee1991" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Ipadeola Taiwo
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Ipadeola Taiwo
                
              
              &lt;div id="story-author-preview-content-3630217" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/highpee1991" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1894849%2F48eadcb7-7cf7-4299-8728-58c535f1e6bd.jpeg" class="crayons-avatar__image" alt="" width="460" height="460"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Ipadeola Taiwo&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/highpee1991/implementing-rbac-in-azure-for-secure-resource-group-access-39n" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;May 8&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/highpee1991/implementing-rbac-in-azure-for-secure-resource-group-access-39n" id="article-link-3630217"&gt;
          Implementing RBAC in Azure for Secure Resource Group Access
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/azure"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;azure&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/cloud"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;cloud&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/security"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;security&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/tutorial"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;tutorial&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
            &lt;a href="https://dev.to/highpee1991/implementing-rbac-in-azure-for-secure-resource-group-access-39n#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              &lt;span class="hidden s:inline"&gt;Add&amp;nbsp;Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            5 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


&lt;p&gt;Next step is to Create the Custom Role:&lt;br&gt;
To create the custome role follow below step&lt;br&gt;
first go to the resource group created, click on access control (IAM), click on add, add custome role&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkkg3lpr8z264ff2sc22c.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkkg3lpr8z264ff2sc22c.PNG" alt=" " width="800" height="653"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Give the custom role a name, and description&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0h0pom1kj4r4maa65rrz.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0h0pom1kj4r4maa65rrz.PNG" alt=" " width="800" height="331"&gt;&lt;/a&gt;&lt;br&gt;
Click on next&lt;br&gt;
Click on add permission&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmbbtt0ee1ejdkoon94mm.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmbbtt0ee1ejdkoon94mm.PNG" alt=" " width="800" height="598"&gt;&lt;/a&gt;&lt;br&gt;
since the role we want to assign is read (view), start and stop (deallocate)&lt;br&gt;
we would would input this in the search for permission Microsoft.Compute/virtualMachines this allow us to have access to virtual machine control, click on the result microsoft compute then go further by adding /read to the path after clicking on the microsoft compute, select permission and click on add&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fybh7f5jnf90v909x0grt.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fybh7f5jnf90v909x0grt.PNG" alt=" " width="800" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fste9rh9jsnjtv4vv2qzf.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fste9rh9jsnjtv4vv2qzf.PNG" alt=" " width="800" height="598"&gt;&lt;/a&gt;&lt;br&gt;
The read permision will be added&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwxt04msqq5mn9raod2l8.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwxt04msqq5mn9raod2l8.PNG" alt=" " width="800" height="278"&gt;&lt;/a&gt;&lt;br&gt;
we would do the same thing for start&lt;br&gt;
we would click on add permision again, where we have search for a permission we would input this again Microsoft.Compute/virtualMachines and click on microsoft compute after then add /start to the path click on the permission start vm and add &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpkvg4yqczl9nx49ytyxv.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpkvg4yqczl9nx49ytyxv.PNG" alt=" " width="800" height="497"&gt;&lt;/a&gt;&lt;br&gt;
this will add the start permission to the list of our created custom role&lt;/p&gt;

&lt;p&gt;next is to create custom role to stop vm in azure it is not called stop but deallocate so we would do the same thing as we have been doing before click on add permison, paste Microsoft.Compute/virtualMachines in the in the search bar and click on miscrosoft compute add /deallocate to the path and select the permission, so this give it the power to stop the vm &lt;br&gt;
Our permission should look like this &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F24pjr84lu8mhx1potg6n.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F24pjr84lu8mhx1potg6n.PNG" alt=" " width="800" height="507"&gt;&lt;/a&gt;&lt;br&gt;
this add the read, start and stop power custome role, click next, asignable scope we leave it as defaults, then we have our json created&lt;/p&gt;

&lt;p&gt;&lt;code&gt;{    "properties": {        "roleName": "VM Operator",        "description": "The custom role allowed the user to:\n- Start virtual machines\n- Stop virtual machines\n- View virtual machines",        "assignableScopes": [            "/subscriptions/732a7227-baa8-4458-8e10-4c85a2615397/resourceGroups/vm-Operator-rg"        ],        "permissions": [            {                "actions": [                    "Microsoft.Compute/virtualMachines/read",                    "Microsoft.Compute/virtualMachines/start/action",                    "Microsoft.Compute/virtualMachines/deallocate/action"                ],                "notActions": [],                "dataActions": [],                "notDataActions": []            }        ]    }}&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Click next and create&lt;/p&gt;

&lt;p&gt;for security restriction, i intenstionally did not include &lt;br&gt;
Microsoft.Authorization/*&lt;br&gt;
Microsoft.Network/*&lt;br&gt;
Microsoft.Compute/virtualMachines/delete&lt;/p&gt;

&lt;p&gt;this prevented&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;IAM modifications&lt;/li&gt;
&lt;li&gt;Network changes&lt;/li&gt;
&lt;li&gt;VM deletion 
we would now assign this role to our user ops-user, that we created at the reourse group level, this give the user power to start, stop and view vms's in this resource group
For us to assign the role to our user we would go to our resource group vm-operator-rg, that we created 
click on add&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fce3vzbjxjbcq2d3tekot.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fce3vzbjxjbcq2d3tekot.PNG" alt=" " width="800" height="417"&gt;&lt;/a&gt;&lt;br&gt;
click on add role assigment&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fb2v5lzx5yeuwjqhffjo0.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fb2v5lzx5yeuwjqhffjo0.PNG" alt=" " width="800" height="393"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;on the role search for the custome role we created by typing the name we gave to it, vm operator and select the custome role &lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgs85f847f9m4va9q3hqz.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgs85f847f9m4va9q3hqz.PNG" alt=" " width="800" height="378"&gt;&lt;/a&gt;&lt;br&gt;
click on next &lt;br&gt;
click on select member to add the user we want to give this permision to &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp6xr5yvc8afxmskzx34h.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp6xr5yvc8afxmskzx34h.PNG" alt=" " width="800" height="403"&gt;&lt;/a&gt;&lt;br&gt;
select the user and click on select&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbqqoj7f1v7cxs703ehwu.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbqqoj7f1v7cxs703ehwu.PNG" alt=" " width="550" height="386"&gt;&lt;/a&gt;&lt;br&gt;
click on next, then review + assign &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzia0p7a581yarquyldol.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzia0p7a581yarquyldol.PNG" alt=" " width="800" height="503"&gt;&lt;/a&gt;&lt;br&gt;
we have succesfully created our custome role and assign it to a user&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffa72sq9jyesl2iocf6s1.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffa72sq9jyesl2iocf6s1.PNG" alt=" " width="550" height="386"&gt;&lt;/a&gt;&lt;br&gt;
our user can start vm, view vm stop vm, but cannot delete vm or modify IAM&lt;/p&gt;

&lt;p&gt;for us to practicalise some of the action we can log in to user ops-user account&lt;br&gt;
first we try assign IAM to the vm from the ops-user account we can see that the add button is greyed there is no permission for the user to do this operation&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8y5p6eh3tnmb893wq3lf.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8y5p6eh3tnmb893wq3lf.PNG" alt=" " width="800" height="413"&gt;&lt;/a&gt;&lt;br&gt;
also let us try and delete the vm &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqx9cph2d06tdybnbh1rt.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqx9cph2d06tdybnbh1rt.PNG" alt=" " width="800" height="447"&gt;&lt;/a&gt;&lt;br&gt;
we can see we got an error trying to delete the vm &lt;/p&gt;

&lt;p&gt;now let us try stop our running vm&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F056e15amzd50n4tn7vaz.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F056e15amzd50n4tn7vaz.PNG" alt=" " width="800" height="653"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhuekq5er28y4h4ptf669.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhuekq5er28y4h4ptf669.PNG" alt=" " width="800" height="453"&gt;&lt;/a&gt;&lt;br&gt;
It was a success because we gave the user the power to stop the vm&lt;/p&gt;

&lt;p&gt;let us start our vm, we click on start &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0tin2ng62wxwuklfdhyw.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0tin2ng62wxwuklfdhyw.PNG" alt=" " width="800" height="447"&gt;&lt;/a&gt;&lt;br&gt;
we succesfully started our vm &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key Security Lessons&lt;/strong&gt;&lt;br&gt;
Least privilege reduces security risk&lt;br&gt;
Custom roles provide granular control&lt;br&gt;
Resource Group scope limits exposure&lt;br&gt;
Built-in roles may provide excessive permissions&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conclusion&lt;/strong&gt;&lt;br&gt;
This project demonstrated how custom RBAC roles can be used to enforce least privilege access in Azure environments. Instead of assigning broad built-in roles, custom permissions allowed secure delegation of operational tasks while protecting critical infrastructure and access control configurations.&lt;/p&gt;

&lt;p&gt;Thanks for reading&lt;/p&gt;

</description>
      <category>azure</category>
      <category>beginners</category>
      <category>security</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Implementing RBAC in Azure for Secure Resource Group Access</title>
      <dc:creator>Ipadeola Taiwo</dc:creator>
      <pubDate>Fri, 08 May 2026 03:14:09 +0000</pubDate>
      <link>https://dev.to/highpee1991/implementing-rbac-in-azure-for-secure-resource-group-access-39n</link>
      <guid>https://dev.to/highpee1991/implementing-rbac-in-azure-for-secure-resource-group-access-39n</guid>
      <description>&lt;p&gt;In modern cloud environments, not every team member should have full access to resources. Proper access control is important for security, accountability, and preventing accidental changes.&lt;/p&gt;

&lt;p&gt;In this project, I implemented Role-Based Access Control (RBAC) in Microsoft Azure to manage access to a Resource Group. I created multiple users with different permission levels and ensured that only authorized users could create or modify resources, while others had view-only access.&lt;/p&gt;

&lt;p&gt;This hands-on project helped me understand:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Azure IAM and RBAC&lt;/li&gt;
&lt;li&gt;Role assignments&lt;/li&gt;
&lt;li&gt;Least privilege principle&lt;/li&gt;
&lt;li&gt;Resource Group level access control&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Project Scenario&lt;/strong&gt;&lt;br&gt;
A company project is hosted in Azure, and multiple team members require access to the environment. However, not everyone should have full control over the resources.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Objectives&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create a Resource Group&lt;/li&gt;
&lt;li&gt;Create users&lt;/li&gt;
&lt;li&gt;Assign RBAC roles&lt;/li&gt;
&lt;li&gt;Implement least privilege access&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Implementation Steps&lt;/strong&gt;&lt;br&gt;
First create a resource group, this act as logical container for our resoucrces and help us manage our resources easily&lt;/p&gt;

&lt;p&gt;To create a resource group click on the search bar in azure console and type resource groups&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyng2w5d9knoopkthxnip.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyng2w5d9knoopkthxnip.PNG" alt=" " width="800" height="341"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click on the resource groups result shown &lt;/p&gt;

&lt;p&gt;Nest step is to click on create as shown in the image below&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2dld5ou4i2zktsncrrqc.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2dld5ou4i2zktsncrrqc.PNG" alt=" " width="800" height="343"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;on clicking on the create button it will take you to the next page where you will fill in the following information, information such as selecting subscription, by default azure create subscription1 for us and that is what we would be using in this mini project&lt;br&gt;
next give it a resource group name and also select a region, for this project we would go with the South Africa North (as i am based in Nigeria and is the closed region to my company, choose a region closest to your company)&lt;br&gt;
then click on next&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa71ovf6suleaxbcvdmqu.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa71ovf6suleaxbcvdmqu.PNG" alt=" " width="800" height="685"&gt;&lt;/a&gt;&lt;br&gt;
Next you are to select a tag name and value for the resource group to make it easier to search, but this is optional for this project we would leave it blank&lt;br&gt;
Next is to click on review and create after confirmation click on create&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fugz030340pl9iks7n7nn.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fugz030340pl9iks7n7nn.PNG" alt=" " width="445" height="703"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;we have succesfully created our resource groups&lt;/p&gt;

&lt;p&gt;next let us create a user &lt;br&gt;
for this project we would create 3 users&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;dev-user -&amp;gt; role: Contributor -&amp;gt; Access Level: Create/Edit Resources&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;tester-user -&amp;gt; role: Reader -&amp;gt; Access Level: View Only&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;auditor-user -&amp;gt; role: Reader -&amp;gt; Access Level: View Only&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To create a user click on the search bar and type microsoft entra ID &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2zc0f4ck279tawaprl9z.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2zc0f4ck279tawaprl9z.PNG" alt=" " width="800" height="183"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click on microsoft entra ID from the results&lt;br&gt;
Go to manage and click on results&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9drh52bsj8gnjrppm8qn.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9drh52bsj8gnjrppm8qn.PNG" alt=" " width="800" height="406"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click on new user, create new user&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Faveblbsfebmytx6m6l27.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Faveblbsfebmytx6m6l27.PNG" alt=" " width="800" height="306"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;fill all the information and click on next &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0spzxts798m2xmyakzyd.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0spzxts798m2xmyakzyd.PNG" alt=" " width="724" height="591"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Since we want to gie it role from the resource group level we wonrt give it role at this point we would click on review and create and this would create our dev-user&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj359zsb693vewk5d3cp6.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj359zsb693vewk5d3cp6.PNG" alt=" " width="636" height="690"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;we would create a new user again to do the same thing we did ealier to create our tester-user and our auditor-user&lt;/p&gt;

&lt;p&gt;at this state we have our three new users created&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi8bxtqfuq3x4y7s8ifjh.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi8bxtqfuq3x4y7s8ifjh.PNG" alt=" " width="800" height="319"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now let us give each users role to what they can do so we dont just give them unnecessary power they do not need to have, for protections and securities reasons&lt;/p&gt;

&lt;p&gt;for us to give them role we would go our resource group we created ealier, by typing resource group in the search bar and click on jake-projects-rg we created ealier&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwvetvo90nkn4u9qychv1.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwvetvo90nkn4u9qychv1.PNG" alt=" " width="800" height="236"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Thank click on access control (IAM)&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx36ptrgc1x17aleb4qwe.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx36ptrgc1x17aleb4qwe.PNG" alt=" " width="800" height="550"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We would click on add -&amp;gt; assign role&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6g289k7p7qaiz6tmkc7j.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6g289k7p7qaiz6tmkc7j.PNG" alt=" " width="800" height="573"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;let us start assigning role to our dev-user first, since we want to give contributor access control to our dev-user we would role we wouls click on priviledge administrator and search for contributor&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdf3s2lzt8yvu77k398ij.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdf3s2lzt8yvu77k398ij.PNG" alt=" " width="800" height="295"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click on the next button &lt;br&gt;
click on User, group, or service principal at the select access to&lt;br&gt;
on the members click on select member in order to add we we want to give this permision to which is our dev-user&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv0qmg1k4zmvm69oyxkit.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv0qmg1k4zmvm69oyxkit.PNG" alt=" " width="800" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;now our dev-user have been selected &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffyu12pvdd3qe3i4irvv0.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffyu12pvdd3qe3i4irvv0.PNG" alt=" " width="800" height="401"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Next click on review and assign&lt;br&gt;
we have succesfully given role based accessed control to our dev-user&lt;/p&gt;

&lt;p&gt;now let us do same thing to our remaining 2 users we want to give read access to &lt;/p&gt;

&lt;p&gt;we would do the same thing as above &lt;br&gt;
we would click on add, add role assignment &lt;br&gt;
since the access we want to give to both users are read access only we would click on the job function and select the reader access this restrict the user from creating resources or accidentally deleting the resources but can only view&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl897mca8dz0qak8fyvhr.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl897mca8dz0qak8fyvhr.PNG" alt=" " width="800" height="374"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We would click on the add member and select the member we want to assign this role to in our case is the tester-user, and the auditor-user&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk17ksm8w41i6o9de0dro.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk17ksm8w41i6o9de0dro.PNG" alt=" " width="800" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;we would click on review and assign to complete this &lt;br&gt;
we have succesfully created and assign reader role to both users&lt;/p&gt;

&lt;p&gt;When we click on role assigment inside the access control (IAM) in our resource group we can see we have succesfully &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa8xic5p9hayw4zxm9qop.PNG" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa8xic5p9hayw4zxm9qop.PNG" alt=" " width="569" height="393"&gt;&lt;/a&gt;&lt;br&gt;
now this users can now sign in to their created accounts and do only what they are gicen power to do&lt;/p&gt;

&lt;p&gt;The Dev-user can create resources, modify resources work on resources but  delete resources and delete the resource group, mind you if the dev user mistakenly delete the resource group it can not be undone&lt;/p&gt;

&lt;p&gt;auditor user and tester user can only read but can not create resources can not modify resources and can not delete resources&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security Improvement&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;During testing, I observed that a user with the Contributor role could delete the entire Resource Group. To improve protection against accidental or unauthorized deletion, Azure Resource Locks can be implemented to restrict deletion operations even for users with elevated resource permissions.&lt;/p&gt;

</description>
      <category>azure</category>
      <category>cloud</category>
      <category>security</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
