Referencia Detallada de Terraform en Producción
Gestionar infrastructure as code con Terraform en produccion. Cubre modules, state management, workspaces, drift detection, remote backends, variable validation, sentinel policies y CI/CD integration con ejemplos practicos de HCL.
Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.
Introducción
Terraform es el standard para infrastructure as code. Pero manejar Terraform en production requiere mas que escribir HCL files. Lo siguiente recorre modules, state management, workspaces, drift detection, remote backends, variable validation, policy as code, y CI/CD integration.
Project Structure
terraform/
├── modules/
│ ├── vpc/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ └── versions.tf
│ ├── eks/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ └── versions.tf
│ └── rds/
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ └── versions.tf
├── environments/
│ ├── staging/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── backend.tf
│ │ └── terraform.tfvars
│ └── production/
│ ├── main.tf
│ ├── variables.tf
│ ├── backend.tf
│ └── terraform.tfvars
├── policies/
│ └── sentinel/
│ └── require-tags.sentinel
└── README.md
Modules
VPC Module
# modules/vpc/main.tf
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
resource "aws_vpc" "main" {
cidr_block = var.cidr_block
enable_dns_support = true
enable_dns_hostnames = true
tags = merge(var.tags, {
Name = "${var.name_prefix}-vpc"
})
}
resource "aws_subnet" "private" {
for_each = toset(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.cidr_block, 4, index(var.availability_zones, each.value))
availability_zone = each.value
tags = merge(var.tags, {
Name = "${var.name_prefix}-private-${each.value}"
Tier = "private"
})
}
resource "aws_subnet" "public" {
for_each = toset(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.cidr_block, 4, index(var.availability_zones, each.value) + 8)
availability_zone = each.value
map_public_ip_on_launch = true
tags = merge(var.tags, {
Name = "${var.name_prefix}-public-${each.value}"
Tier = "public"
})
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = merge(var.tags, {
Name = "${var.name_prefix}-igw"
})
}
resource "aws_nat_gateway" "main" {
for_each = toset(var.availability_zones)
allocation_id = aws_eip.nat[each.value].id
subnet_id = aws_subnet.public[each.value].id
tags = merge(var.tags, {
Name = "${var.name_prefix}-nat-${each.value}"
})
depends_on = [aws_internet_gateway.main]
}
resource "aws_eip" "nat" {
for_each = toset(var.availability_zones)
domain = "vpc"
tags = merge(var.tags, {
Name = "${var.name_prefix}-eip-${each.value}"
})
}
# modules/vpc/variables.tf
variable "name_prefix" {
description = "Prefix para all resource names"
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]{1,30}$", var.name_prefix))
error_message = "name_prefix must be 2-31 chars, lowercase alphanumeric and hyphens."
}
}
variable "cidr_block" {
description = "CIDR block para el VPC"
type = string
default = "10.0.0.0/16"
validation {
condition = can(cidrhost(var.cidr_block, 0))
error_message = "cidr_block must be a valid IPv4 CIDR."
}
}
variable "availability_zones" {
description = "List de availability zones"
type = list(string)
default = ["us-east-1a", "us-east-1b", "us-east-1c"]
validation {
condition = length(var.availability_zones) >= 2
error_message = "At least 2 availability zones required para HA."
}
}
variable "tags" {
description = "Common tags applied a all resources"
type = map(string)
default = {}
}
# modules/vpc/outputs.tf
output "vpc_id" {
description = "ID del VPC"
value = aws_vpc.main.id
}
output "private_subnet_ids" {
description = "IDs de private subnets"
value = [for s in aws_subnet.private : s.id]
}
output "public_subnet_ids" {
description = "IDs de public subnets"
value = [for s in aws_subnet.public : s.id]
}
output "vpc_cidr_block" {
description = "CIDR block del VPC"
value = aws_vpc.main.cidr_block
}
Using Modules
# environments/production/main.tf
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = "stackpractices"
Environment = "production"
ManagedBy = "terraform"
Owner = "platform-team"
}
}
}
module "vpc" {
source = "../../modules/vpc"
name_prefix = "sp-prod"
cidr_block = "10.10.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
tags = {
CostCenter = "platform"
}
}
module "eks" {
source = "../../modules/eks"
name_prefix = "sp-prod"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
cluster_version = "1.30"
node_groups = {
general = {
desired_size = 3
min_size = 2
max_size = 6
instance_types = ["t3.large"]
}
}
tags = {
CostCenter = "platform"
}
}
module "rds" {
source = "../../modules/rds"
name_prefix = "sp-prod"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
allocated_storage = 100
engine = "postgres"
engine_version = "16.2"
instance_class = "db.r6g.large"
multi_az = true
storage_encrypted = true
tags = {
CostCenter = "platform"
}
}
State Management
Remote Backend (S3 + DynamoDB)
# environments/production/backend.tf
terraform {
backend "s3" {
bucket = "sp-terraform-state-prod"
key = "production/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "sp-terraform-locks"
# Use un dedicated IAM role para state access
# role_arn = "arn:aws:iam::123456789:role/TerraformStateAccess"
}
}
# Bootstrap: Create S3 bucket y DynamoDB table para state
# Run this una vez per environment (no managed por Terraform itself)
resource "aws_s3_bucket" "terraform_state" {
bucket = "sp-terraform-state-prod"
lifecycle {
prevent_destroy = true # Prevent accidental state deletion
}
}
resource "aws_s3_bucket_versioning" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_public_access_block" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_dynamodb_table" "terraform_locks" {
name = "sp-terraform-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}
State Import
# Import existing resources en Terraform state
terraform import aws_s3_bucket.my_bucket my-bucket-name
terraform import aws_db_instance.my_db my-db-instance-id
# Import con module path
terraform import module.rds.aws_db_instance.main my-db-id
# List all resources en state
terraform state list
# Show un specific resource desde state
terraform state show module.rds.aws_db_instance.main
# Move un resource en state (rename)
terraform state mv aws_instance.old aws_instance.new
# Remove un resource desde state (sin destroying)
terraform state rm aws_instance.deprecated
# Pull state a local file (para backup)
terraform state pull > backup.tfstate
Workspaces
# Using workspaces para environment separation
# Note: Workspaces son best para ephemeral environments, no prod vs staging
# Para prod vs staging, usa separate directories con separate state files
# environments/staging/main.tf
module "vpc" {
source = "../../modules/vpc"
name_prefix = "sp-staging"
cidr_block = "10.20.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b"]
}
# environments/production/main.tf
module "vpc" {
source = "../../modules/vpc"
name_prefix = "sp-prod"
cidr_block = "10.10.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
# Workspace commands (para ephemeral environments)
terraform workspace new feature-xyz
terraform workspace select production
terraform workspace list
terraform workspace delete feature-xyz
# Use workspace name en configuration
# resource "aws_s3_bucket" "app" {
# bucket = "sp-app-${terraform.workspace}"
# }
Drift Detection
# Manual drift detection
terraform plan -detailed-exitcode
# Exit codes:
# 0 - No changes (no drift)
# 1 - Error
# 2 - Changes detected (drift exists)
# Automated drift detection script
#!/bin/bash
set -e
cd environments/production
terraform init -input=false
EXIT_CODE=0
terraform plan -detailed-exitcode -out=plan.tfplan || EXIT_CODE=$?
if [ $EXIT_CODE -eq 2 ]; then
echo "DRIFT DETECTED"
terraform show -json plan.tfplan | jq '.resource_changes[] | select(.change.actions != ["no-op"]) | {address: .address, actions: .change.actions}'
# Send alert
curl -X POST "$SLACK_WEBHOOK" -H "Content-Type: application/json" \
-d "{\"text\": \"Terraform drift detected en production!\"}"
exit 1
elif [ $EXIT_CODE -eq 0 ]; then
echo "No drift detected"
exit 0
else
echo "Terraform plan failed"
exit $EXIT_CODE
fi
# GitHub Actions: Scheduled drift detection
name: Terraform Drift Detection
on:
schedule:
- cron: "0 8 * * *" # Daily at 8 AM UTC
jobs:
drift-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.6.0"
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Terraform Init
run: terraform init -input=false
working-directory: environments/production
- name: Check for drift
run: |
set +e
terraform plan -detailed-exitcode -out=plan.tfplan
EXIT_CODE=$?
if [ $EXIT_CODE -eq 2 ]; then
echo "::warning::Terraform drift detected"
terraform show -json plan.tfplan > plan.json
exit 1
fi
working-directory: environments/production
CI/CD Pipeline
# GitHub Actions: Terraform CI/CD
name: Terraform Pipeline
on:
pull_request:
paths:
- "terraform/**"
push:
branches: [main]
paths:
- "terraform/**"
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.6.0"
- name: Terraform Format Check
run: terraform fmt -check -recursive
working-directory: terraform
- name: Terraform Init
run: terraform init -input=false -backend=false
working-directory: terraform/environments/production
- name: Terraform Validate
run: terraform validate
working-directory: terraform/environments/production
plan:
needs: validate
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.6.0"
- name: Configure AWS
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Terraform Init
run: terraform init -input=false
working-directory: terraform/environments/production
- name: Terraform Plan
run: terraform plan -no-color -out=plan.tfplan
working-directory: terraform/environments/production
env:
TF_VAR_db_password: ${{ secrets.TF_DB_PASSWORD }}
- name: Upload plan artifact
uses: actions/upload-artifact@v4
with:
name: terraform-plan
path: terraform/environments/production/plan.tfplan
apply:
needs: plan
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production # Requires manual approval
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.6.0"
- name: Configure AWS
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Terraform Init
run: terraform init -input=false
working-directory: terraform/environments/production
- name: Terraform Apply
run: terraform apply -auto-approve -no-color
working-directory: terraform/environments/production
env:
TF_VAR_db_password: ${{ secrets.TF_DB_PASSWORD }}
Policy as Code (Sentinel)
# policies/sentinel/require-tags.sentinel
# Require specific tags en all resources
import "tfplan/v2" as tfplan
mandatory_tags = {
"Project",
"Environment",
"ManagedBy",
"Owner",
}
resources = filter tfplan.resource_changes as _, rc {
rc.type starts with "aws_" and
rc.change.actions contains "create"
}
main = rule {
all resources as _, rc {
all mandatory_tags as tag {
rc.change.after.tags contains tag
}
}
}
# policies/sentinel/no-public-s3.sentinel
# Prevent public S3 buckets
import "tfplan/v2" as tfplan
s3_buckets = filter tfplan.resource_changes as _, rc {
rc.type is "aws_s3_bucket" and
rc.change.actions contains "create"
}
main = rule {
all s3_buckets as _, rc {
rc.change.after.acl is not "public-read" and
rc.change.after.acl is not "public-read-write"
}
}
# policies/sentinel/require-encryption.sentinel
# Require encryption en RDS y EBS
import "tfplan/v2" as tfplan
rds_instances = filter tfplan.resource_changes as _, rc {
rc.type is "aws_db_instance" and
rc.change.actions contains "create"
}
ebs_volumes = filter tfplan.resource_changes as _, rc {
rc.type is "aws_ebs_volume" and
rc.change.actions contains "create"
}
main = rule {
all rds_instances as _, rc {
rc.change.after.storage_encrypted is true
} and all ebs_volumes as _, rc {
rc.change.after.encrypted is true
}
}
Preguntas Frecuentes
¿Debería usar workspaces o separate directories para environments?
Usa separate directories para production vs staging. Cada directory tiene su own state file, backend configuration, y tfvars. Esto provee complete isolation y previene accidental changes a production. Usa workspaces para ephemeral environments (feature branches, testing) donde el infrastructure es identical en structure. Workspaces share el mismo backend y variables, que es risky para production.
¿Cómo manejo secrets en Terraform?
Nunca stores secrets en tfvars files o HCL. Usa environment variables (TF_VAR_db_password), AWS Secrets Manager, o HashiCorp Vault. Para sensitive values en state, usa sensitive = true en variable definitions. Marka outputs como sensitive = true para prevenir que sean displayed en plan output. Usa un remote backend con encryption (S3 con SSE) para proteger state files, que pueden contener sensitive data.
¿Qué es Terraform drift y cómo lo detecto?
Drift ocurre cuando el actual infrastructure differs de lo que Terraform expects (e.g., alguien manualmente changed un resource en el AWS console). Detecta drift running terraform plan -detailed-exitcode — exit code 2 significa drift exists. Automatiza drift detection con un daily CI/CD job que run plan y alerts en exit code 2. Fix drift sea importando los manual changes en Terraform o running terraform apply para revertir el infrastructure al expected state.
¿Cómo structuro Terraform modules?
Crea un module per logical infrastructure component (VPC, EKS, RDS, CDN). Cada module deberia tener main.tf (resources), variables.tf (inputs con validation), outputs.tf (exports), y versions.tf (provider requirements). Keep modules generic — no hardcodees environment-specific values. Usa el module desde environment-specific directories que pass in los right variables. Version tus modules con Git tags y reference specific versions en production.
¿Debería usar Terraform Cloud o self-managed backends?
Terraform Cloud provee managed state storage, remote execution, policy as code (Sentinel), y un UI para plan review. Vale la pena para teams que necesitan audit trails, RBAC, y approval workflows. Para small teams o cost-sensitive projects, self-managed S3 + DynamoDB backends funcionan well. El trade-off es operational overhead (managing state infrastructure) vs cost (Terraform Cloud pricing per resource).
¿Cómo prevengo destructive changes?
Add lifecycle { prevent_destroy = true } a critical resources (databases, S3 buckets con state). Esto previene terraform destroy de delete them. Usa Sentinel o OPA policies para block destructive changes en CI. Review plans carefully en pull requests. Usa terraform plan en CI antes de merge. Require manual approval para production applies. Usa create_before_destroy lifecycle hook para resources que necesitan zero-downtime replacement.
See Also
Recursos Relacionados
Complete Guide to Docker in Production
Run Docker containers in production with confidence. Covers multi-stage builds, distroless images, health checks, image scanning, resource limits, logging, secrets, multi-arch builds, and container runtime security with practical Dockerfile examples.
GuideComplete Guide to GitOps in Production
Implement GitOps with ArgoCD and Flux. Covers declarative infrastructure, drift reconciliation, automated rollbacks, progressive delivery, multi-cluster management, secret management with SOPS, and CI/CD pipeline integration with practical YAML examples.
GuideComplete Guide to Secrets Management
Manage application secrets securely in production. Covers HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, GCP Secret Manager, Doppler, secret rotation, environment variables, zero-downtime rotation, and secrets in CI/CD pipelines with practical code examples.
PatternExternal Configuration Store Pattern
Centralize application configuration outside of deployment artifacts to support live updates and multi-environment management.
Recipea Custom Terraform Provider with Python and
Extend Terraform with a custom provider using Python and the terraform-plugin-framework to manage external resources.
DocInfrastructure as Code Review Template
A template for reviewing Terraform and CloudFormation infrastructure code.