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."