Skip to content

Terraform — Recap

What it is

Infrastructure as code tool. You declare what infrastructure you want in HCL config files. Terraform reads them, calls the cloud provider API, and makes it exist. Stateful — tracks what it created so subsequent runs only touch what changed.

Declarative, not imperative. You describe the end state, Terraform figures out how to get there. Contrast with bash scripts which are step by step with no state tracking.


Where it runs

Your local machine. One binary, nothing on your VMs, nothing in your Swarm. It calls Azure APIs the same way az CLI does.

Your laptop
  └── terraform apply
        └── calls Azure API
              └── Azure creates VM, VNet, NSG etc

Installation

# Windows
winget install Hashicorp.Terraform

# verify
terraform version

Authentication to Azure

az login

Terraform picks up your existing Azure CLI credentials automatically. No extra config.


The core commands

terraform init      # download azurerm provider — run once per project
terraform plan      # show what will be created/changed/destroyed — your diff
terraform apply     # actually do it, asks for confirmation
terraform destroy   # tear everything down
terraform output    # show output values after apply

Project structure

terraform-repo/
  main.tf           # resources
  variables.tf      # input variables
  outputs.tf        # values printed after apply
  envs/
    dev.tfvars      # variable values for dev
    prod.tfvars     # variable values for prod

The two providers needed for your setup

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 3.0"
    }
    azuread = {
      source  = "hashicorp/azuread"
      version = "~> 2.0"
    }
  }
}

azurerm — manages Azure infrastructure (VMs, VNet, NSG, Blob Storage, Key Vault, Functions) azuread — manages Azure AD / Entra ID (Service Principal). Lives at tenant level, not inside a resource group.


Resource vs data source

# resource — Terraform creates and manages this
resource "azurerm_resource_group" "main" {
  name     = "learn_week_1"
  location = "South India"
}

# data — this already exists, just read it, don't touch it
data "azurerm_resource_group" "main" {
  name = "learn_week_1"
}

Use data for things that exist outside Terraform that you don't want Terraform to own.


Variables and environment separation

variables.tf

variable "environment" {}
variable "location" {}
variable "resource_group" {}
variable "vm_size" {}

envs/dev.tfvars

environment    = "dev"
location       = "South India"
resource_group = "dev-rg"
vm_size        = "Standard_B1s"

envs/prod.tfvars

environment    = "prod"
location       = "South India"
resource_group = "learn_week_1"
vm_size        = "Standard_B2s"

Run for a specific environment:

terraform apply -var-file="envs/dev.tfvars"
terraform apply -var-file="envs/prod.tfvars"

Use workspaces to keep state isolated per environment:

terraform workspace new dev
terraform workspace new prod
terraform workspace select dev
terraform apply -var-file="envs/dev.tfvars"

State file

After apply, Terraform writes terraform.tfstate. This is how it knows what it created. Never delete it, never commit it to Git. For team setups store it in Azure Blob Storage with state locking.

If you declare a resource that already exists in Azure but not in state, Terraform tries to create it and Azure throws an error. Fix: import it.

terraform import azurerm_resource_group.main /subscriptions/<sub-id>/resourceGroups/learn_week_1

What Terraform provisions in your setup

# Infrastructure
azurerm_resource_group
azurerm_virtual_network           # aver-vnet, 10.1.0.0/16
azurerm_subnet                    # default, 10.1.0.0/24
azurerm_network_security_group    # SSH, Swarm/2377, API/8080, VXLAN/4789
azurerm_public_ip                 # manager and worker
azurerm_network_interface         # manager and worker
azurerm_linux_virtual_machine     # aver-manager, aver-worker (Standard_B1s)

# Azure services
azurerm_storage_account           # averblobstore
azurerm_storage_container         # containers inside it
azurerm_key_vault                 # peppol-vault
azurerm_linux_function_app        # image-processor
azurerm_service_plan              # consumption plan

# Identity
azuread_application               # app registration
azuread_service_principal         # SP linked to app
azuread_service_principal_password # client secret
azurerm_role_assignment           # Storage Blob Data Contributor
azurerm_role_assignment           # Key Vault Secrets User

Outputs consumed by Ansible

output "manager_public_ip" {
  value = azurerm_public_ip.manager.ip_address
}

output "worker_public_ip" {
  value = azurerm_public_ip.worker.ip_address
}

output "sp_tenant_id" {
  value     = data.azurerm_client_config.current.tenant_id
  sensitive = true
}

