м. Київ, вул. Кирилівська 102

14 вересня 2026 р.

Terraform для Azure

Terraform для Azure

Terraform перетворює налаштування Azure з послідовності ручних кліків у відтворюваний код. Замість того щоб щоразу створювати resource group, virtual network, subnet, network security group і virtual machine через Portal, команда описує бажаний стан у HCL, переглядає план і лише після перевірки дозволяє зміни. Це спрощує повторне розгортання середовищ, code review, аудит і пошук конфігураційного drift.

У цій інструкції ми зберемо практичний Terraform-проєкт для Azure: налаштуємо AzureRM provider, безпечну автентифікацію, remote state в Azure Blob Storage, мережу та Linux VM. Наприкінці розберемо plan/apply workflow, імпорт уже наявних ресурсів і типові помилки. Приклад навмисно компактний, але його структура придатна для подальшого поділу на модулі та окремі середовища.

Terraform не робить невдалу архітектуру безпечною. Помилка в CIDR, RBAC або lifecycle може відкрити сервіс назовні, замінити VM чи видалити ресурс із даними. Перед автоматизацією варто визначити subscriptions, management groups, naming, tagging, мережеві межі та правила доступу. Якщо ви вже працювали з Terraform для AWS, загальний цикл залишиться знайомим, але Azure має власну модель subscriptions, resource groups, Microsoft Entra ID та RBAC.

Підготовка Terraform-проєкту та AzureRM provider

Встановіть Terraform із офіційного репозиторію HashiCorp, Azure CLI — з репозиторію Microsoft, а Git — із пакетів вашої ОС. Не копіюйте випадковий binary і не фіксуйте версію лише тому, що вона трапилася в старій інструкції. Перевірте встановлені інструменти:

bash
terraform version
az version
git --version

Створіть каталог проєкту та базові файли:

bash
mkdir terraform-azure-demo
cd terraform-azure-demo

touch versions.tf providers.tf variables.tf main.tf outputs.tf
touch backend.hcl terraform.tfvars.example .gitignore

У `.gitignore` виключіть локальний state, plan-файли та файли зі значеннями змінних:

gitignore
.terraform/
*.tfstate
*.tfstate.*
*.tfplan
crash.log
crash.*.log
terraform.tfvars
*.auto.tfvars
*.auto.tfvars.json

Файл `.terraform.lock.hcl` не ігноруйте: його потрібно commit у Git, щоб локальне середовище й CI завантажували ту саму перевірену версію provider. У `versions.tf` задайте підтримуваний діапазон Terraform та AzureRM. Constraint `~> 5.0` допускає сумісні оновлення в межах major-версії, але не виконає автоматичний перехід на наступний major:

