← Back
DevOps 21 min read

This article isn’t translated yet — showing the Spanish original.

Terraform desde cero: infraestructura que podés leer, versionar y reproducir

Terraform no es solo una herramienta para crear servidores. Es el lenguaje con el que describís cómo debería verse tu infraestructura — y Terraform se encarga de que así sea, siempre.

Data verified on 10 August 2026. Provider pricing, limits and flag names change.

Terraform es la herramienta de Infrastructure as Code de HashiCorp. No es la única — CloudFormation, Pulumi y Ansible existen — pero es la más adoptada por una razón concreta: funciona con casi cualquier proveedor, el lenguaje es legible, y el modelo de "declarar qué querés y dejar que Terraform lo construya" es difícil de superar en claridad.

Hay una bifurcación que conviene conocer antes de elegir. En agosto de 2023 HashiCorp cambió la licencia de Terraform de open source a BUSL, y de ahí salió OpenTofu: un fork bajo la Linux Foundation, impulsado por Gruntwork, Spacelift, Harness, env0 y Scalr, que se presenta como reemplazo directo y conserva los mismos workflows y archivos de configuración. Todo lo que sigue en este artículo aplica a los dos: el lenguaje es el mismo, y tofu acepta los comandos donde dice terraform.

La idea central es declarativa: describís el estado deseado de tu infraestructura, no los pasos para llegar ahí. Terraform se encarga de los pasos.

El modelo mental: cuatro conceptos que lo explican todo

Providers

Un provider es el plugin que Terraform usa para hablar con una API. AWS, GCP, Azure, Cloudflare, GitHub, Vercel, Kubernetes — cada uno tiene su provider. El mismo lenguaje HCL funciona para todos.

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

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

Resources

Un resource es cualquier objeto de infraestructura que Terraform gestiona: una instancia EC2, un bucket S3, un registro DNS, una regla de firewall. Es la unidad básica del lenguaje.

resource "aws_s3_bucket" "mi_bucket" {
  bucket = "mi-proyecto-archivos-2025"
}

El formato es siempre resource "tipo" "nombre_local". El tipo determina qué se crea; el nombre local es cómo lo referenciás dentro del mismo archivo de configuración.

State

Terraform mantiene un archivo de estado (terraform.tfstate) que mapea lo que declaraste con lo que realmente existe en el proveedor. Es la memoria de Terraform.

Sin el estado, Terraform no sabría qué ya existe y qué hay que crear. Cada terraform apply compara tu configuración actual con el estado guardado y calcula los cambios mínimos necesarios.

Plan / Apply

El flujo de trabajo de Terraform tiene tres comandos core:

terraform init     # descarga los providers y configura el backend
terraform plan     # muestra qué va a crear, modificar o destruir
terraform apply    # ejecuta los cambios (pide confirmación)

terraform plan es la herramienta de debugging más valiosa. Nunca corrás apply sin revisar el plan. Un + es creación, ~ es modificación, - es eliminación.

Primer proyecto real: servidor web en AWS

Vamos a construir algo concreto: una instancia EC2 accesible desde internet con un security group que solo permite tráfico HTTP y SSH. Es el "hola mundo" de infraestructura en AWS.

Estructura de archivos

mi-infra/
├── main.tf          # recursos principales
├── variables.tf     # inputs del módulo
├── outputs.tf       # valores que querés exponer al final
└── terraform.tfvars # valores concretos para las variables

No existe una estructura obligatoria — Terraform carga todos los .tf del directorio. Pero esta separación es la convención que adoptó la comunidad y la que vas a encontrar en cualquier proyecto serio.

variables.tf

variable "region" {
  description = "Región de AWS donde se despliega todo"
  type        = string
  default     = "us-east-1"
}

variable "instance_type" {
  description = "Tipo de instancia EC2"
  type        = string
  default     = "t3.micro"
}

variable "project_name" {
  description = "Nombre del proyecto, usado para tagear recursos"
  type        = string
}

Las variables son los parámetros de tu infraestructura. Las que tienen default son opcionales. Las que no lo tienen son obligatorias — Terraform te las pide al correr apply si no las pasás.

main.tf

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

provider "aws" {
  region = var.region
}

# Security Group: qué tráfico puede entrar y salir
resource "aws_security_group" "web" {
  name        = "${var.project_name}-sg"
  description = "Permite HTTP y SSH"

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"] # en producción: solo tu IP
  }

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

  tags = {
    Name    = "${var.project_name}-sg"
    Project = var.project_name
  }
}

# Instancia EC2
resource "aws_instance" "web" {
  ami                    = "ami-0c02fb55956c7d316" # Amazon Linux 2, us-east-1
  instance_type          = var.instance_type
  vpc_security_group_ids = [aws_security_group.web.id]

  user_data = <<-EOF
    #!/bin/bash
    yum update -y
    yum install -y httpd
    systemctl start httpd
    systemctl enable httpd
    echo "<h1>Deployed via Terraform</h1>" > /var/www/html/index.html
  EOF

  tags = {
    Name    = "${var.project_name}-server"
    Project = var.project_name
  }
}

