Terraform IaC con Claude Code: AWS, módulos, state, CI y revisión de plan
Guía Terraform IaC con Claude Code en AWS: módulos, backend, state, CI, policy y errores comunes.
Terraform permite describir infraestructura con código, pero eso no significa que cualquier código generado sea seguro. Un VPC puede verse correcto y aun así guardar el state en local, mezclar dev y prod en la misma clave de backend, exponer subredes públicas sin querer o permitir que un terraform plan reemplace recursos críticos. Claude Code es útil en este flujo, siempre que lo tratemos como asistente de implementación y revisión, no como botón automático de despliegue.
IaC significa Infrastructure as Code: gestionar recursos de nube mediante archivos versionados y revisables. HCL es el lenguaje de configuración de Terraform. Un module es una pieza reutilizable de infraestructura. El state es el registro con el que Terraform relaciona el código con los recursos reales. El backend es el lugar donde se guarda ese state. Si estos límites no están claros, Claude Code puede producir HCL que compila, pero que no conviene llevar a producción.
En una prueba pequeña para este artículo, Masa usó Claude Code para dividir un VPC en un module, añadir validaciones de variables y preparar un CI de plan. El resultado fue rápido, pero los prompts necesitaron reglas explícitas: no escribir secrets en tfvars, no aprobar cambios con destroy, no copiar ejemplos antiguos de backend con bloqueo obsoleto.
Flujo de trabajo y casos de uso
El flujo recomendable es simple: especificar requisitos, generar HCL, validar, ejecutar plan, revisar y solo entonces aplicar.
flowchart LR
A["Escribir requisitos"] --> B["Claude Code redacta HCL"]
B --> C["terraform fmt / validate"]
C --> D["terraform plan"]
D --> E["Revisión con IA y humana"]
E --> F["Apply aprobado"]
Estos son los casos donde más valor aporta.
| Caso de uso | Qué puede preparar Claude Code | Qué debe revisar una persona |
|---|---|---|
| Red AWS nueva | VPC, subnets, rutas, etiquetas | Solape de CIDR y coste de NAT Gateway |
| Module reusable | modules/vpc, variables y outputs | Que el module no tenga demasiadas responsabilidades |
| Separación de entornos | envs/dev.tfvars y envs/prod.tfvars | Backend key, permisos de producción |
| CI y policy | fmt, validate, plan y checks JSON | destroy, replace y ampliación de IAM |
Para los conceptos del producto consulta ladocumentación oficial de Claude Code. Para modules, usa ladocumentación oficial de Terraform. Si el punto débil es IAM, continúa con la guía interna deAWS IAM.
Pedir código con límites claros
Un buen prompt de Terraform no solo enumera recursos. También dice qué no tocar, cómo validar y cuándo detenerse.
claude -p "
Crea Terraform para un module de VPC en AWS.
No borres archivos existentes salvo que lo pida explícitamente.
Requisitos:
- Asumir Terraform 1.10 o posterior
- Usar el provider hashicorp/aws
- Crear VPC, 2 subnets públicas, 2 privadas, Internet Gateway y NAT Gateway
- Poner el código reusable en modules/vpc
- Separar dev y prod con tfvars
- No escribir secrets ni AWS access keys en tfvars
- Incluir terraform fmt -recursive, terraform validate y terraform plan
- Si el plan contiene destroy o replacement, tratarlo como no aprobado
"
La frase más importante es “no aprobado”. Claude Code puede resumir un plan, pero no asume la responsabilidad de coste, caída del servicio o exposición pública. En Terraform, una omisión pequeña puede convertirse en una diferencia peligrosa.
Estructura Terraform lista para empezar
La siguiente estructura es deliberadamente compacta. Con credenciales de AWS y un bucket de state real, puedes formatear, validar y planificar. En junio de 2026, el backend S3 de Terraform soporta bloqueo con use_lockfile; el bloqueo basado en DynamoDB aparece como deprecated en la documentación oficial. Revisa elS3 backend oficial antes de reutilizar ejemplos antiguos.
infra/
versions.tf
variables.tf
main.tf
envs/dev.tfvars
modules/vpc/main.tf
modules/vpc/variables.tf
modules/vpc/outputs.tf
# versions.tf
terraform {
required_version = ">= 1.10.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0, < 7.0"
}
}
backend "s3" {
bucket = "replace-with-your-tfstate-bucket"
key = "claude-code-terraform-iac/dev/terraform.tfstate"
region = "eu-west-1"
encrypt = true
use_lockfile = true
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = var.common_tags
}
}
# variables.tf
variable "aws_region" {
type = string
description = "AWS region to deploy into."
default = "eu-west-1"
}
variable "environment" {
type = string
description = "Environment name such as dev, staging, or prod."
}
variable "common_tags" {
type = map(string)
description = "Tags applied to every supported AWS resource."
}
variable "vpc_cidr" {
type = string
description = "CIDR block for the VPC."
}
variable "public_subnets" {
type = list(object({
name = string
cidr = string
az = string
}))
}
variable "private_subnets" {
type = list(object({
name = string
cidr = string
az = string
}))
}
# main.tf
module "network" {
source = "./modules/vpc"
project = "claude-code-iac"
environment = var.environment
vpc_cidr = var.vpc_cidr
public_subnets = var.public_subnets
private_subnets = var.private_subnets
common_tags = var.common_tags
}
Diseño del module y variables
Mantén el module de VPC centrado en red. Si metes ALB, ECS, RDS e IAM dentro del mismo module, un cambio pequeño generará un plan difícil de revisar. También conviene usar for_each con nombres estables en lugar de depender de count.index para recursos que vivirán mucho tiempo.
# modules/vpc/variables.tf
variable "project" {
type = string
description = "Project name used in resource names."
}
variable "environment" {
type = string
description = "Environment name used in tags and names."
}
variable "vpc_cidr" {
type = string
description = "CIDR block for the VPC."
validation {
condition = can(cidrhost(var.vpc_cidr, 0))
error_message = "vpc_cidr must be a valid CIDR block."
}
}
variable "public_subnets" {
type = list(object({
name = string
cidr = string
az = string
}))
validation {
condition = length(var.public_subnets) >= 2 && alltrue([for s in var.public_subnets : can(cidrhost(s.cidr, 0))])
error_message = "Define at least two valid public subnet CIDR blocks."
}
}
variable "private_subnets" {
type = list(object({
name = string
cidr = string
az = string
}))
validation {
condition = length(var.private_subnets) >= 2 && alltrue([for s in var.private_subnets : can(cidrhost(s.cidr, 0))])
error_message = "Define at least two valid private subnet CIDR blocks."
}
}
variable "common_tags" {
type = map(string)
description = "Common tags."
}
# modules/vpc/main.tf
locals {
public_subnets_by_name = { for subnet in var.public_subnets : subnet.name => subnet }
private_subnets_by_name = { for subnet in var.private_subnets : subnet.name => subnet }
name_prefix = "${var.project}-${var.environment}"
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(var.common_tags, {
Name = "${local.name_prefix}-vpc"
})
}
resource "aws_subnet" "public" {
for_each = local.public_subnets_by_name
vpc_id = aws_vpc.main.id
cidr_block = each.value.cidr
availability_zone = each.value.az
map_public_ip_on_launch = true
tags = merge(var.common_tags, {
Name = "${local.name_prefix}-public-${each.key}"
Tier = "public"
})
}
resource "aws_subnet" "private" {
for_each = local.private_subnets_by_name
vpc_id = aws_vpc.main.id
cidr_block = each.value.cidr
availability_zone = each.value.az
tags = merge(var.common_tags, {
Name = "${local.name_prefix}-private-${each.key}"
Tier = "private"
})
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
}
resource "aws_route_table_association" "public" {
for_each = aws_subnet.public
subnet_id = each.value.id
route_table_id = aws_route_table.public.id
}
resource "aws_eip" "nat" {
domain = "vpc"
}
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = values(aws_subnet.public)[0].id
depends_on = [aws_internet_gateway.main]
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main.id
}
}
resource "aws_route_table_association" "private" {
for_each = aws_subnet.private
subnet_id = each.value.id
route_table_id = aws_route_table.private.id
}
Backend, state y separación de entornos
El state no es una caché sin importancia. Contiene IDs de recursos y, a veces, atributos sensibles. Usa S3 con versioning, bloqueo y permisos estrechos. Además, separa las claves de backend por entorno.
# envs/dev.tfvars
aws_region = "eu-west-1"
environment = "dev"
vpc_cidr = "10.40.0.0/16"
common_tags = {
Project = "claude-code-iac"
Environment = "dev"
Owner = "masa"
ManagedBy = "terraform"
}
public_subnets = [
{ name = "a", cidr = "10.40.0.0/24", az = "eu-west-1a" },
{ name = "b", cidr = "10.40.1.0/24", az = "eu-west-1b" }
]
private_subnets = [
{ name = "a", cidr = "10.40.10.0/24", az = "eu-west-1a" },
{ name = "b", cidr = "10.40.11.0/24", az = "eu-west-1b" }
]
En producción cambia CIDR, tags y backend key. Workspaces pueden servir a equipos maduros, pero para principiantes los tfvars explícitos son más fáciles de revisar.
Validación con fmt, validate y plan
Terraform documenta los comandos fmt, validate y plan. Primero valida sin backend remoto.
cd infra
terraform init -backend=false
terraform fmt -recursive
terraform validate
Después inicializa el backend real y guarda el plan.
terraform init -reconfigure
terraform plan -var-file=envs/dev.tfvars -out=tfplan
terraform show -no-color tfplan > plan.txt
Pide a Claude Code una revisión centrada en riesgo.
claude -p "
Revisa plan.txt.
Prioriza destroy, replace, force replacement, ampliación de IAM, exposición pública y cambios de backend key.
Si hay borrados, no apruebes el plan.
Devuelve preguntas concretas para el reviewer humano.
"
CI, policy, permisos y secrets
En GitHub Actions evita claves AWS de larga duración. Usa OIDC para asumir un IAM role limitado; la guía oficial está enGitHub Actions OIDC in AWS. El role debe seguirAWS IAM best practices.
name: terraform-plan
on:
pull_request:
paths:
- "infra/**"
permissions:
contents: read
id-token: write
jobs:
plan:
runs-on: ubuntu-latest
defaults:
run:
working-directory: infra
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.10.0"
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-terraform-plan
aws-region: eu-west-1
- run: terraform init -backend=false
- run: terraform fmt -check -recursive
- run: terraform validate
- run: terraform init -reconfigure
- run: terraform plan -var-file=envs/dev.tfvars -out=tfplan
- run: terraform show -json tfplan > tfplan.json
Una policy sencilla puede bloquear deletes.
package terraform.deny
deny[msg] {
rc := input.resource_changes[_]
rc.change.actions[_] == "delete"
msg := sprintf("Destroy action requires manual approval: %s", [rc.address])
}
Para ampliar el flujo, lee la guía deCI/CD con Claude Code y la desecrets management.
Errores comunes y resultado de la prueba
El primer error es tratar el state como un archivo temporal. No lo subas a Git y no compartas la misma key entre dev y prod. El segundo es usar count.index en recursos persistentes; reordenar una lista puede provocar replacements. El tercero es escribir contraseñas, tokens o access keys en tfvars. El cuarto es aplicar un plan solo porque Claude Code lo resumió bien.
En la prueba de este artículo, terraform fmt -recursive y terraform validate pasaron después de dividir el VPC en module. El plan real exige reemplazar el bucket de backend y configurar credenciales AWS. Los riesgos que más se repitieron fueron coste de NAT Gateway, key de state compartida y cambios de replace. La revisión mejoró cuando el prompt pidió priorizar destroy y replace por encima de comentarios de estilo.
Si tu equipo necesita plantillas de revisión, checklists de CI y material de onboarding para IaC, revisa labiblioteca de Claude Code o laformación de implementación. Incluso en proyectos personales, guardar este prompt y los comandos en CLAUDE.md reduce mucho la variabilidad de cada PR de Terraform.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Workflow de Obsidian a CLAUDE.md con Claude Code
Convierte notas de trabajo de Obsidian en notas operativas de CLAUDE.md para no repetir contexto.
Claude Code Revenue CTA Routing: de artículos a PDF, Gumroad y consulta
Un flujo con Claude Code para dirigir lectores a PDF gratis, Gumroad o consulta según intención.
Reglas de handoff para equipos con Claude Code: evidencia, permisos, rollback e ingresos
Formato práctico para entregar trabajo de Claude Code con pruebas, permisos, rollback, PDF gratis, Gumroad y consulta.