DEV Community

Cover image for Building Enterprise Azure Infrastructure With Terraform and Remote State
Ipadeola Taiwo
Ipadeola Taiwo

Posted on

Building Enterprise Azure Infrastructure With Terraform and Remote State

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.

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.

The plan

Before touching any code, I sketched out what I was building.

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.

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.

Setting up the backend manually

I already had the Azure CLI installed and was logged in, which I confirmed with:

az account show
Enter fullscreen mode Exit fullscreen mode

Then I created the resource group for the backend:

az group create --name big8-rg --location eastus
Enter fullscreen mode Exit fullscreen mode

That one worked first try. The storage account did not.

az storage account create --name big8-backend-store --resource-group big8-rg --location eastus --sku standard_LRS --kind storageV2
Enter fullscreen mode Exit fullscreen mode

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 big8backendstore, and it went through.

Last piece of the manual setup, the blob container that would actually hold the state file:

az storage container create --name bigcontainer --account-name big8backendstore --auth-mode login
Enter fullscreen mode Exit fullscreen mode

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.

Writing the Terraform config

I set up a providers.tf file with the provider block and the backend block pointing at what I'd just created by hand.


terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }

  backend "azurerm" {
    resource_group_name  = "big8-rg"
    storage_account_name = "big8backendstore"
    container_name        = "bigcontainer"
    key                    = "pro/big8store.tfstate"
  }
}

provider "azurerm" {
  features {}
}
Enter fullscreen mode Exit fullscreen mode

That key 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 dev.tfstate and prod.tfstate, or networking/vnet.tfstate and compute/vm.tfstate, 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.

Then I ran:

terraform init
Enter fullscreen mode Exit fullscreen mode

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 big8store.tfstate sitting exactly where I told it to go.

The part that finally made variables click

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

What clicked for me eventually is that it's just name matching. If variables.tf declares a variable called location, and terraform.tfvars has a line location = "East US", Terraform pairs them up because the names match exactly. Nothing more clever than that. Then anywhere in main.tf you write var.location, Terraform swaps in the value before it ever talks to Azure. Run terraform plan and what you're looking at is that swap already done, the fully resolved version of your config.

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 local.tags everywhere.


locals {
  tags = {
    Project     = var.project_name
    Environment = var.environment
    Owner       = var.admin_username
  }
}
Enter fullscreen mode Exit fullscreen mode

I actually mixed this up once, declared tags 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.

Writing main.tf

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.

Two mistakes came up here worth mentioning. First, my NSG rules were written with destination_address_prefixes (plural) set to a single string, "*". Terraform wants a list for the plural version. The fix was switching to destination_address_prefix (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 .ssh folder and correcting the path with pathexpand("~/.ssh/id_ed25519.pub") sorted that out.

After both fixes, terraform validate finally came back clean.

The plan, and a real deploy error

terraform plan 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.

terraform apply got nine of those ten up without issue. The tenth, the VM itself, failed:

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.
Enter fullscreen mode Exit fullscreen mode

I'd written the sku as 22_04_lts, with underscores. Running a quick image list against Azure showed the real current sku uses a hyphen instead, 22_04-lts. 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.

Getting onto the box and putting something real on it

With a public IP in hand, I connected over SSH:

ssh <admin_username>@<public-ip>
Enter fullscreen mode Exit fullscreen mode

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.

Then I copied a small static site over with scp, moved it into /var/www/html, and reloaded the page.

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.

Proving the state lock actually works

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.

In one terminal, I kicked off terraform apply.

While that was still running, I switched to the second terminal and ran terraform plan.


Error: Error acquiring the state lock
Error message: state blob is already locked
Lock Info:
  Operation: OperationTypeApply
Enter fullscreen mode Exit fullscreen mode

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.

Cleaning up

Once I was done, tearing it all down was just:

terraform destroy
Enter fullscreen mode Exit fullscreen mode

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.

The backend pieces were created by hand, so they had to go the same way:

az group delete --name big8-rg
Enter fullscreen mode Exit fullscreen mode

A quick az group list afterward confirmed only Azure's own auto-created NetworkWatcherRG was left. Clean slate.

What actually stuck with me

Reading Terraform state as a genuine record of reality, not just a lock file, changed how I think about every command. plan isn't guessing, it's comparing your code against a document that describes what's really out there. apply 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.

If you want to follow along with the actual code, it's all here:

https://github.com/highpee1991/terraform-IAC

If you're working through something similar and get stuck, drop a comment, happy to help however I can.

Top comments (0)