StackPractices
intermediate Por Mathias Paulenko

Buenas Prácticas de Terraform: Módulos, State y Workspaces

Guía práctica de Terraform: módulos, estado remoto, workspaces y seguridad para IaC de producción.

Overview

Terraform se convirtió en la herramienta go-to de infrastructure-as-code para muchos equipos. La usan para definir, provisionar y gestionar recursos cloud a través de archivos de configuración declarativos. Empezar es sencillo — unos bloques HCL y tenés un VPC. Pero mantenerlo ordenado a escala? Es otra historia. Requiere disciplina — diseño de módulos, gestión de estado, seguridad, colaboración. Nada de eso es opcional.

Lo aprendí por las malas. Hace unos años, heredé un repo de Terraform donde todo vivía en un main.tf de 2,000 líneas. Sin módulos, sin estado remoto, sin locking. Dos ingenieros corrieron terraform apply en el mismo workspace al mismo tiempo y corrompieron el archivo de estado. Tardamos tres días recuperando desde backups. Esa experiencia me enseñó algo: las prácticas alrededor de Terraform importan mucho más que el código en sí.

Esta guía recorre lo que separa el Terraform de prototipo del código que podés correr en producción: diseño de módulos, estado remoto, workspaces, seguridad, testing, CI/CD, drift detection y policy as code. Está escrita para equipos que ya conocen lo básico y quieren subir de nivel.

Prerrequisitos: ya deberías saber lo básico — resources, variables, outputs. Vas a necesitar una cuenta de AWS o GCP y una plataforma de CI/CD (GitHub Actions, GitLab CI o similar).

Cuándo Usarlo

Terraform vale la pena cuando gestionás infraestructura cloud que cambia seguido, cuando varios miembros del equipo tocan los mismos recursos, y cuando necesitás entornos reproducibles en dev, staging y production. También es una buena elección cuando querés tener las definiciones de infraestructura en control de versiones, o cuando estás migrando de provisionamiento manual a infrastructure as code.

Cuándo NO Usarlo

Evitá Terraform para un puñado de recursos estáticos que rara vez cambian. No lo fuerces a un equipo que no está listo para gestionar archivos de estado, locks y acceso al backend. Y si necesitás reconciliación de infraestructura en tiempo real basada en eventos, herramientas como Ansible u operadores de Kubernetes suelen ajustarse mejor.

Diseño de Módulos

La forma en que estructurás los módulos determina si tu codebase de Terraform escala o colapsa bajo su propio peso. Vi equipos empezar con un solo main.tf y terminar con 5,000 líneas de HCL copy-pasteado seis meses después. La solución es casi siempre la misma: dividir en módulos pequeños, componibles y con interfaces claras.

flowchart diagram: Root module<br/>environments/prod

Root module vs child modules

terraform/
├── modules/
│   ├── vpc/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   └── database/
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
├── environments/
│   ├── dev/
│   │   └── main.tf
│   ├── staging/
│   │   └── main.tf
│   └── prod/
│       └── main.tf

Diseño de interfaz de módulos

Mantené los inputs explícitos y los outputs mínimos.

# modules/vpc/variables.tf
variable "vpc_cidr" {
  description = "CIDR block for the VPC"
  type        = string
  default     = "10.0.0.0/16"
}

variable "availability_zones" {
  description = "List of AZs to use"
  type        = list(string)
}

# modules/vpc/outputs.tf
output "vpc_id" {
  description = "ID of the created VPC"
  value       = aws_vpc.main.id
}

output "private_subnet_ids" {
  description = "List of private subnet IDs"
  value       = aws_subnet.private[*].id
}

Composición sobre herencia

Preferí módulos chicos que se componen entre sí. Un bloque monolítico te va a perseguir.

# environments/prod/main.tf
module "vpc" {
  source             = "../../modules/vpc"
  vpc_cidr           = "10.0.0.0/16"
  availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
}

module "database" {
  source          = "../../modules/database"
  vpc_id          = module.vpc.vpc_id
  subnet_ids      = module.vpc.private_subnet_ids
  instance_class  = "db.r6g.xlarge"
}

Para más detalles, consultá Complete Guide to Terraform Modules.

Versionado de módulos

