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

8 вересня 2026 р.

Terraform для AWS

Terraform для AWS

Створити EC2 instance через AWS Console нескладно. Складніше через місяць точно відповісти, хто змінив security group, чому production відрізняється від staging і як відтворити всю інфраструктуру в іншому регіоні. Terraform розв’язує цю проблему декларативно: ви описуєте бажаний стан у коді, переглядаєте план змін і лише потім дозволяєте інструменту звернутися до AWS API.

У цій інструкції ми підготуємо невеликий, але повноцінний Terraform-проєкт для AWS. Він створить VPC, public subnet, internet gateway, route table, security group та EC2 instance. Окремо налаштуємо автентифікацію без ключів у `.tf` файлах, remote state в S3 із блокуванням, змінні, outputs і безпечний workflow `plan` → review → `apply`.

Terraform не замінює розуміння AWS. Помилка в HCL може відкрити порт, видалити ресурс або створити витрати в кількох Availability Zones. Тому код інфраструктури потребує такого самого review, тестування й контролю доступу, як код застосунку. Якщо потрібно спроєктувати перенесення сервісів і цільову AWS-архітектуру до початку автоматизації, варто окремо спланувати міграцію в хмару.

Структура Terraform-проєкту та підготовка інструментів

Встановлюйте Terraform із офіційного репозиторію HashiCorp або перевіреного release package. Версію CLI не фіксуйте за випадковим прикладом зі статті: перевірте актуальний supported release для вашого середовища та зафіксуйте constraint у коді. Так само встановіть AWS CLI v2, адже він зручний для входу через IAM Identity Center і перевірки активної identity.

bash
terraform version
aws --version
git --version

Створіть окремий каталог і базові файли:

bash
mkdir terraform-aws-demo
cd terraform-aws-demo

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

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

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

Файл `.terraform.lock.hcl`, навпаки, додавайте в Git. Він фіксує вибрану версію provider та checksums, щоб локальний запуск і CI використовували однакову залежність.

У `versions.tf` обмежте Terraform і AWS provider. Constraint `~> 6.0` дозволяє сумісні оновлення в межах major-версії, але не переводить проєкт на наступний major автоматично:

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

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

Перед використанням constraints звірте їх з актуальними releases та перевірте upgrade у тестовому середовищі. Не запускайте `terraform init -upgrade` без review змін lock-файла та release notes provider.

Зручна структура з одним root module підходить для навчального прикладу. У реальному репозиторії розділяйте незалежні stacks: наприклад, network, compute і data. Не складайте dev, stage та prod в один state. Окремий state зменшує blast radius, прискорює plan і дозволяє видати різні IAM-права.

Автентифікація AWS і конфігурація provider

Не записуйте `access_key` і `secret_key` у `providers.tf`, variables або `terraform.tfvars`. Terraform state та plan можуть містити чутливі значення, а Git зберігає історію навіть після видалення рядка. Для локальної роботи використовуйте IAM Identity Center, короткоживучий profile або AssumeRole; для CI — OIDC і окрему IAM role з мінімальними правами.

Після входу перевірте, від чийого імені виконається apply:

bash
aws sso login --profile platform-dev
export AWS_PROFILE=platform-dev

aws sts get-caller-identity
aws configure get region --profile "$AWS_PROFILE"

Вивід `get-caller-identity` потрібно звірити з очікуваним account ID. Захист від випадкового запуску в іншому account можна додати безпосередньо в provider через `allowed_account_ids`.

`providers.tf`:

hcl
provider "aws" {
  region              = var.aws_region
  allowed_account_ids = [var.aws_account_id]

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

`variables.tf`:

hcl
variable "aws_region" {
  description = "AWS region for all resources"
  type        = string
  default     = "eu-central-1"
}

variable "aws_account_id" {
  description = "Allowed AWS account ID"
  type        = string
}

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 the web port"
  type        = string
}

Створіть `terraform.tfvars` локально, не додаючи його в Git:

hcl
aws_account_id = "111122223333"
admin_cidr     = "203.0.113.10/32"

IP з документаційного діапазону потрібно замінити на фактичну довірену адресу. Для production краще не відкривати адміністративний доступ до EC2 взагалі, а використовувати AWS Systems Manager Session Manager через instance role і приватні endpoints.

Remote state у S3 та блокування операцій

За замовчуванням Terraform зберігає `terraform.tfstate` локально. Для командної роботи це небезпечно: файл легко втратити, двоє інженерів можуть одночасно виконати apply, а локальна копія не має централізованого аудиту. Створіть окремий S3 bucket для state до підключення backend. Bucket має бути не в тому самому state, який він зберігає, інакше виникає циклічна залежність під час bootstrap або destroy.