Hay algo importante en este archivo: aws_security_group.web.id. Esto es una referencia a otro recurso — Terraform sabe que el security group tiene que existir antes de crear la instancia, y construye el grafo de dependencias automáticamente. No tenés que especificar el orden.

outputs.tf

output "public_ip" {
  description = "IP pública de la instancia"
  value       = aws_instance.web.public_ip
}

output "public_dns" {
  description = "DNS público para acceder al servidor"
  value       = aws_instance.web.public_dns
}

Los outputs son los valores que Terraform imprime al terminar el apply. También los podés consultar después con terraform output.

terraform.tfvars

project_name  = "mi-web"
region        = "us-east-1"
instance_type = "t3.micro"

Los .tfvars son los valores concretos. La separación entre variables.tf (qué se puede parametrizar) y terraform.tfvars (con qué valores) es lo que hace que la misma configuración pueda usarse para distintos entornos.

Ejecutarlo

# 1. Inicializá el proyecto (descarga el provider de AWS)
terraform init

# 2. Revisá qué va a crear
terraform plan -var-file="terraform.tfvars"

# 3. Aplicá
terraform apply -var-file="terraform.tfvars"

# Cuando ya no lo necesitás:
terraform destroy -var-file="terraform.tfvars"

El plan te muestra algo como esto:

Plan: 2 to add, 0 to change, 0 to destroy.

+ aws_security_group.web
+ aws_instance.web

Leé siempre el plan. Un - aws_instance.web inesperado puede significar que Terraform va a destruir y recrear tu servidor de producción.

El estado: lo que hace que todo funcione (y lo que puede salir mal)

El estado es el archivo más importante de cualquier proyecto Terraform. Contiene la referencia de cada recurso que Terraform gestiona — su ID en AWS, sus propiedades actuales, sus dependencias.

Problema uno: el estado local. Por defecto, terraform.tfstate se guarda en tu máquina. Si trabajás en equipo o desde dos máquinas, el estado se desincroniza y las cosas se rompen. La solución es un backend remoto.

Problema dos: el estado contiene secretos. Si un recurso tiene una contraseña o un token como atributo, aparece en el estado en texto plano. El estado nunca va al repositorio de Git.

Backend remoto: S3, y el lock que ya no necesita DynamoDB

El backend estándar en AWS guarda el estado en S3. Falta una pieza más: el lock, que evita que dos apply simultáneos escriban el mismo estado y lo dejen inconsistente.

Durante años eso se resolvía con una tabla de DynamoDB. Hoy no hace falta: S3 hace el lock de forma nativa con use_lockfile, y la documentación del backend marca dynamodb_table como deprecado y anuncia que se va a remover.

terraform {
  backend "s3" {
    bucket       = "mi-empresa-terraform-state"
    key          = "mi-proyecto/terraform.tfstate"
    region       = "us-east-1"
    use_lockfile = true
    encrypt      = true
  }
}

Con este bloque, terraform init configura el backend remoto. El estado se guarda en S3, versionado, encriptado, y con lock automático.

Si heredás un proyecto con dynamodb_table, no urge migrarlo — sigue funcionando — pero conviene saber que es una tabla facturable que dejó de ser necesaria.

Variables: tipos, validación y sensibilidad

Las variables tienen más opciones de las que muestra el ejemplo básico.

variable "environment" {
  description = "Ambiente de despliegue"
  type        = string

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "El ambiente debe ser dev, staging o prod."
  }
}

variable "db_password" {
  description = "Contraseña de la base de datos"
  type        = string
  sensitive   = true  # no aparece en el plan ni en los logs
}

variable "allowed_ips" {
  description = "IPs que pueden acceder al servidor"
  type        = list(string)
  default     = []
}

Las variables sensitive = true son críticas para secretos. Terraform las oculta del output del plan y del apply, aunque siguen apareciendo en el estado — motivo de más para encriptar el backend.

Módulos: la reutilización en Terraform

Un módulo es un conjunto de recursos que agrupás para reutilizar. La misma VPC que necesitás en dev, staging y prod puede vivir en un módulo y llamarse tres veces con distintos parámetros.

Estructura de un módulo

modules/
└── vpc/
    ├── main.tf
    ├── variables.tf
    └── outputs.tf
# modules/vpc/main.tf
resource "aws_vpc" "this" {
  cidr_block           = var.cidr_block
  enable_dns_hostnames = true

  tags = {
    Name        = var.name
    Environment = var.environment
  }
}

resource "aws_subnet" "public" {
  vpc_id            = aws_vpc.this.id
  cidr_block        = var.public_subnet_cidr
  availability_zone = "${var.region}a"
}

Usando el módulo

# main.tf raíz
module "vpc_dev" {
  source = "./modules/vpc"

  name               = "mi-proyecto-dev"
  environment        = "dev"
  cidr_block         = "10.0.0.0/16"
  public_subnet_cidr = "10.0.1.0/24"
  region             = var.region
}

module "vpc_prod" {
  source = "./modules/vpc"