Pineá las versiones de los módulos. Un módulo sin pinear es una bomba de tiempo — el maintainer pushea un breaking change y tu próximo terraform init lo levanta silenciosamente.

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.1.0"  # pin a una versión específica
  cidr    = "10.0.0.0/16"
}

Para módulos internos, usá Git tags como versiones:

module "database" {
  source = "git::https://github.com/myorg/terraform-modules//database?ref=v1.2.0"
}

Recomiendo semantic versioning para módulos: bump del patch para bug fixes, minor para recursos nuevos, major para breaking changes. Tu pipeline de CI debería correr terraform plan contra la nueva versión antes de mergear.

Gestión de Estado

Estado remoto con locking

Nunca guardes el estado en control de versiones. Usá backends remotos con locking.

# backend.tf
terraform {
  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "prod/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-locks"
  }
}
# Create the backend resources
aws s3api create-bucket --bucket my-terraform-state --region us-east-1
aws s3api put-bucket-versioning --bucket my-terraform-state --versioning-configuration Status=Enabled
aws dynamodb create-table \
  --table-name terraform-locks \
  --attribute-definitions AttributeName=LockID,AttributeType=S \
  --key-schema AttributeName=LockID,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST

Para más información, consultá Terraform Remote State S3 Backend.

Aislamiento de estado

Dale un archivo de estado propio a cada entorno y a cada componente.

EnfoqueIdeal para
WorkspacesEntornos simples (dev/staging/prod)
Directorios separadosEntornos complejos con configuraciones distintas
Backends separadosMáximo aislamiento, distintas cuentas de AWS

Detección de drift

El drift ocurre cuando alguien cambia infraestructura fuera de Terraform — un fix manual en la consola, un script, otra herramienta. Sin control, el drift hace que los planes te sorprendan y los applies fallen.

# Detectar drift en un schedule
terraform plan -detailed-exitcode

# Exit codes:
# 0 = sin cambios
# 1 = error
# 2 = drift detectado (hay cambios)

Corré esto en CI de noche. Si el exit code es 2, abrí un issue automáticamente. Algunos equipos usan terraform plan con notificaciones de Slack; otros usan herramientas como driftctl (ahora archivado, pero alternativas como Terradrift existen).

Vi el drift morder a equipos de dos formas: un DB instance class que alguien subió manualmente (Terraform lo revirtió en el próximo apply, causando un outage), y una regla de security group añadida durante un incidente (Terraform la eliminó, reabriendo la vulnerabilidad). La solución es la misma: detectar drift temprano, investigar por qué pasó, y要么 actualizar la config de Terraform o revertir el cambio manual.

Workspaces

Los workspaces de Terraform permiten varios archivos de estado dentro de la misma configuración.

# Create and switch to a workspace
terraform workspace new prod
terraform workspace select prod

# Use workspace in configuration
locals {
  environment = terraform.workspace
  instance_count = {
    dev     = 1
    staging = 2
    prod    = 3
  }[terraform.workspace]
}

Los workspaces comparten la configuración del backend. Si necesitás aislamiento real, workspaces solos no alcanzan. Vas a querer backends separados o incluso distintas cuentas cloud. Consultá Terraform Workspace Environment Isolation.

Importar recursos existentes

Si estás adoptando Terraform sobre infraestructura que ya existe, no tenés que recrear todo. Usá terraform import para traer recursos existentes al estado:

# Importar un S3 bucket existente
terraform import aws_s3_bucket.main my-existing-bucket

# Importar una instancia RDS existente
terraform import aws_db_instance.main my-db-instance-id

El comando import solo agrega el recurso al estado — no genera el HCL. Acá está el catch: necesitás escribir el bloque de resource manualmente y asegurar que coincida con la infraestructura real. Corré terraform plan después del import; si no muestra cambios, tu HCL coincide con la realidad.

Para imports en masa, herramientas como terraformer (GoogleCloudPlatform/terraformer) pueden generar HCL desde recursos cloud existentes. Lo usé una vez para importar 200+ recursos de una cuenta de AWS en una sola tarde. El código generado? Necesitó cleanup, sí. Pero fue mejor que escribir todo a mano. Te lo aseguro.

Prácticas de Seguridad

Nunca commitear secretos

