Infrastruktura jako kod i rola Terraform w codziennej pracy
Czym jest Infrastructure as Code (IaC)
Infrastructure as Code (IaC) to podejście, w którym cała infrastruktura – serwery, sieci, bazy danych, load balancery, uprawnienia – jest opisana w postaci plików tekstowych, a nie „klikana” ręcznie w konsoli chmury. Pliki konfiguracyjne stają się źródłem prawdy o środowisku, a wszelkie zmiany przechodzą przez ten sam proces co zmiany aplikacyjnego kodu.
Kluczowa różnica względem ręcznej konfiguracji polega na tym, że:
- nie tworzysz zasobów pojedynczymi kliknięciami w panelu webowym, tylko zapisujesz je jako deklaracje w plikach,
- środowisko można odtworzyć w powtarzalny sposób na innym koncie, regionie lub nawet u innego dostawcy,
- zyskujesz historię zmian (Git, PR-y, code review) i możliwość łatwego porównywania konfiguracji w czasie.
Terraform używa deklaratywnego modelu konfiguracji. Opisujesz co ma istnieć (np. „ma istnieć VPC z dwoma publicznymi subnetami i jednym load balancerem”), a narzędzie samo wylicza jak to osiągnąć – jakie wywołać API, w jakiej kolejności, które zasoby zmodyfikować, które usunąć. To podejście odróżnia Terraform od wielu narzędzi imperatywnych, gdzie musisz zaprogramować sekwencję kroków (pętle, if-y, kolejność wykonywania).
Najważniejsze korzyści IaC w połączeniu z Terraform:
- Powtarzalność – ten sam kod Terraform można uruchomić wielokrotnie i uzyskać identyczne (lub deterministycznie przewidywalne) środowisko.
- Audyt i zgodność – każda zmiana infrastruktury jest widoczna w historii Git, można ją powiązać z ticketem, MR/PR i osobą, która ją zatwierdziła.
- Versioning – możesz wrócić do starszej wersji opisu infrastruktury i zobaczyć, jak wyglądała trzy miesiące temu, a nawet spróbować ją odtworzyć.
- Automatyzacja – Terraform łączy się z CI/CD, co pozwala na w pełni automatyczne provisionowanie środowisk testowych, ephemeral environments czy replikę produkcji do debugowania.
Gdzie Terraform pasuje w ekosystemie narzędzi DevOps
Terraform jest jednym z kilku popularnych narzędzi IaC, ale jego rola jest specyficzna. Zajmuje się przede wszystkim warstwą infrastrukturalną, czyli wszystkim tym, czym zarządza API dostawcy chmury albo innego systemu (np. GitHub, Cloudflare, Kubernetes). Nie zarządza bezpośrednio konfiguracją wewnątrz systemu operacyjnego (tam częściej używa się Ansible, Chef, Puppet).
Porównanie do innych narzędzi w skrócie:
| Narzędzie | Główny obszar | Model | Zastosowanie |
|---|---|---|---|
| Terraform | Infrastruktura (multi-cloud, SaaS) | Deklaratywny, HCL | Sieci, bazy, load balancery, konta, uprawnienia |
| CloudFormation | AWS (vendor-specific) | Deklaratywny, YAML/JSON | Wyłącznie ekosystem AWS |
| Ansible | Konfiguracja systemów | Głównie imperatywny | Instalacja pakietów, templaty configów, deploy aplikacji |
| Pulumi | Infrastruktura | Deklaratywno‑imperatywny (języki ogólnego przeznaczenia) | IaC w TypeScript/Python/Go/C# |
Główny argument za Terraform w porównaniu z narzędziami vendor‑specific (typu CloudFormation, ARM Templates) to multi‑cloud. Ten sam model i składnia HCL pozwalają zarządzać AWS, Azure, GCP, a także dziesiątkami innych providerów (GitHub, Datadog, Cloudflare, Kubernetes, Vault). Dla wielu firm to ułatwia uniknięcie zbyt mocnego przywiązania do jednego dostawcy lub przynajmniej upraszcza migracje.
Typowe scenariusze użycia Terraform:
- budowa i utrzymanie sieci (VPC, subnets, routing, security groups, firewall rules),
- tworzenie i skalowanie baz danych (RDS, Cloud SQL, Aurora, Redis, itp.),
- provisioning klastrów Kubernetes (EKS, GKE, AKS) wraz z powiązanymi zasobami sieciowymi,
- zarządzanie kontami i uprawnieniami (IAM, organizacje, role, polityki),
- definiowanie infrastruktury aplikacji: load balancery, autoscaling groups, serwisy serverless, kolejki.
Konfiguracyjny drift – czym jest i dlaczego robi bałagan
Konfiguracyjny drift to rozjazd pomiędzy tym, co jest zapisane w kodzie Terraform (i jego stanie), a tym, co faktycznie istnieje w chmurze. W praktyce oznacza to, że Terraform „myśli”, że infrastruktura wygląda tak, jak w plikach i w terraform state, ale konsola chmurowa pokazuje coś innego.
Efekty dryfu są natychmiast odczuwalne:
- przy kolejnym
terraform planpojawiają się zaskakujące zmiany – Terraform próbuje „naprawić” różnice, często w niepożądany sposób, - rollbacki stają się trudne, bo nie da się przewidzieć, jakie zasoby zostały ręcznie poprawione lub utworzone obok,
- częściej dochodzi do awarii podczas wdrożeń, jeśli ktoś „uratował produkcję” szybką zmianą w konsoli, a potem Terraform ją nadpisuje.
Typowe źródła dryfu konfiguracji:
- Ręczne zmiany w konsoli – ktoś dodaje regułę security group z poziomu panelu, bo „trzeba szybko otworzyć port”, bez aktualizacji kodu.
- Skrypty obok Terraform – fragment infrastruktury budowany jest bashami lub innym narzędziem, którego efekty Terraform nie zna.
- Nieprawidłowe zarządzanie stanem – kilka osób działa na lokalnym
terraform.tfstate, dochodzi do nadpisywania i utraty informacji. - Brak kontroli dostępu – zbyt szerokie uprawnienia do konta chmurowego, gdzie każdy może dowolnie modyfikować zasoby poza Terraform.
Jeśli celem jest stabilna infrastruktura jako kod Terraform, rdzeniem strategii musi być minimalizacja dryfu. Ręczne poprawki w konsoli powinny być traktowane jak wyjątek awaryjny, po którym obowiązkowo następuje korekta w kodzie oraz align stanu z rzeczywistością.

