DEV Community

Cover image for Three Days, Four Errors, One Working Azure VM: Deploying with Terraform and Remote State
David Cletus
David Cletus

Posted on

Three Days, Four Errors, One Working Azure VM: Deploying with Terraform and Remote State

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

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

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

Then the command that downloads the provider plugin and sets everything up:

terraform init
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

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 = "*"
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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


az storage account create `
  --name cohorttfstate `
  --resource-group cohort-rg `
  --location eastus `
  --sku Standard_LRS
Enter fullscreen mode Exit fullscreen mode


az storage container create `
  --name tfstate `
  --account-name cohorttfstate `
  --auth-mode login
Enter fullscreen mode Exit fullscreen mode

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

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

Then I tried to update and upgrade in one line and typed & where I meant &&:

sudo apt upgrade & update -y
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

Renamed the default branch from master to main to match what GitHub expects by default:

git branch main
git switch main
Enter fullscreen mode Exit fullscreen mode

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

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

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

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)

Collapse
 
samuel_kolade profile image
Samuel Kolade

Well done David! 👏🏽

Collapse
 
samuel_kolade profile image
Samuel Kolade

Well Documented ✨

Collapse
 
phavorite profile image
Favour

Weldone