Для bucket увімкніть versioning, server-side encryption, Block Public Access і заборону незашифрованого transport. Доступ до state та lock-файла надавайте тільки Terraform execution roles і вузькому колу адміністраторів. State може містити identifiers, attributes та secrets, навіть якщо output позначено `sensitive`.

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

hcl
terraform {
  backend "s3" {
    use_lockfile = true
    encrypt      = true
  }
}

А несекретні параметри збережіть у `backend.hcl`:

hcl
bucket = "company-terraform-state-eu-central-1"
key    = "web-platform/dev/terraform.tfstate"
region = "eu-central-1"

Ініціалізуйте backend:

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

Сучасний S3 backend підтримує native locking через `use_lockfile = true`. Старий спосіб із DynamoDB table для locking позначений як deprecated, тому не додавайте нову таблицю лише за застарілими прикладами. Під час міграції старого проєкту HashiCorp дозволяє тимчасово мати обидві конфігурації, але цільовий стан має відповідати документації вашої версії Terraform.

IAM policy для backend повинна дозволяти list потрібного bucket prefix, читання й запис конкретного state object, а для locking — також роботу з object `<key>.tflock`, включно з видаленням lock. Не видавайте wildcard-доступ до всіх S3 buckets account. Versioning допоможе повернути попередню версію object після людської помилки, але відновлення state все одно потрібно документувати й тестувати.

Створення VPC, subnet і EC2 instance

Тепер опишемо ресурси у `main.tf`. Спочатку отримаємо доступні Availability Zones і актуальний Ubuntu AMI через AWS Systems Manager public parameter. Так конфігурація не залежатиме від AMI ID, який різниться між регіонами та змінюється з новими образами.

hcl
data "aws_availability_zones" "available" {
  state = "available"
}

data "aws_ssm_parameter" "ubuntu_ami" {
  name = "/aws/service/canonical/ubuntu/server/24.04/stable/current/amd64/hvm/ebs-gp3/ami-id"
}

resource "aws_vpc" "main" {
  cidr_block           = "10.40.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = { Name = "${var.project_name}-${var.environment}-vpc" }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = { Name = "${var.project_name}-${var.environment}-igw" }
}

resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.40.10.0/24"
  availability_zone       = data.aws_availability_zones.available.names[0]
  map_public_ip_on_launch = true

  tags = { Name = "${var.project_name}-${var.environment}-public-a" }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.id
  }

  tags = { Name = "${var.project_name}-${var.environment}-public" }
}

resource "aws_route_table_association" "public" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}

Security group відкриє тільки HTTP-порт із CIDR, переданого змінною. Вихідний traffic тут дозволено повністю для демонстрації; у захищеному середовищі egress також потрібно обмежити відповідно до залежностей workload.

hcl
resource "aws_security_group" "web" {
  name_prefix = "${var.project_name}-${var.environment}-web-"
  description = "Web access from a trusted CIDR"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "HTTP from trusted network"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = [var.admin_cidr]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  lifecycle {
    create_before_destroy = true
  }

  tags = { Name = "${var.project_name}-${var.environment}-web" }
}

resource "aws_instance" "web" {
  ami                         = data.aws_ssm_parameter.ubuntu_ami.value
  instance_type               = "t3.micro"
  subnet_id                   = aws_subnet.public.id
  vpc_security_group_ids      = [aws_security_group.web.id]
  associate_public_ip_address = true

  metadata_options {
    http_endpoint = "enabled"
    http_tokens   = "required"
  }

  root_block_device {
    encrypted   = true
    volume_type = "gp3"
    volume_size = 12
  }

  tags = { Name = "${var.project_name}-${var.environment}-web-01" }
}

Значення `t3.micro` не є універсально безкоштовним: eligibility залежить від account, регіону та чинних умов AWS. Перед apply перевірте pricing, service quotas і створіть AWS Budget. Для production потрібні щонайменше private subnets, кілька Availability Zones, load balancer, Auto Scaling, централізовані logs, backup і monitoring. Аналогічно, якщо EC2 використовується як основа для control plane, сам Terraform provisioning ще не створює HA Kubernetes кластер — для нього потрібні окремі bootstrap та lifecycle-процедури.

У `outputs.tf` виведіть лише потрібні значення:

hcl
output "instance_id" {
  value = aws_instance.web.id
}

output "public_ip" {
  value = aws_instance.web.public_ip
}

output "vpc_id" {
  value = aws_vpc.main.id
}

Виконайте форматування і статичну перевірку до звернення до AWS:

bash
terraform fmt -check -recursive
terraform validate
terraform plan -out=dev.tfplan
terraform show dev.tfplan

