⏱️ Lectura: 15 min

On August 10, 2023, HashiCorp changed Terraform’s license from MPL 2.0 to Business Source License, and within weeks a group of companies launched OpenTofu as an open fork under the Linux Foundation. The episode confirmed something infrastructure teams already knew: declaring servers, networks, and databases as infrastructure as code stopped being a curiosity and became the standard way to operate the cloud.

📑 En este artículo
  1. TL;DR
  2. What infrastructure as code is and why it matters
  3. How Terraform works under the hood
  4. Practical examples: from a local file to real infrastructure
  5. How to get started step by step
  6. Real use cases
  7. Common mistakes and best practices
  8. Comparison with alternatives
  9. Going deeper: the provider protocol and the OpenTofu fork
  10. Frequently Asked Questions
    1. Are Terraform and OpenTofu compatible with each other?
    2. Do I need an AWS or GCP account to learn Terraform?
    3. Why shouldn’t I edit the state file by hand?
    4. How do I prevent two people from applying changes at the same time?
    5. Does Terraform install packages inside a server?
    6. What happens if someone deletes a resource manually in the console?
  11. References

This article explains how Terraform and its fork OpenTofu implement that model with the plan/apply cycle, what happens under the hood when you run those commands, and how to write your first reproducible module with progressive code examples.

TL;DR

  • HCL declares the final state of your infrastructure: Terraform calculates the plan to get there, not the manual steps to achieve it.
  • terraform plan compares the code with the state file and shows which resources it will create, change, or destroy before touching anything real.
  • A directed acyclic graph orders resource creation based on their dependencies, without anyone writing that order by hand.
  • The OpenTofu fork keeps Terraform on an open license since HashiCorp migrated to Business Source License in 2023.
  • Remote backends with locking prevent two people from applying changes to the same state at the same time.
  • Each module packages reusable resources: the same code spins up staging and production by just changing variables.
  • terraform import brings resources created manually in the console into the state, without recreating them from scratch.

What infrastructure as code is and why it matters

Infrastructure as code means describing servers, networks, databases, and permissions in versioned text files, instead of creating them with clicks in a web console. Terraform reads those files written in HCL (HashiCorp Configuration Language) and calculates which calls to the cloud provider’s API are needed to make reality match what was declared.

The difference from an imperative script is central. A bash script using the AWS CLI executes steps one by one and doesn’t know what to do if the resource already exists or if someone deleted it manually. Terraform, on the other hand, compares the current state against the desired state on every run and only applies the difference.

The simplest analogy is a cooking recipe versus a diary of what you cooked. An imperative script is the diary: it records the steps you took once, but doesn’t guarantee that repeating them today produces the same result. Terraform is the recipe, it describes the final dish and you can prepare it as many times as you want because the process is idempotent: running apply twice with no changes in between doesn’t duplicate anything.

This brings concrete advantages. Infrastructure changes go through pull requests just like application code, anyone on the team can review a terraform plan before approving it, and reverting broken infrastructure is as simple as running git revert and applying again.

OpenTofu joined the Linux Foundation in 2023 after HashiCorp’s license change. Foto de Luca Bravo en Unsplash

How Terraform works under the hood

Terraform separates the core (Terraform Core) from the providers. Each provider, such as AWS, Google Cloud, Kubernetes, Cloudflare, or GitHub, is an independent binary that Terraform downloads and communicates with through its own protocol over gRPC. The core knows nothing about the AWS API: it asks the AWS provider what to do, and the provider translates that response into real HTTP calls.

When you run terraform plan, the core builds a directed acyclic graph (DAG) with each declared resource and the references between them. If an aws_instance references the id of an aws_subnet, Terraform infers that the subnet must be created first, without anyone writing that dependency by hand. Then it compares that graph against the state file, the JSON file that stores which resources exist and their real attributes.

flowchart TD
    A["HCL Code (.tf)"] --> B["terraform plan"]
    B --> C{"Are there changes?"}
    C -->|"yes"| D["terraform apply"]
    D --> E["Cloud provider (API)"]
    D --> F[("State file")]
    C -->|"no"| G["No changes to apply"]

The result of the plan is a list of actions (create, update, replace, or destroy) that terraform apply executes in the order set by the DAG. Every successful call to the provider updates the state file, so the next plan always starts from real data instead of an assumption.

To inspect that DAG without guessing, terraform graph exports the structure in DOT format, which tools like Graphviz turn into an image. In large projects with hundreds of resources, seeing the actual graph helps catch circular dependencies before the plan fails with a cryptic error.

sequenceDiagram
    participant Dev as Developer
    participant TF as Terraform Core
    participant ST as Remote state
    participant AWS as AWS Provider
    Dev->>TF: terraform apply
    TF->>ST: reads current state
    ST-->>TF: returns existing resources
    TF->>AWS: creates or modifies resources
    AWS-->>TF: confirms the changes
    TF->>ST: writes the new state
    Note over Dev,AWS: the lock prevents another simultaneous apply