  name               = "mi-proyecto-prod"
  environment        = "prod"
  cidr_block         = "10.1.0.0/16"
  public_subnet_cidr = "10.1.1.0/24"
  region             = var.region
}

La misma lógica, dos VPCs distintas, cero duplicación. Los módulos también pueden venir de fuentes externas:

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

  name = "mi-vpc"
  cidr = "10.0.0.0/16"
  # ...
}

El Terraform Registry tiene módulos mantenidos por la comunidad para casi cualquier recurso de AWS. No reinventés la VPC.

Workspaces: un estado por ambiente

Los workspaces son una forma de tener múltiples estados dentro de la misma configuración. Cada workspace tiene su propio terraform.tfstate.

terraform workspace new dev
terraform workspace new staging
terraform workspace new prod

terraform workspace select dev
terraform apply   # crea infraestructura en el workspace "dev"

terraform workspace select prod
terraform apply   # crea infraestructura separada en "prod"

Dentro de la configuración podés usar terraform.workspace para parametrizar por ambiente:

locals {
  instance_type = terraform.workspace == "prod" ? "t3.medium" : "t3.micro"

  tags = {
    Environment = terraform.workspace
    ManagedBy   = "terraform"
  }
}

resource "aws_instance" "app" {
  instance_type = local.instance_type
  tags          = local.tags
  # ...
}

Cuándo usar workspaces vs. directorios separados. Para proyectos pequeños, workspaces son suficientes. Para organizaciones más grandes, muchos equipos prefieren directorios separados por ambiente (infra/dev/, infra/prod/) porque el aislamiento es más explícito y un apply en el workspace equivocado no puede afectar producción.

Locals: cálculos internos sin exponer variables

Los locals son como variables internas de la configuración — útiles para expresiones que usás múltiples veces o para mantener la lógica centralizada.

locals {
  common_tags = {
    Project     = var.project_name
    Environment = var.environment
    ManagedBy   = "terraform"
    CreatedAt   = "2025"
  }

  bucket_name = "${var.project_name}-${var.environment}-assets"
}

resource "aws_s3_bucket" "assets" {
  bucket = local.bucket_name
  tags   = local.common_tags
}

Data Sources: leer lo que existe sin crearlo

Los data sources te permiten traer información de recursos que existen pero que Terraform no gestiona — por ejemplo, una AMI actualizada, una VPC existente, o una zona DNS ya creada.

# Trae la AMI más reciente de Amazon Linux 2
data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["amzn2-ami-hvm-*-x86_64-gp2"]
  }
}

resource "aws_instance" "web" {
  ami           = data.aws_ami.amazon_linux.id  # siempre la AMI más reciente
  instance_type = "t3.micro"
}

Los data sources resuelven el problema de hardcodear IDs que cambian — AMIs, IDs de zonas, ARNs de recursos compartidos.

Buenas prácticas antes de llegar a producción

Nunca corras apply sin revisar el plan. Los cambios destructivos (-) son los que rompen producción. Un plan de 30 segundos evita horas de recovery.

El estado no va a Git. Nunca. Usá un backend remoto desde el primer día, incluso en proyectos personales.

Versioná los providers. Sin version = "~> 5.0" en required_providers, una actualización del provider puede romper tu configuración silenciosamente.

Usá terraform fmt antes de cada commit. Terraform tiene un formateador estándar. Úsalo. La consistencia en HCL es la diferencia entre un directorio legible y uno que nadie quiere tocar.

terraform fmt -recursive   # formatea todos los .tf del directorio
terraform validate         # valida que la sintaxis sea correcta

Protegé los recursos críticos de destrucción accidental.

resource "aws_db_instance" "main" {
  # ...

  lifecycle {
    prevent_destroy = true  # terraform destroy fallará si intenta borrar esto
  }
}

Separé los recursos con distinto ciclo de vida. La base de datos y el servidor de aplicación no deberían estar en el mismo directorio de Terraform si tienen distintas frecuencias de cambio. Un apply inocente en la capa de aplicación no debería tener la posibilidad de tocar la base de datos.

Cuándo Terraform es la herramienta correcta

Terraform brilla en:

  • Infraestructura multi-ambiente que necesita ser reproducible
  • Equipos donde más de una persona gestiona infraestructura
  • Proyectos donde los recursos tienen dependencias entre sí
  • Cuando necesitás auditoría — el historial de Git es el registro de cada cambio

No es la herramienta correcta para:

  • Scripts de configuración de software dentro del servidor (para eso es Ansible)
  • Orquestación de contenedores a nivel de aplicación (para eso es Kubernetes o ECS)
  • Cambios de emergencia que necesitás aplicar en segundos (el plan + apply tiene overhead)

El error más común al empezar con Terraform es querer meter todo en un solo archivo y un solo apply. La infraestructura tiene capas — red, compute, datos, aplicación — y cada capa cambia a distinta velocidad. Separalas desde el principio y vas a agradecer haberlo hecho cuando llegue el momento de escalar.

Next · DevOps · 4 min Your infrastructure should live in Git Read next →