# .gitignore
*.tfstate
*.tfstate.*
.terraform/
.terraform.lock.hcl
*.auto.tfvars
secrets.tfvars

Usar variables para datos sensibles

variable "db_password" {
  description = "Database administrator password"
  type        = string
  sensitive   = true
}

Mínimo privilegio para CI/CD

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["ec2:*", "rds:*", "s3:*"],
      "Resource": "*",
      "Condition": {
        "StringEquals": {"aws:RequestedRegion": "us-east-1"}
      }
    },
    {
      "Effect": "Deny",
      "Action": ["ec2:DeleteVpc", "rds:DeleteDBInstance"],
      "Resource": "*"
    }
  ]
}

Testing y Validación

Análisis estático

# Format check
terraform fmt -check -recursive

# Validate syntax
terraform validate

# Security scanning with Checkov
checkov -d .

Flujo de revisión de planes

# Generate a plan file
terraform plan -out=tfplan

# Review the plan
terraform show tfplan

# Apply only the reviewed plan
terraform apply tfplan

Pipeline de CI/CD para Terraform

Un pipeline de CI/CD sólido pilla issues antes de que lleguen a producción. Este es el workflow que funciona para mí:

# .github/workflows/terraform.yml
name: Terraform CI

on:
  pull_request:
    paths: ["terraform/**"]

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.7.0"
      - run: terraform fmt -check -recursive
      - run: terraform init -backend=false
      - run: terraform validate
      - run: checkov -d terraform/

  plan:
    needs: validate
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.7.0"
      - run: terraform init
      - run: terraform plan -no-color
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

El pipeline corre cuatro checks: formato, validación, security scan y plan. El step de plan postea su output como comentario en el PR para que los reviewers vean exactamente qué va a cambiar. Apply solo ocurre después de merge a main, y solo desde una branch protegida.

Algo que siempre exijo: el rol de CI tiene IAM de mínimo privilegio. Puede crear y modificar recursos en la cuenta target, pero ¿eliminar VPCs o instancias RDS? Eso requiere un step de aprobación separado. Sin excepciones.

Explicación

Los módulos mantienen el código DRY y reutilizable. Los root modules llaman a child modules y pasan valores específicos del entorno a través de variables. El estado remoto almacena el archivo .tfstate fuera de discos locales: S3 provee durabilidad, y DynamoDB provee locking para evitar escrituras concurrentes. Los workspaces separan el estado por entorno dentro de un mismo backend. Son livianos, claro, pero recordá: comparten las mismas credenciales del backend. Para una separación estricta, conviene usar backends separados.

El archivo de estado es la fuente de verdad de Terraform. Mapea tus recursos HCL a objetos cloud reales. Perdilo, y Terraform no puede distinguir qué existe y qué no — estás volando a ciegas. Por eso el estado remoto con versionado y locking es innegociable. El versionado de S3 significa que podés recuperar de un state file corrupto; el locking de DynamoDB significa que dos ingenieros no pueden aplicar al mismo tiempo.

La seguridad empieza por no commitear secretos, marcar variables como sensitive y darle a CI/CD los permisos mínimos necesarios. La validación con terraform fmt, terraform validate y checkov detecta problemas de sintaxis y seguridad antes del apply. Policy as code (Sentinel u OPA) agrega guardrails para reglas de todo el equipo — sin buckets públicos, sin bases sin cifrar, sin instancias oversized en dev. La estimación de costos con infracost cierra el loop mostrando el impacto financiero de cada cambio.

El workflow que lo ata todo: escribir código → correr fmt y validate local → pushear a branch → CI corre fmt check, validate, checkov, plan e infracost → revisar el plan y la estimación en el PR → mergear → CI aplica el plan en main. Este loop, repetido a diario, es lo que separa a un equipo que shippea infraestructura seguro de uno que le tiene miedo a prod.