Podstawy Terraform: jak „myśli” narzędzie i co z tego wynika
Podstawowe pojęcia: provider, resource, data, state
Terraform korzysta z kilku bazowych pojęć, które warto mieć w głowie od samego początku. Ich zrozumienie ułatwia projektowanie modularnej infrastruktury Terraform i unikanie dryfu konfiguracji.
Provider to w uproszczeniu wtyczka do konkretnego API. Provider AWS wie, jak rozmawiać z API AWS, provider azurerm z Azure, provider google z GCP. Provider konfiguruje się zwykle raz w module root (region, profile, credentials), a potem używa się go przy definiowaniu zasobów.
Resource (zasób) reprezentuje element infrastruktury zarządzany przez Terraform: instancję EC2, bucket S3, bazę RDS, regułę firewalla. Terraform tworzy, modyfikuje i usuwa resource poprzez provider. Deklaracja resource to serce konfiguracji – to „co ma istnieć”.
Data source (blok data) służy do odczytu istniejących zasobów, którymi Terraform niekoniecznie zarządza. Na przykład możesz odczytać istniejące VPC albo AMI z określoną etykietą. Dane te są tylko do użycia w konfiguracji (np. jako inputy dla innych resource), ale Terraform nie będzie ich usuwał ani modyfikował.
State (terraform.tfstate) to plik przechowujący mapowanie pomiędzy konfiguracją Terraform a rzeczywistymi zasobami w chmurze. Dla każdego resource Terraform zapisuje m.in. ID zasobu z chmury, atrybuty, metadane. Dzięki temu przy kolejnym uruchomieniu plan narzędzie wie, co istnieje, co trzeba zmienić, a czego nie ruszać.
State jest krytycznym elementem dla utrzymania spójności. Jeśli zostanie uszkodzony, zgubiony lub nadpisany, Terraform może spróbować odtworzyć zasoby, które już istnieją, albo usunąć te, których w stanie nie ma. Dlatego zarządzanie stanem Terraform to osobny obszar i jeden z głównych kluczy do unikania driftu.
Cykl życia pracy z Terraform (init → plan → apply → destroy)
Większość codziennej pracy z Terraform przebiega w powtarzalnym workflow: terraform init, terraform plan, terraform apply, czasem terraform destroy.
terraform init:
- pobiera niezbędne providery (np. hashicorp/aws) i zapisuje je lokalnie,
- konfiguruje backend stanu (np. S3, GCS, Azure Blob, lokalny plik),
- należy uruchamiać zawsze po zmianach w konfiguracji providerów lub backendu.
terraform plan to „suchy bieg” (dry-run). Terraform:
- wczytuje aktualny stan z backendu,
- wczytuje kod HCL z bieżącego katalogu,
- pyta API providera o bieżący stan zasobów,
- generuje plan zmian: co zostanie dodane, zmodyfikowane, usunięte.
plan niczego nie zmienia. To kluczowy etap kontroli, który pozwala wykryć potencjalny drift konfiguracji oraz niespodziewane modyfikacje, zanim trafią na środowisko. W praktyce, w dobrze zorganizowanym procesie, plan jest publikowany jako artefakt w CI i podlega code review.
terraform apply wykonuje plan. Z reguły:
- najpierw generuje plan (jeśli nie przekazano wcześniej pliku planu),
- wyświetla zmiany do potwierdzenia,
- wykonuje operacje na API providera w kolejności wynikającej z grafu zależności,
- aktualizuje state (lokalny lub w backendzie) po zakończeniu.
Ręczne zmiany w konsoli wprowadzone po planie, a przed apply mogą doprowadzić do niespójności. Dlatego w produkcji coraz częściej stosuje się workflow: plan w CI → review → zatwierdzenie → apply z dokładnie tym samym planem (plik binarny .tfplan).
terraform destroy usuwa wszystkie zasoby zarządzane przez dany moduł. W środowiskach produkcyjnych zwykle nie używa się go wprost, tylko pracuje się na poziomie poszczególnych modułów albo stosuje się blokady, aby przypadkowo nie wyczyścić środowiska. Destroy jest natomiast bardzo przydatne w short-lived environments, np. testowych lub dla developerów.
Format HCL i struktura katalogu
Terraform korzysta z HCL (HashiCorp Configuration Language) – języka deklaratywnego opracowanego z myślą o czytelności dla ludzi, ale i prostocie parsowania przez maszyny. HCL jest bardziej przyjazny niż JSON czy YAML w kontekście opisu infrastruktury: wspiera typy, interpolacje, funkcje, a przy tym jest dość zwięzły.
Standardowa struktura plików w małym projekcie Terraform:
- main.tf – główna logika: providery, zasoby, moduły,
- variables.tf – deklaracje zmiennych wejściowych (nazwa, typ, opis, wartości domyślne),
- outputs.tf – definicje outputów eksportowanych przez moduł,
- terraform.tfvars (opcjonalnie) – domyślne wartości zmiennych dla danego środowiska.
Terraform traktuje wszystkie pliki *.tf w jednym katalogu jako jeden moduł. Kolejność wczytywania nie ma znaczenia – narzędzie samodzielnie buduje graf zależności na podstawie referencji pomiędzy zasobami, zmiennymi i outputami. Dzięki temu można logicznie podzielić kod pomiędzy kilka plików bez martwienia się o to, co jest „pierwsze”.
Przy skalowaniu kodu zwykle wprowadza się podkatalogi modules/ na moduły wielokrotnego użycia oraz katalogi per środowisko (np. envs/prod, envs/stage), w których istnieją osobne moduły root z własnym backendem stanu.
Pierwsza konfiguracja Terraform: minimalny, działający przykład
Przygotowanie środowiska
Start z Terraform jest prosty: potrzebna jest binarka, dostęp do chmury i katalog roboczy. Ważne, aby od początku pilnować kwestii wersjonowania i plików, których nie chcemy wrzucać do Git.
Instalacja Terraform:
Instalacja Terraform i podstawowa konfiguracja
Najwygodniej zainstalować Terraform z oficjalnych repozytoriów HashiCorp lub przez menedżer pakietów (brew, choco, apt, dnf). Kluczowe jest trzymanie się konkretnej wersji binarki, aby uniknąć różnic w zachowaniu na różnych stanowiskach i w CI.
Przykładowo na macOS z Homebrew:
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
terraform -version
Na Linuxie, gdy chcesz mieć pełną kontrolę nad wersją, często spotyka się ręczne pobieranie binarki:
TF_VERSION=1.9.4
curl -LO https://releases.hashicorp.com/terraform/${TF_VERSION}/terraform_${TF_VERSION}_linux_amd64.zip
unzip terraform_${TF_VERSION}_linux_amd64.zip
sudo mv terraform /usr/local/bin/
terraform -version
Uwaga: w projektach zespołowych dobrą praktyką jest dodanie pliku .terraform-version (np. dla tfenv) lub zdefiniowanie wersji w required_version, aby lokalne binarki i CI korzystały z tego samego wydania.
terraform {
required_version = ">= 1.8, < 1.10"
}
Konfiguracja dostępu do chmury
Terraform sam nie przechowuje sekretów typu login/hasło. Uwierzytelnianie opiera się na mechanizmach providera i środowiska (zmienne środowiskowe, profile CLI, role).
Przykład konfiguracji AWS przy użyciu profilu z ~/.aws/credentials:
provider "aws" {
region = "eu-central-1"
profile = "terraform-lab"
}
Przy pracy z Azure (provider azurerm):
provider "azurerm" {
features {}
}
Logowanie odbywa się przez az login lub tożsamość usługi (service principal). W GCP podobną rolę pełnią klucze serwisowe JSON lub tożsamość workload identity.
Tip: zmienne środowiskowe (np. AWS_ACCESS_KEY_ID, ARM_CLIENT_ID, GOOGLE_CREDENTIALS) są wygodne w CI, ale na stacjach developerskich lepiej oprzeć się o dedykowane CLI i profile, aby unikać przypadkowego wrzucenia sekretów do repo.
Inicjalizacja katalogu z minimalnym przykładem
Przykład na AWS, który tworzy pojedynczy bucket S3. W nowym katalogu roboczym utwórz plik main.tf:
terraform {
required_version = ">= 1.8, < 1.10"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "eu-central-1"
profile = "terraform-lab"
}
resource "aws_s3_bucket" "example" {
bucket = "tf-example-bucket-UNIKALNY-SUFIKS"
}
W katalogu wykonaj:
terraform init
terraform plan
terraform apply
Po zatwierdzeniu apply bucket zostanie utworzony, a lokalny plik terraform.tfstate będzie zawierał informacje o jego ID i atrybutach.
Uwaga: nazwa bucketu S3 jest globalnie unikalna. W praktyce stosuje się prefiksy/sufiksy z nazwą projektu i losowym komponentem, albo generuje nazwę z użyciem funkcji random_id (resource z providera random).
Dodanie backendu stanu – pierwszy krok do pracy zespołowej
Trzymanie lokalnego terraform.tfstate ma sens tylko na samym początku. Przy kilku osobach w projekcie stan powinien być przechowywany w zdalnym backendzie z blokadą (locking), np. S3 + DynamoDB, Google Cloud Storage lub Azure Blob + Storage Table.
Dla AWS typowa konfiguracja backendu w osobnym pliku backend.tf:
terraform {
backend "s3" {
bucket = "tf-state-prod"
key = "infra/network/terraform.tfstate"
region = "eu-central-1"
dynamodb_table = "tf-state-locks"
encrypt = true
}
}
Po dodaniu backendu uruchom ponownie terraform init. Terraform zapyta, czy zmigrować istniejący stan lokalny do S3. Od tego momentu każdy plan/apply korzysta z tego samego stanu i z mechanizmu locka, który zabezpiecza przed równoległymi modyfikacjami.
Kontrola wersji i pliki ignorowane
Konfiguracja Terraform powinna być w Git od pierwszego dnia. W repozytorium nie umieszcza się natomiast:
.terraform/– katalog z pobranymi providerami i cachem,terraform.tfstatei*.tfstate.backup– pliki stanu (w produkcji trzymane w backendzie),- lokalnych plików
*.tfvarsz sekretami.
Prosty .gitignore dla monorepo z Terraform:
.terraform/
.terraform.*
terraform.tfstate
terraform.tfstate.backup
*.tfstate
*.tfvars
*.tfplan
Jeśli część zmiennych musi zostać w repo (np. konfiguracje środowisk), używaj *.tfvars bez sekretów i wrażliwych danych, a sekrety przenoś do dedykowanych systemów (np. AWS SSM, Secrets Manager, Vault) z odczytem przez data source.