Уважно прочитайте summary: скільки ресурсів буде created, changed або destroyed. Застосовуйте саме переглянутий plan-файл, а не новий неузгоджений plan:

bash
terraform apply dev.tfplan
terraform output

Saved plan містить повну заплановану зміну й може містити sensitive data, тому не завантажуйте його як публічний artifact і задавайте короткий retention у CI.

Workflow, drift, import і CI/CD

Зберігайте HCL, `.terraform.lock.hcl`, `terraform.tfvars.example` і backend template у Git. На merge request запускайте `terraform fmt -check`, `terraform init`, `terraform validate`, policy/security scan і `terraform plan`. Apply дозволяйте лише з protected branch або захищеного environment після review. Такий CI/CD-процес повинен отримувати короткоживучі AWS credentials через OIDC, а не довгостроковий secret key.

Не запускайте паралельні apply для одного state. S3 lock захистить від одночасного запису, але не вирішить організаційний конфлікт двох різних merge requests. Серіалізуйте jobs для кожного stack та environment. `terraform force-unlock` використовуйте тільки після перевірки, що попередній процес справді завершився: примусове зняття активного lock може пошкодити state.

Періодично запускайте read-only plan для пошуку drift:

bash
terraform plan -detailed-exitcode

Exit code `0` означає відсутність змін, `2` — наявність diff, `1` — помилку. У shell або CI не можна трактувати `2` як звичайний failure без окремої логіки. Знайдений drift не слід автоматично «виправляти» apply: спочатку визначте, чи була ручна зміна аварійною, чи HCL більше не відповідає потрібному стану.

Існуючий AWS resource можна взяти під керування через import block:

hcl
import {
  to = aws_instance.web
  id = "i-0123456789abcdef0"
}

Import додає binding у state, але не створює правильну конфігурацію автоматично. Спочатку опишіть resource, виконайте plan і вирівняйте аргументи так, щоб не отримати несподіваний replace або destroy. Після успішного import block можна прибрати окремим reviewed commit.

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

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

Не запускайте `terraform destroy -auto-approve` у спільному account. Ресурси з даними мають мати backup, retention і окремий захист від видалення. Видалення root stack не гарантує, що зникнуть snapshots, зовнішні DNS records, manual resources або витрати в іншому регіоні.

Типові помилки під час роботи з Terraform і AWS

Перша помилка — commit AWS keys, `terraform.tfvars` або state у Git. Якщо секрет уже потрапив в історію, простого видалення файла недостатньо: негайно відкличте credentials, перевірте CloudTrail, оцініть виконані API calls і лише потім очищуйте історію за погодженою процедурою.

Друга — один state для всіх середовищ. Невдалий apply тоді має надто великий blast radius, а IAM role отримує зайві права. Розділяйте stacks та accounts, використовуйте окремі keys backend і не покладайтеся на workspaces як на єдиний security boundary.

Третя — apply без saved plan або після того, як plan застарів. Між plan і apply могла змінитися інфраструктура, credentials чи provider configuration. У CI застосовуйте той самий reviewed artifact протягом короткого вікна й повторюйте plan після зміни branch.

Четверта — надто широкі IAM policies та security groups із `0.0.0.0/0`. Terraform автоматизує помилку так само швидко, як правильне рішення. Додавайте `allowed_account_ids`, least privilege, policy checks, IMDSv2, encryption і default tags. Перевіряйте не лише синтаксис, а й наслідки для мережі та доступу.

П’ята — ручне редагування state або сліпе використання `force-unlock`. Для state operations застосовуйте Terraform CLI, робіть backup і спершу перевіряйте lock owner. Відновлення попередньої S3 object version має бути аварійною процедурою з тестом, а не щоденним способом виправлення конфігурації.

Висновок

Надійний Terraform-проєкт для AWS складається не лише з `aws_instance`. Йому потрібні зафіксовані версії provider, короткоживуча IAM identity, захист від неправильного account, remote state у versioned S3 bucket, native lockfile, контрольовані variables і review кожного plan.

Навчальний приклад VPC, subnet, route table, security group та EC2 показує головний цикл: `fmt`, `validate`, `plan`, review і `apply` з того самого plan-файла. Далі цю основу можна розділити на modules і stacks, додати private networks, load balancers, databases, monitoring та policy checks без ручного створення ресурсів у Console.

Починайте з невеликого dev account, перевіряйте identity й витрати до apply, зберігайте state окремо від коду та регулярно шукайте drift. Автоматизація стає безпечною лише тоді, коли доступ мінімальний, зміни видимі до виконання, а видалення й відновлення відпрацьовані так само ретельно, як створення інфраструктури.

📞