📌 Note: the state file isn’t optional or cosmetic. If it’s lost or corrupted, Terraform stops knowing which resources it manages and may try to recreate them from scratch.

Practical examples: from a local file to real infrastructure

The simplest example doesn’t need a cloud account. It declares a local provider and creates a text file to show the full cycle without spending a cent:

terraform {
  required_providers {
    local = {
      source  = "hashicorp/local"
      version = "~> 2.5"
    }
  }
}

resource "local_file" "hello" {
  filename = "${path.module}/hello.txt"
  content  = "Hello from Terraform"
}

When you run terraform apply on this file, Terraform creates hello.txt in the module’s directory and stores that resource in the state. If you run apply again without changing anything, the plan comes back empty because the file already matches what was declared.

The second example gets closer to a real case: an S3 bucket with versioning enabled, using a random suffix to avoid name collisions, because S3 bucket names are global.

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

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

resource "random_id" "suffix" {
  byte_length = 4
}

resource "aws_s3_bucket" "logs" {
  bucket = "programacion-logs-${random_id.suffix.hex}"
}

resource "aws_s3_bucket_versioning" "logs" {
  bucket = aws_s3_bucket.logs.id
  versioning_configuration {
    status = "Enabled"
  }
}

output "bucket_name" {
  value = aws_s3_bucket.logs.bucket
}

This is where dependency inference comes into play. aws_s3_bucket_versioning references aws_s3_bucket.logs.id, so Terraform creates the bucket first and only then enables versioning. Nobody wrote that order, it was inferred from the graph:

flowchart LR
    A["aws_s3_bucket.logs"] --> B["aws_s3_bucket_versioning.logs"]
    C["random_id.suffix"] --> A

After applying, terraform output bucket_name returns the actual bucket name without having to go look for it in the AWS console.

A third example shows why the same code works for both staging and production. A variable controls the instance size without duplicating the file:

variable "environment" {
  type    = string
  default = "staging"
}

resource "aws_instance" "app" {
  ami           = "ami-0c101f26f147fa7fd"
  instance_type = var.environment == "production" ? "t3.large" : "t3.micro"
  tags = {
    Name = "app-${var.environment}"
  }
}

With tofu apply -var="environment=production" a t3.large gets created, and without that flag the default leaves a t3.micro for staging. Same file, two different environments.

How to get started step by step

Installing OpenTofu (or Terraform) is the first step. On macOS and Linux with Homebrew:

brew install opentofu
# alternative: HashiCorp's version
brew install hashicorp/tap/terraform

With the binary installed, the basic workflow comes down to four commands:

tofu init      # downloads providers and configures the backend
tofu plan      # calculates what will change, without applying anything
tofu apply     # executes the plan and updates the state
tofu destroy   # removes everything Terraform manages

init reads the required_providers blocks from the code and downloads the matching binaries into .terraform/providers. By default, the state is stored in a local terraform.tfstate file, which is fine for practicing but risky on a team: two people with the same local file can overwrite each other’s changes.

Working as a team requires a remote backend with locking. Here’s an example with S3 and DynamoDB for locking:

terraform {
  backend "s3" {
    bucket         = "my-company-tfstate"
    key            = "prod/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
  }
}

The DynamoDB table holds a lock for the duration of an apply. If someone else runs apply at the same time, Terraform rejects the operation with a lock error instead of letting two processes write to the same state at once.

To check which resources Terraform manages without leaving the terminal:

tofu state list              # lists the resources in the state
tofu show                    # shows the current attributes
tofu output bucket_name      # prints a specific output
The Terraform Registry publishes providers and modules maintained by the community. Foto de Juanjo Jaramillo en Unsplash

Real use cases

The most common case is separating environments: one directory or workspace per staging and production, reusing the same module with different variables, such as instance size or the number of replicas. That way the code tested in staging is literally the same code applied in production, only the input values change.

Modules published on the Terraform Registry encapsulate entire patterns (a VPC with public and private subnets, a managed Kubernetes cluster, a database with read replicas) so you don’t have to rewrite the same configuration for every project.

In continuous integration, a typical flow runs terraform plan automatically on every pull request and posts the result as a comment, so the team can review what’s about to change before approving:

# .github/workflows/terraform.yml
name: terraform
on: [pull_request]
jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: opentofu/setup-opentofu@v1
      - run: tofu init
      - run: tofu plan -out=tfplan

This workflow runs on every pull request, downloads OpenTofu, and generates the plan as a step before any review, without applying anything automatically. The apply only fires when merging into the main branch, almost always reusing that same saved plan to apply exactly what was reviewed.

Providers aren’t limited to servers. There are providers for configuring GitHub repositories, Cloudflare DNS zones, Datadog dashboards, or Kubernetes policies. Any service with an API can be managed with the same plan/apply flow, which explains why the provider ecosystem grew well beyond traditional clouds.

Common mistakes and best practices

