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.
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.
| Enfoque | Ideal para |
|---|---|
| Workspaces | Entornos simples (dev/staging/prod) |
| Directorios separados | Entornos complejos con configuraciones distintas |
| Backends separados | Má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 importpara 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.
Recursos Relacionados
Módulos de Terraform
Construye módulos de Terraform reutilizables con estructura adecuada, inputs, outputs y versionado. Cubre composición, testing y publicación en el Registry.
RecipeProvisiona una VPC de AWS con Terraform
Como usar Terraform para provisionar una VPC de AWS lista para produccion con subredes publicas y privadas, NAT gateways y security groups
RecipeAlmacenar Terraform State en S3 con DynamoDB Locking
Cómo configurar Terraform remote state con S3 backend y DynamoDB locking, cubriendo state isolation, workspace management, encryption e integración con CI/CD.
RecipeAislar Entornos con Terraform Workspaces
Cómo usar Terraform workspaces para environment isolation, cubriendo workspace creation, conditional resources, variable management y migración a separate state files.
RecipeConstruye un Provider Personalizado de Terraform con
Extiende Terraform con un provider personalizado usando Python y terraform-plugin-framework para gestionar recursos externos.
GuidePlatform Engineering — Plataformas de Desarrollo Internas
Guia practica de platform engineering: conceptos de IDP, golden paths, infraestructura self-service y herramientas como Backstage, Crossplane y Terraform.