output "sp_client_id" {
  value     = azuread_application.app.application_id
  sensitive = true
}

output "sp_client_secret" {
  value     = azuread_service_principal_password.sp_secret.value
  sensitive = true
}

sensitive = true — Terraform won't print them in terminal. Ansible reads them via terraform output -raw.


What Terraform does NOT do

  • Install Docker on the VMs — that's Ansible
  • Init Swarm — that's Ansible
  • Load Docker secrets — that's Ansible
  • Deploy the stack — that's GitHub Actions

Terraform stops at infrastructure. Private key never appears in Terraform. remote-exec was considered and rejected — that's Ansible's job.


Modular structure for adding nodes

terraform-repo/
  modules/
    swarm-worker/
      main.tf     # VM, NIC, public IP, joins Swarm
  main.tf         # calls modules

Adding a new worker:

module "worker_2" {
  source     = "./modules/swarm-worker"
  name       = "aver-worker-2"
  subnet_id  = azurerm_subnet.default.id
}
terraform apply

One new VM, same spec, same network. Ansible handles Docker install and Swarm join separately.


Full runnable code — complete main.tf for your setup

providers.tf

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 3.0"
    }
    azuread = {
      source  = "hashicorp/azuread"
      version = "~> 2.0"
    }
  }
}

provider "azurerm" {
  features {}
}

provider "azuread" {}

data "azurerm_client_config" "current" {}

variables.tf

variable "environment" {
  default = "prod"
}

variable "location" {
  default = "South India"
}

variable "resource_group" {
  default = "learn_week_1"
}

variable "vm_size" {
  default = "Standard_B1s"
}

variable "ssh_public_key_path" {
  default = "~/.ssh/id_rsa.pub"
}

variable "admin_username" {
  default = "azureuser"
}

main.tf

# ── Resource Group ────────────────────────────────────────
# Use data if already exists, resource if Terraform should create it
data "azurerm_resource_group" "main" {
  name = var.resource_group
}

# ── Networking ────────────────────────────────────────────
resource "azurerm_virtual_network" "main" {
  name                = "aver-vnet"
  address_space       = ["10.1.0.0/16"]
  location            = var.location
  resource_group_name = data.azurerm_resource_group.main.name
}

resource "azurerm_subnet" "default" {
  name                 = "default"
  resource_group_name  = data.azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.main.name
  address_prefixes     = ["10.1.0.0/24"]
}

resource "azurerm_network_security_group" "main" {
  name                = "aver-nsg"
  location            = var.location
  resource_group_name = data.azurerm_resource_group.main.name

  security_rule {
    name                       = "SSH"
    priority                   = 100
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "22"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }

  security_rule {
    name                       = "Swarm"
    priority                   = 110
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "2377"
    source_address_prefix      = "10.1.0.0/24"
    destination_address_prefix = "*"
  }

  security_rule {
    name                       = "API"
    priority                   = 120
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "8080"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }

  security_rule {
    name                       = "VXLAN"
    priority                   = 130
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Udp"
    source_port_range          = "*"
    destination_port_range     = "4789"
    source_address_prefix      = "10.1.0.0/24"
    destination_address_prefix = "*"
  }

  security_rule {
    name                       = "Seq"
    priority                   = 140
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "8081"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }
}

resource "azurerm_subnet_network_security_group_association" "main" {
  subnet_id                 = azurerm_subnet.default.id
  network_security_group_id = azurerm_network_security_group.main.id
}

# ── Public IPs ────────────────────────────────────────────
resource "azurerm_public_ip" "manager" {
  name                = "aver-manager-pip"
  location            = var.location
  resource_group_name = data.azurerm_resource_group.main.name
  allocation_method   = "Static"
}

resource "azurerm_public_ip" "worker" {
  name                = "aver-worker-pip"
  location            = var.location
  resource_group_name = data.azurerm_resource_group.main.name
  allocation_method   = "Static"
}

# ── NICs ──────────────────────────────────────────────────
resource "azurerm_network_interface" "manager" {
  name                = "aver-manager-nic"
  location            = var.location
  resource_group_name = data.azurerm_resource_group.main.name

  ip_configuration {
    name                          = "internal"
    subnet_id                     = azurerm_subnet.default.id
    private_ip_address_allocation = "Dynamic"
    public_ip_address_id          = azurerm_public_ip.manager.id
  }
}