The most frequent mistake is drift: someone modifies a resource by hand in the web console, and the next plan detects an unexpected difference. Terraform doesn’t know whether that manual change was intentional, so it proposes reverting it. The fix isn’t to ban the console (sometimes it’s needed for urgent debugging), but to run plan frequently and use terraform import or terraform state rm when a manual change should be recorded in the code.

⚠️ Watch out: the state file can contain secrets in plain text, including passwords generated by the provider. Never commit it to git, use an encrypted remote backend, and mark sensitive outputs with sensitive = true.

Another common mistake is not pinning provider versions. A required_providers block with no version constraint can pull in a breaking change the next time someone runs init on a new machine, and the plan changes even though nobody touched the application code. Pinning ranges like ~> 5.0 avoids surprises.

Using count to create several similar resources also causes headaches: if you delete an item from the middle of a list, Terraform reindexes and may destroy and recreate resources that didn’t actually change. for_each with a map or a set avoids that problem because each resource is identified by a stable key instead of by its position.

Finally, giant modules with thousands of lines in a single main.tf are hard to review and test. Splitting by responsibility (network, compute, database) and composing smaller modules keeps each pull request readable and makes the plan take less time to calculate the graph.

Comparison with alternatives

ToolWhen to use itAdvantageLimitation
Terraform / OpenTofuMulti-cloud infrastructure with a single plan/apply flowHuge ecosystem of providers and modulesThe state file requires its own management and locking
PulumiTeams who prefer loops, functions, and tests in a real languageTypeScript, Python, or Go instead of HCLFewer mature providers than Terraform
AWS CloudFormation100% AWS shops with no need for multi-cloudNative integration with nothing to installDoesn’t work outside AWS
AWS CDKAWS teams who prefer code over YAML but stay on CloudFormationCompiles to CloudFormation from TypeScript or PythonInherits CloudFormation’s underlying limitations
AnsibleConfiguring the operating system and packages of an already-created serverAgentless, runs over SSHDoesn’t model infrastructure dependencies as a graph

Going deeper: the provider protocol and the OpenTofu fork

Each provider is actually a separate process that Terraform Core launches and talks to through the Terraform Plugin Protocol over gRPC. That separation is why adding support for a new service doesn’t require touching the core: someone writes a provider in Go, publishes it to the registry, and Terraform downloads it on demand with init.

Besides resource IDs, the state file stores a serial number and a lock ID when the backend supports it. That lock is what keeps two concurrent apply runs from corrupting the file: the second process waits or fails with an explicit message instead of overwriting the first one mid-write.

The state file is versioned JSON: every new format includes a terraform_version field and a serial number that increments on every write. Downgrading Terraform after applying with a newer version can fail because the state ends up in a format the older binary doesn’t understand, another reason to pin versions with required_version.

OpenTofu, the fork born after HashiCorp’s license change in 2023, keeps HCL and state format compatibility with Terraform, so most modules and providers work unchanged on both. Over time it started diverging with its own features, like native state file encryption, something Terraform doesn’t offer built in.

💡 Tip: always save the plan before applying it in CI with terraform plan -out=tfplan and then terraform apply tfplan. That way you apply exactly what was reviewed, not a plan recalculated at merge time that could have changed due to external drift.

📖 Summary on Telegram: View summary.

Your next step: install OpenTofu and run tofu init && tofu plan on the S3 bucket example (or the local_file one if you don’t have a cloud account yet) to see the full plan before applying anything.

Frequently Asked Questions

Are Terraform and OpenTofu compatible with each other?

Yes, generally. Both share the same HCL language and the same state format inherited from the moment of the fork, so most modules and providers from the registry work on both without modification. Compatibility could narrow in the future if each project adds exclusive features.

Do I need an AWS or GCP account to learn Terraform?

No. The local provider (files) or the random provider (random values) are enough to practice the full init, plan, and apply cycle without spending a cent or creating a cloud account.

Why shouldn’t I edit the state file by hand?

Because it’s the map that connects each block of your code to the real resource that exists at the provider. Editing it by hand without using Terraform’s commands can break that reference and cause the next apply to try to create a duplicate resource or delete one you still need.

How do I prevent two people from applying changes at the same time?

With a remote backend that supports locking, like S3 combined with a DynamoDB table, or a service like Terraform Cloud that handles it natively. The lock blocks the second apply until the first one finishes.

Does Terraform install packages inside a server?

Not directly. Terraform creates the server (the machine, the disk, the network), but configuring the operating system and packages inside it is usually done with user_data or cloud-init at creation time, or configuration tools like Ansible afterward.

What happens if someone deletes a resource manually in the console?

The next terraform plan detects that the resource no longer exists and proposes recreating it. If the deletion was intentional and shouldn’t come back, you need to remove it from the code and from the state with terraform state rm before applying.

References

📱 Enjoy this content? Follow @programacion on Telegram for daily tech content in Spanish: quick summaries, fresh content every day. @programacion


Javier Alarcón

Infrastructure engineer specializing in networking, Linux systems, Kubernetes, and cloud architectures. Covers hardware, networking, observability, and engineering practices for production teams.

0 Comments

Leave a Reply

Avatar placeholder

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.