Imagine setting up your cloud by hand: you log into the AWS console, click "create server", "create storage", "open port". It works — as long as you have few servers. Once there are dozens of them, and several environments (staging, production), you can no longer reproduce it identically by hand: somewhere you forgot a checkbox, somewhere the region is wrong. Terraform solves this pain: you describe the infrastructure you need as text in a file, and Terraform creates it in the cloud itself — identically, as many times as you want.
Terraform is an infrastructure as code tool: instead of clicking, you write configuration, put it in git next to your application, and apply it with a command. Its main strength is that it works with almost any cloud (AWS, Google Cloud, Azure) and hundreds of other systems through a single language. If you are just getting familiar with why all this is needed, start with the article infrastructure as code: the basics. Here we cover Terraform itself, with AWS examples.
What Terraform and HCL Are
Terraform reads files with the .tf extension, written in the HCL language (HashiCorp Configuration Language) — Terraform's own configuration language. HCL is simple: the entire configuration consists of blocks. A block is a type, optional labels in quotes, and a body in curly braces.
block_type "label1" "label2" {
argument = value
}
The main thing a beginner needs to understand: HCL is declarative. You describe not "how to do it" (create, then modify, then delete), but "what the end result should be". Terraform figures out the difference between what is described and what already exists in the cloud, and performs only the necessary steps. It is like a shopping list: you write down what should be in the fridge, not the route through the store.
Any Terraform project usually starts with a terraform {} block, which specifies which providers and what version are needed:
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.40"
}
}
}
Providers
Terraform by itself does not know what an "AWS server" or "S3 bucket" is. That is the job of providers — plugins that translate your blocks into API calls to a specific cloud. The AWS provider knows how to work with AWS, the Google Cloud provider with Google, and so on. An analogy: Terraform is the remote control, and the provider is the adapter for a specific outlet.
A provider is configured with a separate provider block. For AWS it is often enough to specify the region:
provider "aws" {
region = "us-east-1"
}
The same provider can be configured multiple times — for example, to create resources in two regions. To do this, the second instance is given an alias via alias:
provider "aws" {
region = "us-east-1"
}
provider "aws" {
alias = "west"
region = "us-west-2"
}
Exactly which AWS resources exist and why is a separate large topic; start with the overview AWS fundamentals.
Resources and Dependencies
A resource is a specific infrastructure object: a storage bucket, a virtual server, a database table. It is described with a resource block with two labels: the resource type and your internal name (you will use it to reference the resource within the configuration).
resource "aws_s3_bucket" "my_bucket" {
bucket = "my-company-app-data-2026"
tags = {
Environment = "production"
Team = "backend"
}
}
Here aws_s3_bucket is the type (a bucket in S3 object storage), my_bucket is the name for references within the code, and bucket is the real bucket name in AWS.
Often one resource depends on another: for example, an access policy cannot be created until the bucket itself exists. Terraform determines such dependencies automatically — by references. When you mention one resource inside another in the form type.name.attribute, Terraform understands that the first one must be created first:
resource "aws_s3_bucket" "my_bucket" {
bucket = "my-company-app-data-2026"
}
resource "aws_s3_bucket_versioning" "versioning" {
bucket = aws_s3_bucket.my_bucket.id
versioning_configuration {
status = "Enabled"
}
}
The reference aws_s3_bucket.my_bucket.id means "take the id of the my_bucket bucket" — and at the same time tells Terraform: create the bucket first. If a dependency exists but there is no direct reference (a hidden connection), you specify it explicitly via depends_on:
resource "aws_s3_bucket_policy" "policy" {
bucket = aws_s3_bucket.my_bucket.id
policy = "..."
depends_on = [aws_s3_bucket_versioning.versioning]
}
The init, plan, apply, destroy Cycle
Working with Terraform is four commands that you run in sequence.
terraform init— prepares the folder for work. It downloads the specified providers (that same AWS plugin) and sets up state storage. Run it once at the start, and again if you change providers or the backend.
terraform init
terraform plan— a dry run. It shows what exactly will change, touching nothing. In the output,+means create,~means modify,-means delete. This is your safety net: always look at the plan before applying.
terraform plan
terraform apply— applies changes in the real cloud. First it shows the plan again and asks for confirmation — you need to typeyes.
terraform apply
terraform destroy— deletes all the infrastructure managed by this configuration. Useful for temporary test environments so you do not pay for unused resources. It also asks for confirmation.
terraform destroy
Easy to remember: init — get ready, plan — take a look, apply — do it, destroy — tear it down.
State and Where It Lives
To understand the difference between "what is described" and "what is already created", Terraform keeps state — a file that records which real resources correspond to your blocks. By default this is a local file terraform.tfstate in the project folder.
A local file is fine as long as you work alone. As soon as a team works on the infrastructure, local state becomes a problem: everyone has their own copy, they diverge, and if two people run apply at the same time — the state can get corrupted. The solution is a remote backend: the state is stored in a shared place, not on someone's laptop.
The classic option on AWS is the s3 backend: the state itself lives in an S3 bucket, and DynamoDB is used for locking, so that two people do not change the state at the same time.
terraform {
backend "s3" {
bucket = "my-terraform-state-bucket"
key = "my-app/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
Here bucket and key are where the state file lives, dynamodb_table is the table for locking (it must have a LockID key of type String), and encrypt means encrypt the state at rest. Starting with Terraform 1.10, native locking appeared directly in S3 without DynamoDB — the use_lockfile = true flag; the DynamoDB variant will be removed over time, but it is found everywhere in existing projects, so it is worth knowing. State often contains sensitive data (for example, passwords), so the shared bucket must be locked down by permissions — for access control, see IAM in AWS.
Modules
When a configuration grows, copying the same blocks into different environments becomes tedious and dangerous. A module is a reusable set of .tf files, like a function in regular code: you describe it once, then call it with different parameters. Any folder with .tf files is already a module; you call it with a module block with the mandatory source argument — where to get the code.
module "app_storage" {
source = "./modules/storage"
bucket_name = "my-company-app-data-2026"
environment = "production"
}
source specifies the path: a local folder (./modules/storage), a git repository, or the public Terraform Registry, where thousands of ready-made modules are hosted. This way you call one module from both the test and production environments with different values — and the infrastructure is guaranteed to be the same shape.
module "test_storage" {
source = "./modules/storage"
bucket_name = "my-app-test"
environment = "test"
}
module "prod_storage" {
source = "./modules/storage"
bucket_name = "my-app-prod"
environment = "production"
}
Where It Is Used
Terraform is found almost everywhere there is a cloud: teams keep the description of their infrastructure in git and apply it automatically from the build pipeline. If you are setting up CI/CD, the terraform plan and terraform apply steps are usually built into the pipeline — for how pipelines are structured, read CI/CD pipeline principles, and for rollout — release strategies. Often Terraform brings up a Kubernetes cluster, and applications are deployed and configured inside the cluster with other tools.
Typical beginner mistakes:
- Applying without
plan. Always look at the plan first —applychanges the real cloud and costs money. - Not storing state remotely in a team. A local
terraform.tfstateis unique to each person — environments will diverge. Switch to a remote backend as soon as there is more than one person on the project. - Editing resources by hand in the cloud console. Then reality diverges from state (this is called drift), and the next
applymay break everything. Change infrastructure only through Terraform. - Putting state in git. It sometimes contains secrets, and git provides no locking. State belongs in S3 with encryption, not in the repository.
What to learn next: Terraform is not the only infrastructure-as-code tool. AWS has its own native CloudFormation and the more "programmer-oriented" AWS CDK. It is useful to understand state management and delivery in general, as well as to compare approaches in the overview infrastructure as code: the basics. Once you are comfortable with basic resources, look at how Terraform brings up compute and managed databases.