hcl
terraform {
  required_version = ">= 1.10, < 2.0"

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

Перед оновленням provider переглядайте release notes і запускайте plan у тестовій subscription. Команда `terraform init -upgrade` змінює lock-файл, тому її результат має проходити звичайний review.

У `providers.tf` достатньо ввімкнути AzureRM features і явно передати subscription. Явний `subscription_id` зменшує ризик випадкового apply не в тому контурі:

hcl
provider "azurerm" {
  features {}

  subscription_id = var.subscription_id
}

Додайте в `variables.tf` керовані параметри та перевірку назви середовища:

hcl
variable "subscription_id" {
  description = "Azure subscription ID for this stack"
  type        = string
}

variable "location" {
  description = "Azure region for resources"
  type        = string
  default     = "westeurope"
}

variable "project_name" {
  type    = string
  default = "web-platform"
}

variable "environment" {
  type    = string
  default = "dev"

  validation {
    condition     = contains(["dev", "stage", "prod"], var.environment)
    error_message = "environment must be dev, stage or prod"
  }
}

variable "admin_cidr" {
  description = "Trusted IPv4 CIDR allowed to reach SSH"
  type        = string
}

variable "ssh_public_key_path" {
  description = "Path to an existing SSH public key"
  type        = string
  default     = "~/.ssh/id_ed25519.pub"
}

Локальний `terraform.tfvars` не додавайте в Git:

hcl
subscription_id = "00000000-0000-0000-0000-000000000000"
admin_cidr       = "203.0.113.10/32"

Документаційні значення потрібно замінити власними. Для production адміністративний порт краще не відкривати в Internet узагалі: використовуйте Azure Bastion, VPN, private connectivity або керований операційний канал.

Автентифікація без секретів у Terraform-коді

Для локальної роботи увійдіть через Azure CLI і відразу перевірте активну subscription:

bash
az login
az account list --output table
az account set --subscription "00000000-0000-0000-0000-000000000000"
az account show --query '{name:name,id:id,tenantId:tenantId}' --output table

Terraform може використати контекст Azure CLI. Не записуйте client secret у `.tf`, `terraform.tfvars`, backend configuration або shell history. State і saved plan можуть містити чутливі дані навіть тоді, коли output позначений як `sensitive`.

Для CI не використовуйте персональний `az login` або довгостроковий пароль service principal. Краща модель — workload identity federation через OpenID Connect: CI-платформа отримує короткоживучий токен, Microsoft Entra ID перевіряє issuer, subject та audience, після чого Azure видає тимчасовий доступ. Призначайте identity мінімальну роль на конкретну resource group або subscription лише тоді, коли stack справді керує всім її вмістом.

Змінні середовища для автоматизованого запуску мають надходити із захищеного CI context:

bash
export ARM_USE_OIDC=true
export ARM_USE_AZUREAD=true
export ARM_CLIENT_ID="00000000-0000-0000-0000-000000000000"
export ARM_TENANT_ID="00000000-0000-0000-0000-000000000000"
export ARM_SUBSCRIPTION_ID="00000000-0000-0000-0000-000000000000"

Саме значення не слід друкувати в job log. Розділяйте identity для plan і apply, захищайте production environment ручним approval та не видавайте роль Owner, якщо достатньо Contributor або ще вужчої custom role. Якісний CI/CD-процес також серіалізує apply для одного state і зберігає план лише протягом короткого контрольованого вікна.

Remote state в Azure Blob Storage

Локальний `terraform.tfstate` непридатний для спільної роботи: він легко губиться, не має централізованого контролю доступу і створює ризик паралельних apply. Backend `azurerm` зберігає state як blob та підтримує locking через можливості Azure Blob Storage.

Storage account для backend створюйте окремим bootstrap-процесом. Не керуйте ним із того самого state, який лежить у цьому account: destroy root stack тоді може забрати власне сховище стану. Приклад bootstrap через Azure CLI:

bash
az group create \
  --name rg-terraform-state \
  --location westeurope

az storage account create \
  --name sttfstateexample001 \
  --resource-group rg-terraform-state \
  --location westeurope \
  --sku Standard_LRS \
  --kind StorageV2 \
  --min-tls-version TLS1_2 \
  --allow-blob-public-access false

az storage container create \
  --name tfstate \
  --account-name sttfstateexample001 \
  --auth-mode login

Назва storage account має бути глобально унікальною, складатися з дозволених Azure символів і не містити секретної інформації. Увімкніть blob versioning, soft delete, diagnostic logs і заборону public access відповідно до внутрішньої політики. Доступ до data plane надавайте через Microsoft Entra ID, наприклад роллю Storage Blob Data Contributor лише для automation identity та відповідальних інженерів.

Додайте backend block у `versions.tf`:

hcl
terraform {
  backend "azurerm" {
    use_azuread_auth = true
  }
}

Несекретні координати backend збережіть у `backend.hcl`:

hcl
resource_group_name  = "rg-terraform-state"
storage_account_name = "sttfstateexample001"
container_name       = "tfstate"
key                  = "web-platform/dev/terraform.tfstate"

Ініціалізуйте каталог і перевірте providers:

bash
terraform init -backend-config=backend.hcl
terraform providers

Не передавайте access key або SAS token у командному рядку: аргумент може залишитися в history, process list, `.terraform` або plan artifacts. Для одного environment використовуйте один стабільний backend key; для dev, stage і prod — різні keys, identities та бажано окремі subscriptions. Remote state зменшує ризик, але не є резервною копією сам по собі, тому recovery попередньої blob version потрібно документувати й тестувати.

Resource group, мережа та Linux VM

У `main.tf` сформуйте єдині tags, resource group і мережу. Діапазони адрес плануйте до створення peering або VPN: зміна address space з конфліктами часто потребує складної міграції.

hcl
locals {
  name_prefix = "${var.project_name}-${var.environment}"

  common_tags = {
    Project     = var.project_name
    Environment = var.environment
    ManagedBy   = "Terraform"
  }
}

resource "azurerm_resource_group" "main" {
  name     = "rg-${local.name_prefix}"
  location = var.location
  tags     = local.common_tags
}

resource "azurerm_virtual_network" "main" {
  name                = "vnet-${local.name_prefix}"
  address_space       = ["10.50.0.0/16"]
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  tags                = local.common_tags
}

resource "azurerm_subnet" "app" {
  name                 = "snet-app"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.main.name
  address_prefixes     = ["10.50.10.0/24"]
}

Для демонстрації створимо public IP і network security group із доступом до SSH лише з одного довіреного CIDR. Правило `0.0.0.0/0` для TCP/22 не використовуйте навіть тимчасово: воно швидко привертає автоматизоване сканування.

hcl
resource "azurerm_public_ip" "vm" {
  name                = "pip-${local.name_prefix}-01"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  allocation_method   = "Static"
  sku                 = "Standard"
  tags                = local.common_tags
}

resource "azurerm_network_security_group" "vm" {
  name                = "nsg-${local.name_prefix}-vm"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  tags                = local.common_tags

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

resource "azurerm_network_interface" "vm" {
  name                = "nic-${local.name_prefix}-01"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  tags                = local.common_tags

  ip_configuration {
    name                          = "primary"
    subnet_id                     = azurerm_subnet.app.id
    private_ip_address_allocation = "Dynamic"
    public_ip_address_id          = azurerm_public_ip.vm.id
  }
}

resource "azurerm_network_interface_security_group_association" "vm" {
  network_interface_id      = azurerm_network_interface.vm.id
  network_security_group_id = azurerm_network_security_group.vm.id
}

Тепер додайте Linux VM. Пароль не потрібен: вхід дозволений лише за SSH public key. Значення SKU та образу перевіряйте для обраного регіону, а перед apply перегляньте актуальну ціну й quota.

hcl
resource "azurerm_linux_virtual_machine" "vm" {
  name                = "vm-${local.name_prefix}-01"
  resource_group_name = azurerm_resource_group.main.name
  location            = azurerm_resource_group.main.location
  size                = "Standard_B2s"
  admin_username      = "azureadmin"

  network_interface_ids = [
    azurerm_network_interface.vm.id
  ]

  disable_password_authentication = true

  admin_ssh_key {
    username   = "azureadmin"
    public_key = file(pathexpand(var.ssh_public_key_path))
  }

  os_disk {
    name                 = "osdisk-${local.name_prefix}-01"
    caching              = "ReadWrite"
    storage_account_type = "StandardSSD_LRS"
  }

  source_image_reference {
    publisher = "Canonical"
    offer     = "0001-com-ubuntu-server-jammy"
    sku       = "22_04-lts-gen2"
    version   = "latest"
  }

  identity {
    type = "SystemAssigned"
  }

  tags = local.common_tags
}

System-assigned managed identity сама по собі не має прав. Додавайте RBAC assignment лише до конкретного ресурсу, який VM повинна читати або змінювати. Не видавайте Contributor на всю subscription «про запас».

В `outputs.tf` залиште тільки потрібні операційні значення:

hcl
output "resource_group_name" {
  value = azurerm_resource_group.main.name
}

output "vm_id" {
  value = azurerm_linux_virtual_machine.vm.id
}

output "public_ip_address" {
  value = azurerm_public_ip.vm.ip_address
}

Для production-платформи одного public VM недостатньо. Зазвичай потрібні private subnets, zone redundancy, load balancer або Application Gateway, backup, monitoring, patching, Key Vault, private endpoints і контроль egress. Terraform описує ресурси, але не замінює операційне проєктування та DevOps-супровід.

Plan, apply, drift та імпорт ресурсів

Після створення файлів відформатуйте й перевірте конфігурацію:

bash
terraform fmt -check -recursive
terraform init -backend-config=backend.hcl
terraform validate
terraform plan -out=dev.tfplan
terraform show dev.tfplan

Прочитайте plan повністю, а не лише останній рядок. Особливу увагу зверніть на `replace`, `destroy`, зміни CIDR, public IP, NSG rules, disk та identity. Застосовуйте саме переглянутий saved plan:

bash
terraform apply dev.tfplan
terraform output

Saved plan може містити sensitive values, тому його не можна публікувати або зберігати як довгоживучий artifact. Якщо після plan змінився HCL, provider lock, credentials чи зовнішня інфраструктура, побудуйте новий plan і проведіть review повторно.

Для пошуку ручних змін використовуйте read-only plan з окремою обробкою exit code:

bash
terraform plan -detailed-exitcode

Код `0` означає відсутність diff, `2` — наявність змін, `1` — помилку. У CI код `2` не слід бездумно трактувати як технічний збій. Знайдений drift спочатку класифікують: аварійна ручна зміна може бути правильною, а автоматичний apply здатен її скасувати.

Наявний Azure resource можна прив’язати до Terraform через import block. Спочатку опишіть ресурс у HCL, потім додайте його повний Azure resource ID:

hcl
import {
  to = azurerm_resource_group.main
  id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-web-platform-dev"
}

Після `terraform plan` вирівняйте аргументи так, щоб import не призвів до несподіваної заміни чи видалення. Import створює binding у state, але не доводить конфігурацію до правильного стану автоматично. Після успішного apply import block можна видалити окремою reviewed зміною.

Для контрольованого прибирання навчального середовища створіть destroy plan:

bash
terraform plan -destroy -out=destroy.tfplan
terraform show destroy.tfplan
terraform apply destroy.tfplan

Не запускайте `terraform destroy -auto-approve` у спільній subscription. Ресурси з даними повинні мати backup, retention, locks і перевірену процедуру відновлення. Azure resource lock може навмисно зупинити Terraform destroy; знімати його автоматично без окремого approval небезпечно.

Типові помилки

Перша помилка — секрети у provider, backend, `.tfvars` або Git history. Якщо credential уже опинився в репозиторії, видалення рядка недостатньо: відкличте секрет, перевірте sign-in та audit logs, оцініть виконані операції й лише потім очищуйте історію за погодженою процедурою.

Друга — одна identity з Owner для всіх subscriptions. Розділяйте права plan і apply, середовища та stacks. Використовуйте OIDC або managed identity, вузький scope й мінімальну RBAC role. Пам’ятайте, що management-plane роль і data-plane доступ до Blob Storage — різні дозволи.

Третя — один state для dev, stage і prod. Такий state збільшує blast radius, сповільнює plan і змушує automation identity мати зайві права. Розділяйте backend keys, а критичні платформи — ще й subscriptions.

Четверта — відкритий NSG, випадкова subscription або неправильний tenant. Перед plan перевіряйте `az account show`, передавайте `subscription_id` явно, обмежуйте CIDR і додавайте policy checks. Автоматизація однаково швидко відтворює правильну конфігурацію та небезпечну помилку.

П’ята — ручне редагування state або примусове розблокування без перевірки. Для state operations використовуйте Terraform CLI, зберігайте versioning і спочатку переконайтеся, що попередній apply справді завершився. Два паралельні pipeline для одного key мають бути заборонені на рівні CI, навіть якщо backend підтримує locking.

Висновок

Надійний Terraform-проєкт для Azure починається не з Linux VM, а з правильних меж: окрема subscription або resource group, мінімальні RBAC-права, короткоживуча identity, віддалений state та review кожного plan. AzureRM provider описує resource group, virtual network, subnet, NSG, NIC і VM як залежний граф, а Terraform створює їх у потрібній послідовності.

Практичний цикл залишається простим: `fmt`, `validate`, `plan`, уважний review і `apply` того самого saved plan. Далі конфігурацію можна розділити на network, compute та data stacks, винести повторювані частини в modules і додати policy scanning. Не поєднуйте всі середовища в один state та не підміняйте code review автоматичним approval.

Починайте з ізольованої dev subscription, перевіряйте tenant і subscription перед кожною зміною, контролюйте витрати та регулярно шукайте drift. Terraform дає відтворюваність, але безпека з’являється лише тоді, коли credentials короткоживучі, доступ мінімальний, state захищений, а видалення й відновлення перевірені заздалегідь.

📞Terraform для Azure — мережа, Linux VM та remote state | ITheal