resource "azurerm_network_interface" "worker" {
  name                = "aver-worker-nic"
  location            = var.location
  resource_group_name = data.azurerm_resource_group.main.name

  ip_configuration {
    name                          = "internal"
    subnet_id                     = azurerm_subnet.default.id
    private_ip_address_allocation = "Dynamic"
    public_ip_address_id          = azurerm_public_ip.worker.id
  }
}

# ── VMs ───────────────────────────────────────────────────
resource "azurerm_linux_virtual_machine" "manager" {
  name                = "aver-manager"
  location            = var.location
  resource_group_name = data.azurerm_resource_group.main.name
  size                = var.vm_size
  admin_username      = var.admin_username

  network_interface_ids = [azurerm_network_interface.manager.id]

  admin_ssh_key {
    username   = var.admin_username
    public_key = file(var.ssh_public_key_path)
  }

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Standard_LRS"
  }

  source_image_reference {
    publisher = "Canonical"
    offer     = "UbuntuServer"
    sku       = "24_04-lts"
    version   = "latest"
  }
}

resource "azurerm_linux_virtual_machine" "worker" {
  name                = "aver-worker"
  location            = var.location
  resource_group_name = data.azurerm_resource_group.main.name
  size                = var.vm_size
  admin_username      = var.admin_username

  network_interface_ids = [azurerm_network_interface.worker.id]

  admin_ssh_key {
    username   = var.admin_username
    public_key = file(var.ssh_public_key_path)
  }

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Standard_LRS"
  }

  source_image_reference {
    publisher = "Canonical"
    offer     = "UbuntuServer"
    sku       = "24_04-lts"
    version   = "latest"
  }
}

# ── Blob Storage ──────────────────────────────────────────
resource "azurerm_storage_account" "main" {
  name                     = "averblobstore"
  resource_group_name      = data.azurerm_resource_group.main.name
  location                 = var.location
  account_tier             = "Standard"
  account_replication_type = "LRS"
}

resource "azurerm_storage_container" "invoices" {
  name                  = "invoices"
  storage_account_name  = azurerm_storage_account.main.name
  container_access_type = "private"
}

resource "azurerm_storage_container" "originals" {
  name                  = "originals"
  storage_account_name  = azurerm_storage_account.main.name
  container_access_type = "private"
}

# ── Key Vault ─────────────────────────────────────────────
resource "azurerm_key_vault" "main" {
  name                = "peppol-vault"
  location            = var.location
  resource_group_name = data.azurerm_resource_group.main.name
  tenant_id           = data.azurerm_client_config.current.tenant_id
  sku_name            = "standard"
}

# ── Service Principal ─────────────────────────────────────
resource "azuread_application" "app" {
  display_name = "aver-app"
}

resource "azuread_service_principal" "sp" {
  application_id = azuread_application.app.application_id
}

resource "azuread_service_principal_password" "sp_secret" {
  service_principal_id = azuread_service_principal.sp.id
}

# ── Role Assignments ──────────────────────────────────────
resource "azurerm_role_assignment" "sp_blob" {
  scope                = data.azurerm_resource_group.main.id
  role_definition_name = "Storage Blob Data Contributor"
  principal_id         = azuread_service_principal.sp.id
}

resource "azurerm_role_assignment" "sp_keyvault" {
  scope                = data.azurerm_resource_group.main.id
  role_definition_name = "Key Vault Secrets User"
  principal_id         = azuread_service_principal.sp.id
}

outputs.tf

output "manager_public_ip" {
  value = azurerm_public_ip.manager.ip_address
}

output "worker_public_ip" {
  value = azurerm_public_ip.worker.ip_address
}

output "manager_private_ip" {
  value = azurerm_network_interface.manager.private_ip_address
}

output "sp_tenant_id" {
  value     = data.azurerm_client_config.current.tenant_id
  sensitive = true
}

output "sp_client_id" {
  value     = azuread_application.app.application_id
  sensitive = true
}

output "sp_client_secret" {
  value     = azuread_service_principal_password.sp_secret.value
  sensitive = true
}

Run sequence

az login
terraform init
terraform plan
terraform apply
terraform output manager_public_ip    # hand this to Ansible

Interview one liner

"I use Terraform to codify the Azure infrastructure layer — VMs, VNet, NSG, Blob Storage, Key Vault, Functions, and Service Principal creation with role assignments. Declarative, stateful, reproducible. Ansible handles post-provisioning configuration. Terraform stops at infrastructure."