Opisywanie infrastruktury krok po kroku: zmienne, wyjścia, zależności
Zmienne wejściowe (variables) – parametryzacja modułu
Zmienne pozwalają opisać infrastrukturę w sposób generyczny i ponownie używalny. Deklaruje się je w blokach variable, zwykle w variables.tf.
variable "environment" {
description = "Nazwa środowiska (np. dev, stage, prod)"
type = string
}
variable "allowed_cidrs" {
description = "Lista CIDR, którym wolno się łączyć"
type = list(string)
default = ["10.0.0.0/8"]
}
variable "instance_count" {
description = "Liczba instancji w ASG"
type = number
default = 2
}
Wartości zmiennych można ustawiać na kilka sposobów:
- przez
terraform.tfvarslub pliki*.auto.tfvars, - przez parametr
-varlub-var-file, - poprzez zmienne środowiskowe
TF_VAR_nazwa.
Przykład prod.tfvars:
environment = "prod"
allowed_cidrs = ["1.2.3.4/32"]
instance_count = 4
Uruchomienie z konkretnym plikiem:
terraform plan -var-file="prod.tfvars"
terraform apply -var-file="prod.tfvars"
Uwaga: zmienne typu sensitive nie są logowane wprost w outputach Terraform. Deklaracja:
variable "db_password" {
description = "Hasło do bazy danych"
type = string
sensitive = true
}
Wyjścia (outputs) – eksport kluczowych informacji
Wyjścia pełnią rolę publicznego API modułu. Służą zarówno do debugowania (adresy, ID), jak i do przekazywania danych do innych modułów lub systemów (np. CI, Ansible).
output "bucket_name" {
description = "Nazwa utworzonego bucketa"
value = aws_s3_bucket.example.bucket
}
output "bucket_arn" {
description = "ARN bucketa"
value = aws_s3_bucket.example.arn
}
output "db_password" {
description = "Hasło bazy (sensitive output)"
value = var.db_password
sensitive = true
}
Wyjścia dostępne są po apply oraz poprzez terraform output lub terraform output -json. To drugie jest szczególnie wygodne w CI, gdy kolejne kroki pipeline’u potrzebują danych z Terraform.
Referencje i zależności – jak Terraform układa graf
Zależności pomiędzy zasobami Terraform buduje automatycznie, analizując odwołania (references). Jeśli jeden resource korzysta z atrybutu drugiego, powstaje krawędź w grafie.
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
}
resource "aws_subnet" "app" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "eu-central-1a"
}
Terraform wie, że aws_subnet.app nie może powstać przed aws_vpc.main. Podobnie wie, że przy usuwaniu najpierw trzeba skasować subnet, a dopiero potem VPC. Ten mechanizm jest fundamentem bezpiecznego apply i minimalizacji dryfu – narzędzie nie musi zgadywać kolejności ani opierać się na ręcznych skryptach.
Czasem potrzebna jest jawna zależność logiczna – np. gdy resource nie ma bezpośredniego odwołania do drugiego, ale powinien być tworzony po nim. Do tego służy depends_on:
resource "aws_iam_role" "app_role" {
name = "app-role"
# ...
}
resource "aws_iam_role_policy_attachment" "app_role_attachment" {
role = aws_iam_role.app_role.name
policy_arn = "arn:aws:iam::aws:policy/AmazonS3FullAccess"
}
resource "null_resource" "post_config" {
# Ten resource nie ma atrybutów z IAM, ale musi poczekać na ich utworzenie
depends_on = [
aws_iam_role_policy_attachment.app_role_attachment
]
provisioner "local-exec" {
command = "echo 'Do something after IAM is ready'"
}
}
Tip: depends_on jest użyteczne, ale nadużywane staje się sygnałem, że model infrastruktury lub wybór resource’ów/providera wymaga przemyślenia. Naturalne zależności (przez atrybuty) są stabilniejsze.
Dynamiczne wartości i funkcje w HCL
HCL udostępnia bogaty zestaw funkcji (np. join, format, cidrsubnet, merge, coalesce). Pozwalają generować wartości na podstawie innych atrybutów, zamiast kopiować je ręcznie.
locals {
name_prefix = "${var.environment}-app"
}
resource "aws_s3_bucket" "logs" {
bucket = "${local.name_prefix}-logs"
}
resource "aws_iam_role" "app_role" {
name = format("%s-role", local.name_prefix)
}
Zmiana jednej zmiennej (environment) propaguje się w całej konfiguracji. To ogranicza pole do ręcznych poprawek i tym samym redukuje ryzyko dryfu.
Pętle i for_each – wiele podobnych zasobów bez powielania kodu
Zamiast kopiować resource dla każdego środowiska lub instancji, lepiej użyć for_each lub count. To szczególnie przydatne przy sekcjach security groups, IAM policies czy tworzeniu kilku subnetów.
variable "public_subnets" {
type = map(object({
cidr_block = string
availability_zone = string
}))
}
resource "aws_subnet" "public" {
for_each = var.public_subnets
vpc_id = aws_vpc.main.id
cidr_block = each.value.cidr_block
availability_zone = each.value.availability_zone
tags = {
Name = "public-${each.key}"
}
}
Input:
public_subnets = {
"a" = {
cidr_block = "10.0.1.0/24"
availability_zone = "eu-central-1a"
}
"b" = {
cidr_block = "10.0.2.0/24"
availability_zone = "eu-central-1b"
}
}
Fizyczne mapowanie zasobów do kluczy each.key (a, b) jest stabilne. Dzięki temu Terraform rozumie, że zmiana CIDR w jednym entry to modyfikacja konkretnego resource, a nie ich masowe usuwanie i tworzenie na nowo.
locals – centralne miejsce na reguły i konwencje nazw
Blok locals przydaje się do trzymania konwencji nazewniczych, map konfiguracyjnych lub wszelkiej logiki, która nie jest bezpośrednio zmienną wejściową.
locals {
project = "billing"
environment = var.environment
name_prefix = "${local.project}-${local.environment}"
common_tags = {
Project = local.project
Environment = local.environment
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket" "data" {
bucket = "${local.name_prefix}-data"
tags = local.common_tags
}
Dzięki locals reguły są wyraźnie odseparowane od inputów (variables) i outputów. Przy przeglądaniu kodu od razu widać, co jest parametrem, a co lokalną konwencją danego modułu.
Moduły Terraform: od jednego pliku do wielokrotnego użycia
Moduł root i moduły zagnieżdżone
Każdy katalog z plikami *.tf to moduł. Ten, w którym uruchamiasz terraform init/plan/apply, nazywa się modułem root (głównym). Wszystkie pozostałe moduły są wywoływane z niego poprzez blok module.
Struktura katalogów – porządek od początku
Przy jednym pliku main.tf wszystko wydaje się proste. Problem zaczyna się, gdy trzeba obsłużyć kilka środowisk, regionów i komponentów. Wtedy struktura katalogów zaczyna być równie ważna, jak sam kod.
Przykładowy, czytelny układ dla jednego systemu:
infra/
modules/
network/
main.tf
variables.tf
outputs.tf
app/
main.tf
variables.tf
outputs.tf
envs/
dev/
main.tf
backend.tf
variables.tf
dev.tfvars
prod/
main.tf
backend.tf
variables.tf
prod.tfvars
W katalogu modules/ trzymane są moduły (zestawy zasobów wielokrotnego użycia), a w envs/ – konfiguracje środowisk wywołujące te moduły. Dzięki temu:
- moduły nie mieszają się z logiką konkretnych środowisk,
- zmiana w module (np. poprawka tagów) może być niezależnie wdrażana w
deviprod, - łatwo podpiąć różne backendy (np. inny bucket S3) dla różnych środowisk.
W obrębie pojedynczego modułu dobrym nawykiem jest rozdzielenie plików na:
main.tf– główna definicja zasobów,variables.tf– wszystkie zmienne wejściowe modułu,outputs.tf– wyjścia modułu (API modułu),locals.tf(opcjonalnie) – reguły nazewnicze, mapy konfiguracji.
Tip: w większych modułach można podzielić main.tf na logiczne bloki (np. network.tf, security.tf), ale nie przesadzaj – więcej plików to też więcej kontekstu do ogarnięcia dla zespołu.
Tworzenie własnego modułu – mały, ale kompletny
Prosty, realny przykład: moduł VPC z publicznymi subnetami i tagami.
Struktura:
modules/
network/
main.tf
variables.tf
outputs.tf
variables.tf:
variable "name" {
description = "Nazwa VPC (prefiks tagów)"
type = string
}
variable "cidr_block" {
description = "CIDR VPC"
type = string
}
variable "public_subnets" {
description = "Mapa publicznych subnetów"
type = map(object({
cidr_block = string
availability_zone = string
}))
}
variable "tags" {
description = "Dodatkowe tagi wspólne dla wszystkich zasobów"
type = map(string)
default = {}
}
main.tf:
locals {
common_tags = merge(
{
Name = var.name
ManagedBy = "terraform"
},
var.tags
)
}
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
enable_dns_support = true
enable_dns_hostnames = true
tags = local.common_tags
}
resource "aws_subnet" "public" {
for_each = var.public_subnets
vpc_id = aws_vpc.this.id
cidr_block = each.value.cidr_block
availability_zone = each.value.availability_zone
map_public_ip_on_launch = true
tags = merge(
local.common_tags,
{ Name = "${var.name}-public-${each.key}" }
)
}
outputs.tf:
output "vpc_id" {
description = "ID utworzonej VPC"
value = aws_vpc.this.id
}
output "public_subnet_ids" {
description = "Lista ID publicznych subnetów"
value = [for s in aws_subnet.public : s.id]
}
Ten moduł jest mały, robi jedną rzecz, ma jasne wejścia i wyjścia. To dobry wzorzec startowy. Można go wywołać z modułu root:
module "network" {
source = "../modules/network"
name = "billing-dev"
cidr_block = "10.10.0.0/16"
public_subnets = {
"a" = {
cidr_block = "10.10.1.0/24"
availability_zone = "eu-central-1a"
}
"b" = {
cidr_block = "10.10.2.0/24"
availability_zone = "eu-central-1b"
}
}
tags = {
Environment = "dev"
Project = "billing"
}
}
source modułu – lokalny katalog, Git, Terraform Registry
Parametr source określa, skąd Terraform ma pobrać moduł. Kilka typowych wariantów:
- ścieżka lokalna (jak wyżej):
source = "../modules/network", - repozytorium Git:
source = "git::https://github.com/org/infra-modules.git//network?ref=v1.2.0", - Terraform Registry:
source = "terraform-aws-modules/vpc/aws".
Użycie Git lub Registry świetnie sprawdza się przy modułach współdzielonych między zespołami. Warto wtedy wersjonować moduły (tagi w Git, semver w Registry) i w source wskazywać konkretną wersję, zamiast gałęzi main. Unika się w ten sposób sytuacji, w której terraform apply po kilku tygodniach ściąga inną wersję modułu niż przy poprzednim wdrożeniu.
Moduł jako „czarna skrzynka” – kontrakt zamiast szczegółów
Moduł powinien być traktowany jak biblioteka: istotne są wejścia, wyjścia i zachowanie, a nie to, ile ma plików w środku. To mocno pomaga utrzymać stabilność konfiguracji i ograniczyć dryf między środowiskami.
Dobry moduł ma:
- jasno opisane
variables(zdescriptioni typami), - sensowny zestaw
outputs, który wystarcza innym modułom, - rozsądne domyślne wartości dla niekrytycznych parametrów,
- spójne konwencje nazewnicze w
locals.
Jeśli moduł zaczyna przyjmować kilkadziesiąt zmiennych, a jego main.tf ma setki linii, można to odczuć w praktyce: każdy plan staje się trudny do czytania, a zmiana jednej rzeczy ciągnie za sobą nieoczekiwane efekty. Wtedy lepiej rozbić moduł na mniejsze komponenty (np. oddzielny moduł sieci, oddzielny dla warstwy aplikacyjnej).
Moduły zagnieżdżone – kompozycja bez duplikowania kodu
Moduł może wywoływać inne moduły. Przykład: moduł platform korzysta z network oraz app:
module "network" {
source = "../modules/network"
name = var.name
cidr_block = var.vpc_cidr
public_subnets = var.public_subnets
tags = var.tags
}
module "app" {
source = "../modules/app"
name = "${var.name}-app"
subnet_ids = module.network.public_subnet_ids
environment = var.environment
instance_type = var.instance_type
tags = var.tags
}
Moduł platform staje się wyższym poziomem abstrakcji. Konsument (np. envs/prod) nie musi znać szczegółów sieci czy aplikacji – dostaje „bundle” gotowy do wdrożenia. To kolejny krok do uniknięcia dryfu: mniej miejsc w kodzie, w których można „na skróty” dodać pojedynczy zasób bez przemyślenia całości.
Interfejs modułu – jak dobrać zmienne, żeby nie zabić elastyczności
W projektach wieloosobowych często pojawia się pokusa, żeby do modułu włożyć „opcję na wszystko”: dziesiątki booleanów, parametry indywidualne dla każdej flagi providera. Efekt: moduł teoretycznie potrafi wszystko, a praktycznie nikt nie wie, jak go bezpiecznie użyć.
Dobry kompromis:
- ekspozycja tylko tych parametrów, które rzeczywiście się zmieniają między środowiskami,
- trwałe decyzje projektowe (np. włączenie
enable_dns_hostnames) zaszyte w module, nie jako flaga, - konfiguracje specyficzne dla środowiska przekazywane jako mapy (np.
map(string)lubmap(object)) zamiast kilkunastu osobnych zmiennych.
Przykład: zamiast robić trzy osobne zmienne dla subnetów (public_a_cidr, public_b_cidr, public_c_cidr), lepiej przekazać mapę jak wcześniej:
variable "public_subnets" {
type = map(object({
cidr_block = string
availability_zone = string
}))
}
Takie API jest skalowalne – można dodać kolejne subnety bez modyfikowania modułu.
Wersjonowanie modułów a dryf między środowiskami
Jeśli ten sam moduł jest używany w wielu środowiskach (np. dev, stage, prod), brak wersjonowania szybko prowadzi do sytuacji, w której każde środowisko ma nieco inną konfigurację. Część „sucha” (kod modułu) jest zmieniana tylko w jednym katalogu envs, a reszta nigdy nie jest aktualizowana.
Prosty schemat ograniczający problem:
- moduły współdzielone trzymane w osobnym repozytorium lub osobnym katalogu z czytelną historią,
- tagi releasów modułów (np.
v1.0.0,v1.1.0) w Git lub Registry, - w środowiskach użycie
sourcez konkretnymreflubversion.
Przykład modułu sieci z Git:
module "network" {
source = "git::https://github.com/org/infra-modules.git//network?ref=v1.3.0"
name = "billing-prod"
cidr_block = "10.20.0.0/16"
public_subnets = var.public_subnets
}
Kiedy wychodzi nowa wersja modułu (v1.4.0), najpierw aktualizowane jest środowisko testowe, sprawdzany jest plan, a dopiero potem – produkcja. Każde środowisko jest świadomie „przestawiane” na nową wersję, zamiast cichego dryfu wynikającego z braku kontroli nad referencją.
Moduły zewnętrzne (community) vs. własne – gdzie kończy się wygoda, a zaczyna kontrola
Publiczne moduły z Terraform Registry, jak terraform-aws-modules/vpc/aws, potrafią zaoszczędzić sporo czasu, ale są bardzo elastyczne i rozbudowane. To daje wygodę, ale też wprowadza sporo ukrytej złożoności.
Sensowny kompromis w większych organizacjach to:
- otoczenie modułu community własnym cienkim wrapperem (modułem „platformowym”),
- zdefiniowanie we wrapperze własnego, uproszczonego interfejsu,
- schowanie części „twardych” decyzji (np. domyślne tagi, naming) do
locals.
Przykład wrappera na VPC:
module "vpc_base" {
source = "terraform-aws-modules/vpc/aws"
version = "5.0.0"
name = var.name
cidr = var.cidr_block
azs = var.azs
public_subnets = var.public_subnet_cidrs
private_subnets = var.private_subnet_cidrs
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(
{
ManagedBy = "terraform"
Owner = var.owner
},
var.tags
)
}
Środowiska używają wyłącznie wrappera, nie bezpośrednio modułu community. Gdy pojawia się potrzeba zmiany wersji albo konwencji, odbywa się to w jednym miejscu, z kontrolą wpływu na całą platformę.
Organizacja stanów Terraform – granice modułów a granice state’u
Podział kodu na moduły to jedno, a podział stanów Terraform (osobne backendy, osobne pliki state) – drugie. Z punktu widzenia dryfu i bezpieczeństwa to podział state’u jest ważniejszy.
Typowy schemat:
- osobny state dla infrastruktury bazowej (np. sieć, VPC, peering),
- osobny state dla warstwy aplikacyjnej każdego systemu,
- dodatkowy state dla elementów współdzielonych (np. IAM, role cross-account).
Dlaczego tak? Kilka powodów:
- mniejsze ryzyko, że ktoś przypadkiem przepchnie
applyi skasuje zasoby krytyczne dla wielu systemów, - szybsze
plan/apply(mniejszy state i graf zależności), - możliwość delegowania uprawnień – zespół aplikacyjny ma dostęp do state’u aplikacji, ale nie do sieci bazowej.
Granice state’u zwykle pokrywają się z granicami katalogów root (czyli miejsc, gdzie wywoływany jest Terraform), a nie z modułami. Moduł nigdy nie trzyma własnego state’u – zawsze korzysta ze state’u modułu root, który go wywołuje.
Import istniejących zasobów do modułów – uporządkowanie „ręcznie” zrobionej infrastruktury
W wielu firmach start wygląda tak samo: część infrastruktury już istnieje (kliknięta w konsoli), a Terraform ma zacząć ją ogarniać. Tutaj kluczowa jest umiejętność wciągnięcia istniejących zasobów do stanu Terraform (terraform import) i poukładania ich w modułach.
Najczęściej zadawane pytania (FAQ)
Co to jest Terraform i do czego służy?
Terraform to narzędzie typu Infrastructure as Code (IaC), w którym infrastrukturę (serwery, sieci, bazy, load balancery, uprawnienia) opisujesz w plikach tekstowych zamiast klikać ją w konsoli chmury. Na podstawie tych deklaracji Terraform tworzy, modyfikuje i usuwa zasoby przez API dostawców (AWS, Azure, GCP, Kubernetes, GitHub, Cloudflare itd.).
Terraform działa deklaratywnie – opisujesz stan docelowy („ma istnieć VPC z dwoma subnetami i jednym load balancerem”), a narzędzie samo wylicza kolejność kroków i konkretne wywołania API. Dzięki temu możesz łatwo odtworzyć identyczne środowisko na innym koncie lub w innym regionie, trzymać infrastrukturę w Git i poddawać ją code review.
Na czym polega Infrastructure as Code (IaC) i czym różni się od klikania w konsoli?
Infrastructure as Code oznacza, że cała infrastruktura jest opisana w repozytorium jako kod (pliki tekstowe), a nie tworzona ręcznie przez interfejs webowy. Zmiany przechodzą ten sam proces co kod aplikacji: PR/MR, code review, testy, historia w Git, możliwość łatwego diffowania dwóch wersji środowiska.
Różnica praktyczna jest taka, że:
- nie tworzysz zasobów pojedynczymi kliknięciami, tylko dodajesz/zmieniasz deklaracje w plikach,
- środowisko da się odtworzyć w powtarzalny sposób (np. „taka sama produkcja, ale w innym regionie”),
- masz pełny audyt: kto, kiedy i dlaczego zmienił konkretną część infrastruktury.
Tip: jeśli czegoś nie jesteś w stanie odtworzyć z repozytorium, to nie jest to jeszcze pełne IaC.
Czym jest drift konfiguracji w Terraform i skąd się bierze?
Drift (konfiguracyjny dryf) to sytuacja, w której stan realnej infrastruktury w chmurze nie zgadza się z tym, co zapisane jest w kodzie Terraform i w pliku stanu (terraform.tfstate). Terraform „myśli”, że zasoby wyglądają tak jak w state, ale w konsoli chmurowej są już inne parametry, dodatkowe reguły albo zupełnie inne zasoby.
Do driftu najczęściej prowadzą:
- ręczne zmiany w konsoli chmury (np. ktoś „na szybko” otwiera port w security group),
- równoległe skrypty/bash, które tworzą lub modyfikują te same zasoby poza Terraform,
- niepoprawne zarządzanie stanem – kilka osób pracuje na lokalnym terraform.tfstate i nadpisuje sobie zmiany,
- zbyt szerokie uprawnienia do konta chmurowego, przez co każdy może coś „podłubać” poza IaC.
Uwaga: drift często wychodzi dopiero przy terraform plan, kiedy widzisz „dziwne” zmiany, których nie oczekiwałeś.
Jak uniknąć driftu konfiguracji w Terraform?
Podstawowa zasada: Terraform ma być jedynym źródłem prawdy dla infrastruktury. To oznacza maksymalne ograniczenie ręcznych zmian w konsoli i innych narzędzi dotykających tych samych zasobów. Jeśli już musisz coś naprawić ręcznie (incydent, awaria), po akcji koniecznie wprowadź tę zmianę do kodu i wyrównaj stan.
W praktyce pomaga:
- praca wyłącznie przez PR/MR do repozytorium Terraform,
- zdalny, współdzielony backend stanu (S3 + DynamoDB lock, GCS, Terraform Cloud itp.),
- twarde ograniczenie uprawnień w chmurze – zespół inżynierski zmienia infrastrukturę przez Terraform, nie przez konsolę,
- regularne terraform plan (np. w CI) wykrywające niespodziewane różnice.
Tip: automatyczne „plan-only” joby w CI to szybki sposób na złapanie driftu zanim zrobi to produkcja.
Co to jest plik stanu terraform.tfstate i dlaczego jest tak ważny?
Plik stanu (terraform.tfstate) przechowuje mapowanie pomiędzy deklaracjami w kodzie a faktycznymi zasobami w chmurze. Dla każdego resource Terraform zapisuje m.in. ID zasobu z chmury i jego atrybuty. Dzięki temu przy kolejnym terraform plan/apply narzędzie wie, co już istnieje, co trzeba zmodyfikować, a czego nie ruszać.
Jeśli stan zostanie utracony lub uszkodzony, Terraform może:
- spróbować utworzyć zasoby, które fizycznie już istnieją,
- usunąć elementy, które „nie istnieją” w state, mimo że są w chmurze,
- wygenerować ogromną liczbę nieoczekiwanych zmian przy kolejnym plan.
Dlatego state trzyma się w zdalnym, zabezpieczonym backendzie z blokadą (locking), a dostęp do niego jest ściśle kontrolowany.
Czym różni się Terraform od Ansible, CloudFormation i Pulumi?
Terraform skupia się na warstwie infrastrukturalnej (sieci, bazy, load balancery, konta, uprawnienia) i działa deklaratywnie w języku HCL. Nie zarządza bezpośrednio konfiguracją wnętrza systemu operacyjnego – do tego częściej używa się Ansible, Chefa czy Puppeta.
W skrócie:
- Terraform – multi‑cloud, deklaratywny, HCL; infrastruktura w różnych chmurach i usługach SaaS,
- CloudFormation – vendor‑specific dla AWS, YAML/JSON; tylko ekosystem AWS,
- Ansible – głównie imperatywny; konfiguracja systemów, pakiety, pliki konfiguracyjne, deploy aplikacji,
- Pulumi – IaC z użyciem języków ogólnego przeznaczenia (TypeScript, Python, Go, C#); model deklaratywno‑imperatywny.
Silny argument za Terraform to spójny model i składnia HCL dla wielu providerów (AWS, Azure, GCP, Kubernetes, GitHub, Datadog itd.), co ułatwia migracje i ogranicza przywiązanie do jednego dostawcy.
Jak wygląda podstawowy workflow pracy z Terraform (init, plan, apply)?
Typowy cykl pracy to:
- terraform init – inicjalizacja katalogu, pobranie providerów, konfiguracja backendu stanu,
- terraform plan – obliczenie różnic między kodem, stanem a realną infrastrukturą; pokazanie, co zostanie utworzone/zmienione/usunięte,
- terraform apply – wykonanie zaplanowanych zmian i aktualizacja pliku stanu.
Dodatkowo jest terraform destroy, który usuwa wszystkie zasoby zarządzane przez dany zestaw konfiguracji. W praktyce w projektach CI/CD najpierw uruchamia się plan (do wglądu i akceptacji w PR), a dopiero po zatwierdzeniu – apply.







Bardzo wartościowy artykuł dla osób, które dopiero zaczynają swoją przygodę z Terraformem. Autor bardzo klarownie i przystępnie wyjaśnia, jak opisać infrastrukturę i jak unikać driftu konfiguracji. Bardzo przydatne jest również omówienie różnic pomiędzy deklaratywnym i imperatywnym podejściem, co pomaga lepiej zrozumieć działanie narzędzia. Jednakże brakuje mi bardziej zaawansowanych przykładów konfiguracji i możliwych pułapek, na jakie można natrafić podczas pracy z Terraformem. Ogólnie polecam ten artykuł jako dobry punkt startowy dla osób zainteresowanych tym tematem.
Komentowanie wymaga aktywnej sesji użytkownika.