Errores Comunes

  • Guardar el estado en Git. Los archivos de estado pueden contener secretos y no están diseñados para resolución de control de versiones. Usá un backend remoto con cifrado y versionado.
  • Hardcodear credenciales. No metas secretos en el HCL. Pasalos por variables, variables de entorno o roles IAM y mantené los secretos fuera del repositorio.
  • Módulos monolíticos. Dividí la infraestructura en módulos pequeños, reutilizables y testeables en vez de un archivo gigante.
  • Saltear plan files. El plan es tu última línea de defensa — generálo, leélo y solo después aplicá. Saltear este step es como se producen los outages en producción.
  • Ignorar el pin de versiones de providers. Pineá las versiones de providers y módulos para evitar breaking changes sorpresa.
  • No usar state locking. Varios ingenieros corriendo Terraform al mismo tiempo pueden corromper el estado. Usá un backend que soporte locking.
  • Ignorar el drift. Si alguien cambia un recurso en la consola y no lo detectás, tu próximo apply va a revertir el cambio (causando un outage) o fallar con un error confuso. Corré checks de drift nocturnos en CI.
  • No usar terraform import para infraestructura existente. Vi equipos recrear 300 recursos manualmente en Terraform cuando podrían haberlos importado en una tarde. No seas ese equipo.

See Also

  • Docs oficiales de Terraform — referencia completa de configuración, providers y CLI. Empezá por la sección “Configuration Language”.
  • Terraform AWS Provider — referencia de resources y data sources para AWS. Hacé bookmark; lo vas a visitar a diario.
  • Checkov — herramienta SAST open-source para Terraform. Escanea misconfigurations como buckets S3 públicos, volúmenes sin cifrar y SGs demasiado permisivos.
  • Infracost — estimación de costos para Terraform. Postea diffs de costo como comentarios en PRs para que los reviewers vean el impacto financiero antes de mergear.
  • Atlantis — automatización open-source de PRs de Terraform. Corre plan y apply desde comentarios de PR, con locking y soporte de workspaces concurrentes.
  • Sentinel — policy as code para Terraform Cloud / Enterprise. Pilla cosas como “sin buckets S3 públicos” antes de que lleguen al apply.

Temas Avanzados

Terraform modular para producción

# Directory structure
# infra/
#   modules/
#     vpc/
#     eks/
#     rds/
#   environments/
#     dev/
#     staging/
#     production/

# modules/rds/main.tf
variable "vpc_id" { type = string }
variable "subnet_ids" { type = list(string) }
variable "instance_class" { type = string }
variable "allocated_storage" { type = number, default = 100 }
variable "multi_az" { type = bool, default = true }
variable "backup_retention" { type = number, default = 7 }
variable "tags" { type = map(string), default = {} }

resource "aws_db_instance" "main" {
  engine = "postgres"
  engine_version = "16"
  instance_class = var.instance_class
  allocated_storage = var.allocated_storage
  multi_az = var.multi_az
  backup_retention_period = var.backup_retention
  storage_encrypted = true
  kms_key_id = aws_kms_key.rds.arn
  db_subnet_group_name = aws_db_subnet_group.main.name
  vpc_security_group_ids = [aws_security_group.rds.id]
  tags = merge(var.tags, {
    Name = "postgres-main"
    ManagedBy = "terraform"
  })
}

resource "aws_kms_key" "rds" {
  description = "KMS key for RDS encryption"
  enable_key_rotation = true
}

resource "aws_db_subnet_group" "main" {
  name = "main-db-subnet-group"
  subnet_ids = var.subnet_ids
}

