This was my first real hands-on Terraform assignment: deploy a working Azure VM, wire up a remote backend for the state file, and document the whole process. On paper it looked like a weekend task. It took me close to three days, mostly because I kept running into errors I'd never seen before and had to actually understand each one instead of working around it.
This post walks through what I built, the order I built it in, the four errors that stopped me at different points, how I got past each one, and the proof that it all worked in the end.
Repo: github.com/4thman/cloud-cohort
What I built
At a glance, the stack looks like this:
Internet
│
▼
Public IP (cohort-pip)
│
▼
NIC (cohort-nic) ──secured by── NSG (cohort-nsg: allow 22, allow 80)
│
▼
VM: cohort-vm (Ubuntu 18.04 LTS, Standard_B1s)
│
▼
Subnet (cohort-subnet, 10.0.0.0/24)
│
▼
VNet (cohort-vnet, 10.0.0.0/16)
│
▼
Resource Group: cohort-rg (East US)
Everything above lives in Terraform. The state file for all of it lives in an Azure Storage Account, not on my laptop. That was the part of the brief I hadn't done before, and it ended up being the most useful thing I learned.
Setting the stage
Nothing complicated here, just a folder and an editor.
cd Downloads
mkdir cloud-cohort
cd cloud-cohort
code .
First file: main.tf, empty.
Wiring up the provider
Every Terraform project starts with telling it which provider to talk to and which version to pin. I went with azurerm version ~> 5.4.0 since am working on Azure Platform.
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.4.0"
}
}
}
provider "azurerm" {
features {}
subscription_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
Then the command that downloads the provider plugin and sets everything up:
terraform init
Building the network layer
I built this resource by resource, running terraform plan after almost every block just to see what was about to happen.
# Create a resource group
resource "azurerm_resource_group" "cohort-rg" {
name = "cohort-rg"
location = "East US"
}
# Create a virtual network
resource "azurerm_virtual_network" "cohort-vnet" {
name = "cohort-vnet"
address_space = ["10.0.0.0/16"]
location = azurerm_resource_group.cohort-rg.location
resource_group_name = azurerm_resource_group.cohort-rg.name
}
# Create a subnet
resource "azurerm_subnet" "cohort-subnet" {
name = "cohort-subnet"
resource_group_name = azurerm_resource_group.cohort-rg.name
virtual_network_name = azurerm_virtual_network.cohort-vnet.name
address_prefixes = ["10.0.0.0/24"]
}
Locking it down with an NSG
I configured a Network Security Group (NSG) to control inbound traffic. I added two essential rules: SSH (port 22) to allow secure remote administration and HTTP (port 80) to enable web-server access for testing and demonstration purposes.
# Create a network security group & NSG rules
resource "azurerm_network_security_group" "cohort-nsg" {
name = "cohort-nsg"
location = azurerm_resource_group.cohort-rg.location
resource_group_name = azurerm_resource_group.cohort-rg.name
security_rule {
name = "AllowSSH"
priority = 1000
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = "*"
destination_address_prefix = "*"
}
security_rule {
name = "AllowHTTP"
priority = 1001
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "80"
source_address_prefix = "*"
destination_address_prefix = "*"
}
}
The NIC and a formatting habit
The network interface is what actually connects the VM to the subnet and, through the public IP, to the internet. I also started running terraform fmt before every plan around here. It costs nothing, and it catches the indentation mess before it becomes a diff you have to think about.
# Create a network interface
resource "azurerm_network_interface" "cohort-nic" {
name = "cohort-nic"
location = azurerm_resource_group.cohort-rg.location
resource_group_name = azurerm_resource_group.cohort-rg.name
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.cohort-subnet.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.cohort-pip.id
}
}
# Associate the NIC with the NSG
resource "azurerm_network_interface_security_group_association" "cohort-nic-nsg" {
network_interface_id = azurerm_network_interface.cohort-nic.id
network_security_group_id = azurerm_network_security_group.cohort-nsg.id
}
The actual VM
This is the main resource that everything else is built around: an Ubuntu 18.04 LTS virtual machine using the Standard_B1s size and authenticated with an SSH key instead of a password. I chose Ubuntu rather than a Microsoft Windows Server VM because Ubuntu is lightweight, open-source, and commonly used for hosting web servers and working with Linux-based cloud environments. It also provides a straightforward environment for installing and managing web-server software through the command line, making it a suitable choice for this learning project.
# Create a virtual machine
resource "azurerm_linux_virtual_machine" "cohort-vm" {
name = "cohort-vm"
resource_group_name = azurerm_resource_group.cohort-rg.name
location = azurerm_resource_group.cohort-rg.location
size = "Standard_B1s"
admin_username = "cohortuser1"
network_interface_ids = [
azurerm_network_interface.cohort-nic.id,
]
admin_ssh_key {
username = "cohortuser1"
public_key = file("~/.ssh/id_rsa.pub")
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "UbuntuServer"
sku = "18.04-LTS"
version = "latest"
}
}
And two outputs so I don't have to go hunting through the Azure portal every time I need the IP or the username:
Cleaning up: variables and outputs
At this point, everything was hardcoded directly into main.tf. While this works, it makes the configuration harder to maintain, reuse, and modify for different environments. To improve the structure, I moved the repeated and changeable values into variables.tf and placed the two outputs into a separate outputs.tf file.
I created variables.tf to make the Terraform configuration more flexible and reusable. Instead of changing values directly inside main.tf, I can define them as variables and provide different values when needed. This makes it easier to reuse the same configuration for another environment without modifying the main infrastructure code.
I created outputs.tf to keep the information Terraform needs to display after deployment separate from the resource configuration. This makes the project more organised and allows important details, such as the VM's public IP address, to be easily accessed after the infrastructure has been created.
main.tf then got rewritten to reference var.* instead of literal strings:
| Before | After |
|---|---|
location = "East US" |
location = var.location |
name = "cohort-rg" |
name = var.resource_group_name |
address_prefixes = ["10.0.0.0/24"] |
still literal (left this one alone on purpose) |
One small mistake I made was in my variables.tf file. I used the description field to store the default value instead of explaining what the variable is for. For example, I used description = "cohort-rg" instead of description = "Name of the resource group".
This did not cause any problems because Terraform still works with it, but it makes the file less clear and harder to understand later. It is a small mistake, but it is something I would correct to keep the code clean and easy to maintain.
A duplicate-output gotcha
Ran terraform fmt and terraform plan right after the refactor, expecting a clean run. Instead, Terraform stopped me with this:
Error: Duplicate output definition
An output named "vm_username" was already defined at main.tf:134,1-21. Output names must be unique within a module.
Once I looked closer it made sense: I'd created outputs.tf with the two outputs, but never went back and deleted the original output blocks still sitting at the bottom of main.tf. Terraform was looking at two files both trying to define vm_username, and correctly refused to guess which one I meant. Deleted the leftover blocks from main.tf, re-ran plan, and got a clean 8-to-add.
Running terraform apply
ssh_Public_key and subscription_id don't have defaults in variables.tf on purpose. They're marked sensitive, and I didn't want them sitting in a file that might end up on GitHub. Terraform prompts for them at apply time instead.
terraform apply
Configuring the remote backend
This was the part of the brief I'd never actually done before, so it got its own careful pass.
The idea: instead of terraform.tfstate sitting on my machine, where it can get lost, overwritten, or just never seen by anyone else on a team, it lives in an Azure Storage Account, and Terraform reads and writes it there on every command.
First, log in and create the storage account and a container inside it:
az login
az storage account create `
--name cohorttfstate `
--resource-group cohort-rg `
--location eastus `
--sku Standard_LRS
az storage container create `
--name tfstate `
--account-name cohorttfstate `
--auth-mode login
Then tell Terraform where to find it, right inside the terraform {} block:
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.4.0"
}
}
# Backend configuration for storing the Terraform state in Azure Blob Storage
backend "azurerm" {
resource_group_name = "cohort-rg"
storage_account_name = "cohorttfstate"
container_name = "tfstate"
key = "terraform.tfstate"
}
}
Running terraform init again picks up the new backend and asks whether to migrate the state that's currently sitting locally:
From this point forward, every terraform plan reads the state from Azure Blob Storage instead of a local file. This also means the Terraform state file, which contains information such as my resource IDs, is no longer stored on my laptop.
"No changes" here is the whole point: it means the remote state and the real infrastructure agree with each other, from somewhere that isn't tied to my one laptop.
Getting onto the box
Time to prove there's an actual, usable server behind all this config.
ssh cohortuser1@4.157.245.198
Then I tried to update and upgrade in one line and typed & where I meant &&:
sudo apt upgrade & update -y
& backgrounds the first command and immediately tries to run update as its own program, which doesn't exist as a standalone command, hence Command 'update' not found. && chains two commands so the second only runs after the first succeeds: one character, very different behaviour. Ran it properly afterward:
Then installed the two things I actually needed: nginx to prove a web server can be reached from outside, and git to push the code.
sudo apt install nginx && git -y
git --version
And in a browser, hitting the VM's public IP directly:
Proof in the portal
Terraform reported that it successfully created eight resources, and I verified this in the Azure portal, which also shows the same resources. This confirmed that the Terraform configuration was applied successfully and that the infrastructure was created as expected.
Tearing it down
Once everything was verified and screenshotted, I destroyed the whole stack. It's a learning VM, not a production workload, and there's no reason to let a Standard_B1s keep billing for the rest of the month for something I've already proven works.
terraform destroy
Because the state lived remotely, destroying it was as clean as creating it. Terraform read the real state from Blob Storage, worked out exactly what it had made, and took it all back down in one pass.
Getting this into GitHub
This is the section where I learned more about Git than about Terraform.
git init
I tried git branch main before making a single commit, which fails because there's no commit yet for a branch to point at (fatal: not a valid object name 'master'). Staged everything instead:
git commit -m "first commit on terraform cloud cohort from master branch"
Renamed the default branch from master to main to match what GitHub expects by default:
git branch main
git switch main
Created the repo on GitHub, private, with a README ticked on (a decision that comes back to bite me two steps from now):
git remote add origin https://github.com/4thman/cloud-cohort.git
git push -u origin main
The first push got rejected:
remote: error: File .terraform/providers/registry.terraform.io/hashicorp/azurerm/5.4.0/windows_amd64/terraform-provider-azurerm_v5.4.0_x5.exe is 219.00 MB; this exceeds GitHub's file size limit of 100.00 MB
Honestly, I didn't know GitHub even had a 100MB file size limit until this error told me. I'd just run git add . and pushed everything in the folder without thinking about what was actually sitting inside .terraform/. That folder is a local cache of provider plugins, genuinely huge (that one provider binary alone was 219MB), and it's something nobody needs in version control anyway since terraform init rebuilds it from scratch on any machine. So it needed to go in .gitignore instead of getting committed:
.terraform/
Staged it, committed it, and pushed again:
git add .
git commit -m "added .terraform folder to .gitignore due to push error"
git push -u origin main
Rejected a second time, this time with [rejected] main -> main (fetch first), because the README GitHub generated when I created the repo existed as a commit on the remote that my local history knew nothing about. Two histories, no shared ancestor Git could reconcile on its own.
Pulled the remote's history in, merged it with mine, and pushed one more time:
And the repo, live, with all three commits and the actual project files:
Repo: github.com/4thman/cloud-cohort
What I'd do differently
Add .gitignore before the first commit, rather than after the push. When I created the GitHub repository, I left the ADD README option enabled, which meant the local and remote repositories had different histories. I fixed this by running git pull to bring the README into my local repository and merge the changes before pushing my files.
Write proper descriptions in** variables.tf** instead of putting the default value in the description field. I should also add the .tfstate files to .gitignore, not just .terraform/. My state file ended up in the first commit, and although there was nothing sensitive in this particular file, Terraform state files can contain sensitive information and should not normally be committed to GitHub.
None of these broke the deployment. All of them are the kind of thing you only really learn by hitting them once.
If you made it this far, thanks for reading! 🙌 I’d love to hear from you what’s one Terraform, Azure, or Git mistake you’ve made while learning, and what did it teach you? Drop it in the comments; your experience might save someone else a few hours of frustration. If you found this walkthrough useful, like ❤️, comment 💬, and share 🔄 it with someone else learning Terraform or Azure. Let’s learn from the mistakes, not hide them!























































Top comments (3)
Well done David! 👏🏽
Well Documented ✨
Weldone