Ir para o conteúdo principal
  1. Ens/
  2. Posts/

Terraform: Complete Guide — Syntax, Providers, Environments, State & Workflows

·2091 palavras·10 minutos
Abraao Rocha de Paula
Autor
Abraao Rocha de Paula
Comparo ferramentas DevOps pra te auxiliar a tomar a melhor decisão.
IaC Tools - Este artigo faz parte de uma série de artigos.
Parte 2: Esse Artigo

TL;DR
#

TopicShort answer
SyntaxDeclarative HCL: resource, variable, output, module
ProvidersPlugins that translate HCL into API calls (AWS, GCP, Azure, etc.)
ModulesCode reuse — your own or from the Terraform Registry
tfvars.tfvars files separate environment values from infrastructure logic
EnvironmentsUse workspaces (simple) or file structure (recommended for teams)
Trunk-basedFrequent commits to main, feature flags to control environments
Branch-basedBranches per environment (staging, prod), risk of merge hell
State.tfstate + remote backend (S3+GCS+Azure) with locking
Driftterraform plan detects; terraform apply -refresh-only reconciles

1. Terraform Syntax in 5 Minutes
#

Terraform uses HCL (HashiCorp Configuration Language), a declarative language where you describe the desired state of your infrastructure, not the steps to get there. If you’ve never seen HCL before, relax — it was designed to be read by humans, not robots.

The four blocks you’ll use every single day:

# 1. Provider — who Terraform talks to
provider "aws" {
  region = "us-east-1"
}

# 2. Variable — parameterizable values
variable "instance_type" {
  description = "EC2 instance type"
  type        = string
  default     = "t2.micro"
}

# 3. Resource — what you want to create
resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = var.instance_type

  tags = {
    Name = "web-server"
  }
}

# 4. Output — values exported after apply
output "instance_ip" {
  value = aws_instance.web.public_ip
}

A few expressions that save time:

# Variable interpolation
name = "app-${var.environment}"

# Ternary conditional (yes, HCL has them)
instance_type = var.env == "prod" ? "t2.large" : "t2.micro"

# Loop with for_each
resource "aws_s3_bucket" "buckets" {
  for_each = toset(["logs", "backups", "media"])
  bucket   = "${each.key}-${var.project}"
}

And the commands you’ll type constantly:

terraform init       # Downloads providers and modules
terraform plan       # Preview what will change (doesn't apply)
terraform apply      # Applies the changes
terraform destroy    # Removes everything
terraform fmt        # Formats HCL code
terraform validate   # Validates syntax

The basic flow is init → plan → apply. Never apply without seeing the plan first. I’ve seen people nuke production because they trusted -auto-approve. Don’t be that person.


2. Providers: The Bridge Between Code and Cloud
#

Terraform doesn’t create anything by itself. It delegates execution to providers — plugins that translate HCL into real API calls.

Think of it this way: when you declare provider "aws", Terraform downloads the AWS provider binary and uses it to authenticate and call the AWS API. Each resource belongs to a provider (aws_instanceaws provider).

The providers you’ll see most often:

ProviderUse case
awsEC2, S3, RDS, Lambda, VPC, IAM
azurermAzure VMs, Storage, AKS, CosmosDB
googleGCP Compute, GKE, Cloud Storage
kubernetesDeployments, Services, ConfigMaps on K8s
helmHelm charts managed as code
cloudflareDNS, Workers, Pages
githubRepositories, teams, branch protection
datadogDashboards, monitors, alerts

The Terraform Registry has over 3,000 providers. You’ll rarely need something that isn’t there.

Something that took me a while to learn and is genuinely useful: provider aliases. When you need to manage multiple regions or AWS accounts in the same code:

provider "aws" {
  region = "us-east-1"
}

provider "aws" {
  alias  = "west"
  region = "us-west-2"
}

resource "aws_instance" "west_server" {
  provider = aws.west   # Uses the west coast provider
  # ...
}

3. Modules: Stop Copying and Pasting
#

Modules are like functions for infrastructure. Instead of repeating 50 lines of VPC config in every project, you call a module.

Registry Modules
#

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.0.0"

  name = "my-vpc"
  cidr = "10.0.0.0/16"

  azs             = ["us-east-1a", "us-east-1b"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24"]
}

Three precautions I’d take with third-party modules:

  • Always pin the version (version = "5.0.0"), never use latest. Breaking changes in popular modules are more common than you’d think.
  • Read the module’s source code before using it in production. Popular doesn’t mean well-written.
  • If the module is critical, fork it. Better to depend on your own repo than wake up on a Sunday to a deprecated module.

Your Own Modules
#

A convention that works well:

modules/
├── networking/
│   ├── main.tf
│   ├── variables.tf
│   └── outputs.tf
├── compute/
│   ├── main.tf
│   ├── variables.tf
│   └── outputs.tf
└── database/
    ├── main.tf
    ├── variables.tf
    └── outputs.tf
module "database" {
  source = "./modules/database"

  engine         = "postgres"
  instance_class = "db.t3.medium"
  environment    = var.environment
}

The main.tf (resources), variables.tf (inputs), and outputs.tf (returns) split isn’t mandatory, but teams with more than two people will thank you.


4. tfvars: Code is Logic, tfvars is Configuration
#

.tfvars files assign values to variables without touching the code. This separates infrastructure logic from environment-specific values.

# variables.tf — declares variables and defaults
variable "environment" {
  type    = string
  default = "dev"
}

variable "instance_count" {
  type    = number
  default = 1
}
# staging.tfvars
environment    = "staging"
instance_count = 2
# production.tfvars
environment    = "prod"
instance_count = 5

Usage:

terraform plan  -var-file="staging.tfvars"
terraform apply -var-file="staging.tfvars"
terraform plan  -var-file="production.tfvars"
terraform apply -var-file="production.tfvars"

Never commit secrets in .tfvars. Use sensitive = true on the variable and pass values via environment variables (TF_VAR_db_password) or a secrets manager. Create a terraform.tfvars.example with dummy values for team onboarding. And add *.tfvars (except .example) to .gitignore.


5. Environments in Terraform: Which Strategy to Use?
#

Managing dev, staging, and production with Terraform has three main approaches.

Workspaces (the simplest)
#

terraform workspace new staging
terraform workspace new production

terraform workspace select staging
terraform apply -var-file="staging.tfvars"

One backend, multiple state files. Fast and native, but all environments share the same backend and credentials. One terraform destroy on the wrong workspace and you’ve deleted production. I’ve seen it happen.

File Structure (my recommendation for teams)
#

terraform/
├── environments/
│   ├── dev/
│   │   ├── main.tf
│   │   ├── terraform.tfvars
│   │   └── backend.tf
│   ├── staging/
│   │   ├── main.tf
│   │   ├── terraform.tfvars
│   │   └── backend.tf
│   └── prod/
│       ├── main.tf
│       ├── terraform.tfvars
│       └── backend.tf
└── modules/
    ├── networking/
    ├── compute/
    └── database/

Each environment gets its own directory, its own state, and its own credentials. Real isolation. The cost is a bit of code duplication, but for teams of 2–10 people it’s well worth it.

Terragrunt (for larger teams)
#

Terragrunt is a wrapper that eliminates duplication while maintaining isolation:

# terragrunt.hcl
terraform {
  source = "git::git@github.com:org/modules.git//networking?ref=v1.0.0"
}

inputs = {
  environment = "staging"
  cidr_block  = "10.1.0.0/16"
}

Quick summary:

StrategyTeam sizeRiskComplexity
Workspaces1-2 peopleMediumLow
File Structure2-10 peopleLowMedium
Terragrunt10+ peopleLowHigh

My suggestion: start with file structure. If the team grows to the point where duplication hurts, migrate to Terragrunt.


6. Trunk-Based Development with Terraform
#

Trunk-based development (TBD) means committing directly to main several times a day, using feature flags and branch protection rules to control deploys.

main ──●──●──●──●──●──●──●── (frequent commits)
       │       │
       ▼       ▼
    [plan]  [plan]
       │       │
       ▼       ▼
    staging  production

In practice:

# 1. Change staging tfvars
vim environments/staging/terraform.tfvars

# 2. Commit directly to main
git add . && git commit -m "staging: bump instance_count to 3"
git push origin main

# 3. CI detects the push, runs plan on staging
# 4. Clean plan → CI applies to staging
# 5. Validated in staging → promote to prod (same code, different tfvars)

A sample pipeline with GitHub Actions:

name: Terraform CI
on:
  push:
    branches: [main]

jobs:
  terraform:
    strategy:
      matrix:
        environment: [staging, production]
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3

      - name: Terraform Plan
        working-directory: environments/${{ matrix.environment }}
        run: terraform plan -input=false

      - name: Terraform Apply (prod only with manual approval)
        if: matrix.environment == 'staging' || github.event_name == 'workflow_dispatch'
        working-directory: environments/${{ matrix.environment }}
        run: terraform apply -auto-approve -input=false

The advantages of TBD are clear: no long-lived branches, no merge hell, main always reflects what is (or will be) in production, and every small change gets reviewed and deployed fast.