resource "aws_security_group" "rds" {
  name = "rds-sg"
  vpc_id = var.vpc_id
  ingress {
    from_port = 5432
    to_port = 5432
    protocol = "tcp"
    security_groups = [var.app_sg_id]
  }
  egress {
    from_port = 0
    to_port = 0
    protocol = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

output "endpoint" { value = aws_db_instance.main.endpoint }
output "db_arn" { value = aws_db_instance.main.arn }
# environments/production/main.tf
module "rds" {
  source = "../../modules/rds"
  vpc_id = module.vpc.vpc_id
  subnet_ids = module.vpc.private_subnet_ids
  instance_class = "db.r5.xlarge"
  allocated_storage = 500
  multi_az = true
  backup_retention = 30
  tags = { Environment = "production", Team = "platform" }
}

# Environment differences:
#   dev: db.t3.medium, 20GB, no multi-az, backup 1 day
#   staging: db.t3.large, 100GB, multi-az, backup 7 days
#   production: db.r5.xlarge, 500GB, multi-az, backup 30 days

Policy as code

Una vez que tu equipo crece más allá de 3-4 ingenieros, vas a querer guardrails. Policy as code te permite hacer cumplir reglas antes del apply — sin buckets S3 públicos, sin bases sin cifrar, sin instancias más grandes que m5.2xlarge en dev.

# Sentinel policy (Terraform Cloud / Enterprise)
import "tfplan/v2" as tfplan

# No public S3 buckets
no_public_s3 = rule {
    all tfplan.resource_changes as _, rc {
        rc.type is "aws_s3_bucket" and rc.change.actions contains "create" implies
        rc.change.after.acl is not "public-read" and
        rc.change.after.acl is not "public-read-write"
    }
}

main = rule {
    no_public_s3
}

Si no estás en Terraform Cloud, OPA (Open Policy Agent) con Conftest funciona como alternativa open-source:

# Correr políticas OPA contra un plan de Terraform
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
conftest test tfplan.json -p policies/

Encontré policy as code más útil para control de costos. Una política que bloquea db.r6g.16xlarge en dev ahorra muchas conversaciones incómodas con el equipo de finanzas.

Estimación de costos

El output de terraform plan no muestra costos por defecto. Herramientas como infracost llenan ese gap:

# Instalar infracost
brew install infracost

# Generar una estimación de costos desde un plan
infracost breakdown --path tfplan.json

# Example output:
# NAME                          MONTHLY QUANTITY  MONTHLY COST
# aws_db_instance.main          730 hours         $1,460.00
#   ├─ db.r5.xlarge             730 hours         $1,460.00
# aws_s3_bucket.data            100 GB            $2.30
# TOTAL                                           $1,462.30

Corré infracost en CI y posteá la estimación como comentario en el PR. Los reviewers ven el impacto de costo de cada cambio antes de mergear. Esto es especialmente valioso para recursos que escalan automáticamente — un cluster Aurora que auto-scala a 5 read replicas puede sumar $3,000/mes silenciosamente.

Preguntas frecuentes

¿Uso Terraform Cloud?

Terraform Cloud y Enterprise proveen estado remoto, colaboración de equipo y policy-as-code. Para equipos chicos, un backend S3 + DynamoDB suele alcanzar.

¿Cómo gestiono secretos en Terraform?

Usá variables de entorno (TF_VAR_*), HashiCorp Vault o gestores de secretos cloud como AWS Secrets Manager. Marcá las variables como sensitive = true para que no aparezcan en logs ni en la salida del plan.

¿Cuándo uso módulos vs workspaces?

Los módulos son para componentes de infraestructura reutilizables. Los workspaces son para aislamiento de estado por entorno. Usá ambos: módulos para código DRY, workspaces o directorios separados para separación de entornos.

¿Cómo manejo el estado remoto y el locking?

Un backend remoto como S3 más DynamoDB para locking es la configuración estándar. Activá encrypt = true y mantené los archivos .tfstate fuera del repositorio. Para equipos grandes, Terraform Cloud o Atlantis pueden aplicar cambios a través de PRs.

¿Cómo empiezo en un proyecto existente?

Elegí una parte pequeña y aislada del codebase, un módulo o servicio. Aplicá estas prácticas ahí, medí el impacto y expandí.

¿Por qué mi plan muestra cambios que no hice?

Suele ser drift — alguien cambió infraestructura fuera de Terraform. Corré terraform plan -detailed-exitcode en CI para detectarlo. Si el exit code es 2, investigá qué cambió y por qué, y要么 actualizá tu config de Terraform o revertí el cambio manual.

¿Qué onda con .terraform.lock.hcl?

Es el dependency lock file, añadido en Terraform 1.6. Pineá las versiones de providers para asegurar que terraform init produzca el mismo resultado en todas las máquinas. Commitealo a version control — no lo gitignorees. Lo único que deberías gitignorar es .terraform/ (el directorio de plugins).

¿Puedo usar Terraform con múltiples cloud providers?

Sí, pero con cuidado. Cada provider necesita su propio bloque de configuración, y las dependencias cross-provider pueden volverse un lío. Recomiendo state files separados por cloud — mezclar recursos de AWS y GCP en un solo state file dificulta los imports y la detección de drift. Usá módulos y backends separados para cada cloud.