O artigo Introdução ao Terraform: Infraestrutura que Você Pode Versionar introduziu o Terraform com um exemplo que já incluía alguns recursos AWS. Este artigo expande esse escopo de forma sistemática: serão criados os recursos fundamentais que formam a base de qualquer infraestrutura na AWS — rede, computação, armazenamento e banco de dados — todos gerenciados pelo Terraform, todos versionáveis e reproduzíveis.
O objetivo ao final deste artigo é ter uma infraestrutura funcional completa provisionada inteiramente por código, compreendendo uma VPC com subnets públicas e privadas, instâncias EC2, um bucket S3 e um banco de dados RDS — os mesmos componentes que sustentam a maioria das aplicações web em produção.
Configurando as Credenciais AWS
Antes de qualquer coisa, o Terraform precisa de credenciais para interagir com a AWS. A forma recomendada é via variáveis de ambiente — nunca hardcoded nos arquivos de configuração:
# Configura credenciais via AWS CLI (forma mais conveniente)
aws configure
# AWS Access Key ID: AKIA...
# AWS Secret Access Key: ...
# Default region name: us-east-1
# Default output format: json
# Ou via variáveis de ambiente diretamente
export AWS_ACCESS_KEY_ID="AKIA..."
export AWS_SECRET_ACCESS_KEY="..."
export AWS_DEFAULT_REGION="us-east-1"
# Verifica que as credenciais estão funcionando
aws sts get-caller-identity
Para ambientes de produção e pipelines de CI/CD, a abordagem mais segura é usar IAM Roles com OIDC — sem chaves estáticas — conforme abordado no artigo sobre secrets. Para desenvolvimento local, as credenciais via aws configure são suficientes.
Construindo uma VPC Completa
A Virtual Private Cloud é o componente de rede fundamental da AWS. Toda infraestrutura séria começa com uma VPC bem configurada — não com a VPC padrão que a AWS cria automaticamente.
# networking.tf
# VPC principal
resource "aws_vpc" "principal" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.project_name}-${var.environment}-vpc"
}
}
# Internet Gateway — permite tráfego de entrada e saída da internet
resource "aws_internet_gateway" "principal" {
vpc_id = aws_vpc.principal.id
tags = {
Name = "${var.project_name}-${var.environment}-igw"
}
}
# Subnets públicas — para load balancers e bastion hosts
resource "aws_subnet" "publica" {
count = length(var.availability_zones)
vpc_id = aws_vpc.principal.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-${var.environment}-subnet-publica-${count.index + 1}"
Tier = "public"
}
}
# Subnets privadas — para aplicação e banco de dados
resource "aws_subnet" "privada" {
count = length(var.availability_zones)
vpc_id = aws_vpc.principal.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 10)
availability_zone = var.availability_zones[count.index]
tags = {
Name = "${var.project_name}-${var.environment}-subnet-privada-${count.index + 1}"
Tier = "private"
}
}
# Elastic IPs para os NAT Gateways
resource "aws_eip" "nat" {
count = length(var.availability_zones)
domain = "vpc"
tags = {
Name = "${var.project_name}-${var.environment}-eip-nat-${count.index + 1}"
}
}
# NAT Gateways — permitem que as subnets privadas acessem a internet
# sem ficarem expostas a conexões de entrada
resource "aws_nat_gateway" "principal" {
count = length(var.availability_zones)
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.publica[count.index].id
tags = {
Name = "${var.project_name}-${var.environment}-nat-${count.index + 1}"
}
depends_on = [aws_internet_gateway.principal]
}
# Tabela de rotas para subnets públicas
resource "aws_route_table" "publica" {
vpc_id = aws_vpc.principal.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.principal.id
}
tags = {
Name = "${var.project_name}-${var.environment}-rt-publica"
}
}
# Tabelas de rotas para subnets privadas
resource "aws_route_table" "privada" {
count = length(var.availability_zones)
vpc_id = aws_vpc.principal.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.principal[count.index].id
}
tags = {
Name = "${var.project_name}-${var.environment}-rt-privada-${count.index + 1}"
}
}
# Associações de tabelas de rotas — subnets públicas
resource "aws_route_table_association" "publica" {
count = length(var.availability_zones)
subnet_id = aws_subnet.publica[count.index].id
route_table_id = aws_route_table.publica.id
}
# Associações de tabelas de rotas — subnets privadas
resource "aws_route_table_association" "privada" {
count = length(var.availability_zones)
subnet_id = aws_subnet.privada[count.index].id
route_table_id = aws_route_table.privada[count.index].id
}
Grupos de Segurança
# security_groups.tf
# Security group para o load balancer — aceita tráfego da internet
resource "aws_security_group" "load_balancer" {
name = "${var.project_name}-${var.environment}-sg-alb"
description = "Security group do Application Load Balancer"
vpc_id = aws_vpc.principal.id
ingress {
description = "HTTP da internet"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "HTTPS da internet"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-${var.environment}-sg-alb"
}
}
# Security group para as instâncias de aplicação
# Aceita tráfego apenas do load balancer
resource "aws_security_group" "aplicacao" {
name = "${var.project_name}-${var.environment}-sg-app"
description = "Security group das instâncias de aplicação"
vpc_id = aws_vpc.principal.id
ingress {
description = "Tráfego do load balancer"
from_port = 3000
to_port = 3000
protocol = "tcp"
security_groups = [aws_security_group.load_balancer.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-${var.environment}-sg-app"
}
}
# Security group para o banco de dados
# Aceita tráfego apenas das instâncias de aplicação
resource "aws_security_group" "banco_dados" {
name = "${var.project_name}-${var.environment}-sg-db"
description = "Security group do banco de dados RDS"
vpc_id = aws_vpc.principal.id
ingress {
description = "PostgreSQL das instâncias de aplicação"
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.aplicacao.id]
}
tags = {
Name = "${var.project_name}-${var.environment}-sg-db"
}
}
Instâncias EC2 com Auto Scaling
Em vez de criar instâncias individuais, a prática recomendada para aplicações web é usar um Auto Scaling Group — um conjunto de instâncias que escala automaticamente com base na demanda:
# compute.tf
# IAM Role para as instâncias EC2
resource "aws_iam_role" "instancia_ec2" {
name = "${var.project_name}-${var.environment}-ec2-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ec2.amazonaws.com"
}
}]
})
}
# Política para acesso ao SSM — permite acesso sem SSH exposto
resource "aws_iam_role_policy_attachment" "ssm" {
role = aws_iam_role.instancia_ec2.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
# Instance Profile — associa a Role à instância
resource "aws_iam_instance_profile" "aplicacao" {
name = "${var.project_name}-${var.environment}-instance-profile"
role = aws_iam_role.instancia_ec2.name
}
# Launch Template — modelo para criação de instâncias
resource "aws_launch_template" "aplicacao" {
name_prefix = "${var.project_name}-${var.environment}-"
image_id = data.aws_ami.ubuntu.id
instance_type = var.instance_type
iam_instance_profile {
arn = aws_iam_instance_profile.aplicacao.arn
}
network_interfaces {
associate_public_ip_address = false
security_groups = [aws_security_group.aplicacao.id]
}
block_device_mappings {
device_name = "/dev/sda1"
ebs {
volume_size = 20
volume_type = "gp3"
encrypted = true
delete_on_termination = true
}
}
user_data = base64encode(<<-EOF
#!/bin/bash
apt-get update -y
apt-get install -y docker.io docker-compose-plugin awscli
systemctl enable docker
systemctl start docker
usermod -aG docker ubuntu
# Baixa e inicia a aplicação
aws ecr get-login-password --region ${var.aws_region} | \
docker login --username AWS --password-stdin \
${data.aws_caller_identity.atual.account_id}.dkr.ecr.${var.aws_region}.amazonaws.com
docker run -d \
--name minha-api \
--restart unless-stopped \
-p 3000:3000 \
${var.app_image}
EOF
)
tag_specifications {
resource_type = "instance"
tags = {
Name = "${var.project_name}-${var.environment}-app"
}
}
}
# Auto Scaling Group
resource "aws_autoscaling_group" "aplicacao" {
name = "${var.project_name}-${var.environment}-asg"
vpc_zone_identifier = aws_subnet.privada[*].id
min_size = var.asg_min_size
max_size = var.asg_max_size
desired_capacity = var.asg_desired_capacity
launch_template {
id = aws_launch_template.aplicacao.id
version = "$Latest"
}
health_check_type = "ELB"
health_check_grace_period = 60
tag {
key = "Name"
value = "${var.project_name}-${var.environment}-app"
propagate_at_launch = true
}
lifecycle {
create_before_destroy = true
}
}
Bucket S3 com Configurações de Segurança
# storage.tf
resource "aws_s3_bucket" "assets" {
bucket = "${var.project_name}-${var.environment}-assets-${data.aws_caller_identity.atual.account_id}"
tags = {
Name = "${var.project_name}-${var.environment}-assets"
}
}
# Bloqueia todo acesso público — configuração de segurança fundamental
resource "aws_s3_bucket_public_access_block" "assets" {
bucket = aws_s3_bucket.assets.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# Habilita versionamento
resource "aws_s3_bucket_versioning" "assets" {
bucket = aws_s3_bucket.assets.id
versioning_configuration {
status = "Enabled"
}
}
# Habilita criptografia server-side
resource "aws_s3_bucket_server_side_encryption_configuration" "assets" {
bucket = aws_s3_bucket.assets.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
# Política de ciclo de vida — move objetos antigos para storage mais barato
resource "aws_s3_bucket_lifecycle_configuration" "assets" {
bucket = aws_s3_bucket.assets.id
rule {
id = "mover-para-ia"
status = "Enabled"
transition {
days = 30
storage_class = "STANDARD_IA"
}
transition {
days = 90
storage_class = "GLACIER"
}
expiration {
days = 365
}
}
}
Banco de Dados RDS
# database.tf
# Subnet group para o RDS — define em quais subnets o banco pode ficar
resource "aws_db_subnet_group" "principal" {
name = "${var.project_name}-${var.environment}-db-subnet-group"
subnet_ids = aws_subnet.privada[*].id
tags = {
Name = "${var.project_name}-${var.environment}-db-subnet-group"
}
}
# Parâmetros customizados do PostgreSQL
resource "aws_db_parameter_group" "postgres" {
name = "${var.project_name}-${var.environment}-pg16"
family = "postgres16"
parameter {
name = "log_connections"
value = "1"
}
parameter {
name = "log_disconnections"
value = "1"
}
parameter {
name = "log_min_duration_statement"
value = "1000" # loga queries acima de 1 segundo
}
}
# Instância RDS PostgreSQL
resource "aws_db_instance" "principal" {
identifier = "${var.project_name}-${var.environment}-db"
engine = "postgres"
engine_version = "16.1"
instance_class = var.db_instance_class
allocated_storage = 20
max_allocated_storage = 100 # auto scaling de storage até 100GB
storage_type = "gp3"
storage_encrypted = true
db_name = var.db_name
username = var.db_username
password = var.db_password
db_subnet_group_name = aws_db_subnet_group.principal.name
parameter_group_name = aws_db_parameter_group.postgres.name
vpc_security_group_ids = [aws_security_group.banco_dados.id]
multi_az = var.environment == "production"
publicly_accessible = false
deletion_protection = var.environment == "production"
skip_final_snapshot = var.environment != "production"
final_snapshot_identifier = var.environment == "production" ? \
"${var.project_name}-${var.environment}-final-snapshot" : null
backup_retention_period = var.environment == "production" ? 7 : 1
backup_window = "03:00-04:00"
maintenance_window = "Mon:04:00-Mon:05:00"
performance_insights_enabled = true
tags = {
Name = "${var.project_name}-${var.environment}-db"
}
}
Outputs Completos
# outputs.tf
output "vpc_id" {
description = "ID da VPC criada"
value = aws_vpc.principal.id
}
output "subnets_publicas" {
description = "IDs das subnets públicas"
value = aws_subnet.publica[*].id
}
output "subnets_privadas" {
description = "IDs das subnets privadas"
value = aws_subnet.privada[*].id
}
output "endpoint_rds" {
description = "Endpoint de conexão do banco de dados"
value = aws_db_instance.principal.endpoint
sensitive = true
}
output "bucket_assets" {
description = "Nome do bucket S3 de assets"
value = aws_s3_bucket.assets.id
}
output "database_url" {
description = "URL de conexão com o banco formatada"
value = "postgresql://${var.db_username}:${var.db_password}@${aws_db_instance.principal.endpoint}/${var.db_name}"
sensitive = true
}
O Que Vem a Seguir
Com a infraestrutura básica na AWS funcionando via Terraform, o próximo artigo aprofunda o tema de módulos — como organizar configurações reutilizáveis que podem ser compartilhadas entre projetos e ambientes. Módulos são o mecanismo que transforma código Terraform de scripts pontuais em uma biblioteca de infraestrutura corporativa.
Referências para Aprofundamento
Documentação e referências
- Terraform AWS Provider — registry.terraform.io — Referência completa de todos os recursos AWS disponíveis no provider Terraform, com exemplos detalhados.
- AWS VPC Documentation — Documentação oficial da AWS sobre VPC, cobrindo conceitos de subnets, route tables e security groups.
Tutoriais práticos
- HashiCorp Learn — Build Infrastructure — Tutorial oficial da HashiCorp para provisionar infraestrutura na AWS com Terraform, com ambiente de laboratório incluso.
- Terraform AWS Modules — GitHub — Coleção de módulos Terraform oficialmente mantidos para os serviços AWS mais comuns, incluindo VPC, EC2 e RDS. Excelente referência para boas práticas de organização.
Segurança
- Checkov — Bridgecrew — Ferramenta de análise estática para código Terraform que verifica boas práticas de segurança. Pode ser integrada ao pipeline de CI para bloquear configurações inseguras antes do apply
Exercícios
Exercício 1
Considerando vpc_cidr = "10.0.0.0/16" e availability_zones = ["us-east-1a", "us-east-1b"], calcule os quatro blocos CIDR gerados pelas expressões abaixo e explique o papel do segundo argumento.
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index) # subnets públicas
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 10) # subnets privadas
Ver resposta
✓ Resposta: As públicas ficam 10.0.0.0/24 e 10.0.1.0/24; as privadas, 10.0.10.0/24 e 10.0.11.0/24.
O segundo argumento é o número de bits acrescentados ao prefixo. Partindo de /16 e somando 8, cada subnet vira um /24 — 256 endereços cada. O terceiro argumento é o índice da sub-rede dentro desse espaço: índice 0 dá 10.0.0.0/24, índice 1 dá 10.0.1.0/24, índice 10 dá 10.0.10.0/24, e assim por diante.
O + 10 nas privadas é uma decisão de organização, não uma exigência técnica: ele abre uma faixa de folga entre os dois grupos. Assim é possível crescer para até dez subnets públicas sem que a numeração colida com as privadas, e o próprio endereço passa a indicar o tipo da subnet ao olhar o console.
Exercício 2
Qual é a diferença prática entre uma subnet pública e uma privada nesta configuração? Como as instâncias em subnet privada conseguem baixar pacotes da internet sem ficarem acessíveis a partir dela?
Ver resposta
✓ Resposta: A diferença está na tabela de rotas, não na subnet em si. A rota padrão (0.0.0.0/0) da tabela pública aponta para o Internet Gateway; a das privadas aponta para o NAT Gateway. A pública ainda define map_public_ip_on_launch = true, dando IP público às instâncias que sobem ali.
O NAT Gateway é o que resolve o aparente paradoxo: ele fica em uma subnet pública e faz tradução de endereços de saída. O tráfego que parte da instância privada sai com o IP do NAT e as respostas voltam pela conexão já estabelecida — por isso o apt-get update funciona. Mas ninguém na internet consegue iniciar uma conexão para dentro, porque a instância privada não tem IP público nem rota de entrada. É acesso de saída sem exposição de entrada.
Repare que há um NAT Gateway por AZ, cada um com seu Elastic IP e sua própria tabela de rotas privada. Isso custa mais caro, mas evita que a falha de uma zona de disponibilidade derrube a saída de internet das outras.
Exercício 3
Os três security groups formam uma cadeia. Repare que o de aplicação e o de banco usam security_groups = [...] em vez de cidr_blocks. Qual a vantagem concreta disso, e por que o security group do banco não declara nenhum bloco egress?
Ver resposta
✓ Resposta: Referenciar o security group de origem cria uma regra por identidade, não por endereço. A regra passa a dizer "aceito tráfego de quem pertence ao SG do load balancer", e isso continua valendo enquanto instâncias sobem, descem e trocam de IP — exatamente o que acontece num Auto Scaling Group. Com cidr_blocks seria preciso conhecer e manter atualizada a faixa de IPs, e a alternativa preguiçosa (liberar a subnet inteira) autorizaria qualquer coisa hospedada ali, não só a aplicação.
A cadeia resultante é: o ALB aceita a internet nas portas 80 e 443; a aplicação aceita somente o ALB na porta 3000; o banco aceita somente a aplicação na 5432. Cada camada só conversa com a anterior.
O banco não declara egress de propósito. Sem regra de saída explícita, o security group não permite nenhum tráfego de saída iniciado pelo banco — e um banco de dados não precisa iniciar conexões, apenas responder às que recebe. Como security groups têm estado (stateful), as respostas às consultas da aplicação fluem normalmente, sem depender de regra de egress. É o princípio de menor privilégio aplicado à rede.
Exercício 4
O NAT Gateway declara depends_on = [aws_internet_gateway.principal]. Se o Terraform monta o grafo de dependências sozinho a partir das referências, por que essa declaração explícita foi necessária aqui?
Ver resposta
✓ Resposta: Porque a dependência existe na AWS, mas não aparece no código. O aws_nat_gateway referencia apenas aws_eip.nat[...] e aws_subnet.publica[...] — em nenhum atributo ele menciona o Internet Gateway. Como o grafo é construído a partir das referências entre recursos, o Terraform concluiria que os dois são independentes e poderia criá-los em paralelo.
Só que um NAT Gateway precisa que a VPC já tenha um Internet Gateway anexado para funcionar — é por ele que o tráfego traduzido efetivamente sai. Criado antes, o NAT falha ou nasce inoperante, e o erro aparece de forma intermitente, dependendo da ordem em que a AWS processou as chamadas paralelas.
O depends_on é justamente o mecanismo para declarar à mão as dependências que o código não expressa. A regra prática: se A precisa que B exista mas nunca usa nenhum atributo de B, o Terraform não tem como saber — e é preciso dizer.
Exercício 5
O RDS ajusta quatro atributos conforme o ambiente. Descreva o comportamento em production e em staging e explique por que skip_final_snapshot e deletion_protection apontam para lados opostos. Por que o output database_url é marcado como sensitive?
multi_az = var.environment == "production"
deletion_protection = var.environment == "production"
skip_final_snapshot = var.environment != "production"
Ver resposta
✓ Resposta: Em production: multi_az ligado (réplica em outra zona, com failover automático), deletion_protection ligado (a AWS recusa a exclusão do banco) e skip_final_snapshot desligado — ou seja, ao destruir o banco a AWS exige e cria um snapshot final, nomeado em final_snapshot_identifier. O backup fica retido por 7 dias.
Em staging tudo se inverte: sem multi-AZ (metade do custo), sem proteção contra exclusão e pulando o snapshot final, com retenção de 1 dia.
Eles apontam para lados opostos porque expressam a mesma intenção em atributos de polaridade contrária: em produção, dificultar a perda de dados. deletion_protection = true impede o apagão acidental, e skip_final_snapshot = false garante que, se a exclusão realmente for autorizada, ainda reste uma cópia. Em staging o ambiente é descartável — proteção contra exclusão só atrapalharia um terraform destroy de rotina, e guardar snapshot de dados de teste seria custo sem utilidade.
O database_url é sensitive porque a string interpola var.db_password. Sem essa marcação, a senha seria impressa em texto puro na saída do terraform apply e no terraform output — e, num pipeline, direto no log da execução. Vale notar o limite: sensitive apenas oculta o valor na exibição; ele continua gravado em texto puro no arquivo de state, que por isso precisa de backend remoto com criptografia e acesso restrito.