Just watch out for three things: feature flags — if a feature is incomplete, control it with a conditional variable, not a separate branch. Production protection — require manual approval before applying to prod. And tests in CIterraform validate and terraform fmt -check on EVERY commit.


7. Branch-Based Development: The Traditional Model
#

In the branch-based model, each environment gets a long-lived branch:

main ──────●────────────●────────────●── (production)
            \\          \\          \\
staging ────●───●──●────●────────────●── (staging)
                  \\
dev ──────────────●──●──●──●──●──●──●── (development)

Typical flow:

git checkout dev
# ... change resources ...
git commit -m "add redis cache"

git checkout staging
git merge dev
terraform plan && terraform apply

git checkout main
git merge staging
terraform plan && terraform apply

Looks organized, but it brings some real problems:

ProblemWhy it hurts
Merge helldev and staging branches diverge. Resolving conflicts in HCL isn’t like resolving them in Python.
State vs BranchThe state file doesn’t know branches exist. If dev and staging point to the same backend, chaos is guaranteed.
Manual promotionRelies on someone remembering to merge. Fragile and error-prone automation.
Illusion of isolationThe branch is separate but the state isn’t. Without file structure, branches are cosmetic.

Branch-based still makes sense for small teams (1–3 people) without mature CI/CD, or in monorepos where Terraform is just one piece. But honestly, most teams are migrating to trunk-based with file structure — real isolation and continuous deployment.


8. State and Drift: Where the Magic Happens (and the Problems Too)
#

The State File
#

The .tfstate is a JSON file that maps declared resources in .tf to actual cloud resources. Without it, Terraform wouldn’t know what to destroy or modify.

main.tf ──► terraform apply ──► aws_instance.web (i-0a1b2c3d4e5f)
                                   └── terraform.tfstate
                                       resource "aws_instance" "web" {
                                         id = "i-0a1b2c3d4e5f"
                                         ...
                                       }

Remote Backend (mandatory if you’re more than one person)
#

State should never be local. Use a remote backend with locking:

terraform {
  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "prod/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
  }
}
BackendLockingCost
S3Yes (native)Low
GCSYes (native)Low
Azure StorageYes (lease)Low
HCP TerraformYes (native)Free tier

Golden rule: two people should never apply to the same state at the same time. Locking prevents this, but only if you configure it properly.

Drift: When Reality Diverges from Code
#

Drift happens when someone changes a resource outside Terraform (console, CLI, another team). The state gets stale and you only find out when something breaks.

terraform plan -detailed-exitcode
# exit 0 = everything in sync
# exit 1 = error
# exit 2 = drift detected

To fix it:

Reconcile (import the change):

terraform apply -refresh-only   # Updates state without touching infra
terraform plan                   # Check if things match — if not, adjust .tf and apply

Override (force the code):

terraform apply   # Changes infra back to what's in code

For prevention, the combo that works: block write access on cloud consoles (IAM read-only for humans), every change goes through Terraform, and run plan in CI periodically — a daily cron job that alerts on drift. Simple and effective.


9. A Structure That Scales
#

Pulling everything together, this structure works from 1 to 50 people:

terraform/
├── environments/
│   ├── dev/
│   │   ├── backend.tf
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   ├── outputs.tf
│   │   └── terraform.tfvars
│   ├── staging/
│   │   ├── backend.tf
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   ├── outputs.tf
│   │   └── terraform.tfvars
│   └── prod/
│       ├── backend.tf
│       ├── main.tf
│       ├── variables.tf
│       ├── outputs.tf
│       └── terraform.tfvars
├── modules/
│   ├── networking/
│   ├── compute/
│   ├── database/
│   └── monitoring/
├── .github/
│   └── workflows/
│       ├── terraform-plan.yml
│       ├── terraform-apply.yml
│       └── drift-detection.yml
├── .gitignore
├── .terraform-version
└── README.md

Wrapping Up
#

Terraform is massive, but the essence comes down to six concepts:

  1. HCL describes — you declare what you want, Terraform figures out how to get there
  2. Providers execute — the engine that turns code into real resources
  3. Modules organize — code reuse, yours or from the Registry
  4. tfvars separate — code is logic, tfvars is environment configuration
  5. Environments isolate — file structure > workspaces for serious teams
  6. State is truth — protect with remote backend + locking, monitor drift

If there’s one tip I’d give to beginners: start small and iterate. Create a simple EC2 instance, then add a module, then separate environments. Don’t try to build the perfect structure on day 1 — it will evolve as your team grows.


This article is part of the IaC Tools series. The previous one was Terraform vs Pulumi: Which One to Choose in 2026?.

Questions or suggestions? Find me on LinkedIn.

IaC Tools - Este artigo faz parte de uma série de artigos.
Parte 2: Esse Artigo