Post

Infra de produção em projeto pessoal: OIDC, Terraform e o custo real da AWS

Pipeline sem Access Key salva em secret, o paradoxo de bootstrap do Terraform e a conta de US$100 que chega mesmo com zero tráfego

Infra de produção em projeto pessoal: OIDC, Terraform e o custo real da AWS

E aí, pessoal!

Passei as últimas semanas mexendo num projeto pessoal, mas tratando a infra dele como se fosse produção de verdade. É uma API de finanças pessoais, o Budget Planner, e a parte interessante não foi a API. Foi tudo em volta: como ela sobe, como se protege e o que acontece quando quebra.

Este post cobre a infraestrutura: autenticação do pipeline sem segredo fixo, o problema de ovo e galinha do Terraform e a conta no fim do mês. As decisões do lado da aplicação estão no post seguinte.


O desenho

Diagrama da infraestrutura na AWS Duas tasks Fargate em subnet privada, ALB na pública, RDS sem rota para a internet.

O importante no diagrama é o que não está exposto. Só o load balancer aceita tráfego da internet. Tasks e banco ficam em subnets privadas, sem IP público.

Isso é imposto por uma cadeia de security groups onde cada grupo libera acesso para o grupo anterior, não para um bloco de IPs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
resource "aws_security_group" "ecs" {
  ingress {
    from_port       = 8080
    to_port         = 8080
    protocol        = "tcp"
    security_groups = [aws_security_group.load_balancer.id]
  }
}

resource "aws_security_group" "rds" {
  ingress {
    from_port       = 5432
    to_port         = 5432
    protocol        = "tcp"
    security_groups = [aws_security_group.ecs.id]
  }
}

security_groups no lugar de cidr_blocks. A 8080 só aceita quem vem do ALB, a 5432 só quem vem do ECS. Não há CIDR liberado, então mesmo descobrindo o endereço interno do banco não existe caminho até ele. E a regra sobrevive a renumeração de subnets, porque aponta para uma identidade e não para um intervalo que alguém precisa lembrar de atualizar.


Access Key vazada continua válida

O jeito tradicional de dar acesso à AWS para um pipeline é criar um usuário IAM e salvar o Access Key como secret. O problema é o ciclo de vida: aquela chave não expira. Se vazar num log, num fork ou no histórico de um commit, continua válida até alguém perceber e revogar na mão. O intervalo entre o vazamento e a revogação é sorte.

O OIDC inverte isso. O GitHub emite um token assinado descrevendo quem está rodando o job, e a AWS troca esse token por credenciais que expiram em minutos. Nenhum segredo de longa duração fica armazenado.

No workflow são duas linhas de permissão e um step:

1
2
3
4
5
6
7
8
9
    permissions:
      id-token: write
      contents: read
    steps:
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: $
          aws-region: us-east-1

O id-token: write autoriza o job a pedir o token, e fica declarado por job para que o de testes não tenha essa capacidade. O AWS_GITHUB_OIDC_ROLE_ARN não é segredo de verdade: é o ARN de uma role, um identificador público. Se vazar, sozinho não serve de nada, porque quem manda é a trust policy do outro lado.

No Terraform, primeiro registra-se o GitHub como emissor confiável:

1
2
3
4
resource "aws_iam_openid_connect_provider" "github_oidc" {
  url            = "https://token.actions.githubusercontent.com"
  client_id_list = ["sts.amazonaws.com"]
}

Depois a role, com a condição que faz o trabalho todo:

1
2
3
4
5
6
7
8
9
10
11
        Condition = {
          StringEquals = {
            "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
          }
          StringLike = {
            "token.actions.githubusercontent.com:sub" = [
              "repo:${var.github-repo}:ref:refs/heads/main",
              "repo:${var.github-repo}:pull_request"
            ]
          }
        }

O aud confirma que o token foi emitido para o STS da AWS. O sub descreve quem pede: o GitHub preenche esse campo com o repositório e a referência que dispararam o job.

Sem essa condição, qualquer workflow de qualquer repositório do GitHub poderia assumir sua role. O provider apenas diz que o GitHub é um emissor confiável; é o sub que separa “confio no GitHub” de “confio neste repositório, nesta branch”.

A segunda entrada existe porque o pipeline de infra roda terraform plan em pull requests para comentar o plano no PR. Pull request tem um sub diferente de push, então precisa ser autorizado explicitamente.


O paradoxo do bootstrap

Aqui está a parte que não aparece no tutorial: o Terraform que cria a role OIDC precisa de credenciais para rodar a primeira vez.

É ovo e galinha em duas camadas. O pipeline só autentica se a role existir, e a role só existe se alguém rodar o Terraform. Mas o Terraform também precisa guardar o state em algum lugar, e esse lugar é um bucket S3 que também precisa ser criado antes.

O bucket sai de um Terraform separado, que usa state local justamente por ainda não ter onde guardá-lo:

1
2
3
4
5
6
7
terraform {
  backend "s3" {
    bucket = "tfstate-backend-<account-id>"
    key    = "budgetplanner/state/terraform.tfstate"
    region = "us-east-1"
  }
}

A saída foi assumir o bootstrap manual e documentá-lo como etapa única, em vez de fingir que o pipeline cria tudo do zero:

  1. cd infra/bootstrap && terraform apply cria o bucket do state, com credenciais locais.
  2. cd infra && terraform apply cria o resto, incluindo o OIDC provider e a role.
  3. O ARN da role vira o secret AWS_GITHUB_OIDC_ROLE_ARN no GitHub.
  4. A partir daqui o pipeline se sustenta sozinho.

Numa conta nova, esses quatro passos precisam ser refeitos. Não dá para automatizar sem já ter uma credencial, que é justamente o que se está tentando eliminar.

O ponto honesto: o state do bootstrap ficou local e commitado. Como descreve apenas um bucket vazio, o risco é baixo, mas é uma inconsistência real com o resto do desenho e prefiro registrá-la a deixar passar.


Secrets que não passam pelo state

O state do Terraform guarda em texto tudo que os recursos retornam. Declare uma senha no .tf e ela está no state, que está num bucket.

O RDS resolve isso gerando a senha do lado da AWS:

1
2
3
4
5
6
7
8
9
resource "aws_db_instance" "budgetplanner-db" {
  engine                      = "postgres"
  engine_version              = "18.4"
  instance_class              = "db.t4g.micro"
  username                    = "postgres"
  manage_master_user_password = true
  db_subnet_group_name        = aws_db_subnet_group.db_subnet_group.name
  vpc_security_group_ids      = [aws_security_group.rds.id]
}

O manage_master_user_password = true faz a AWS criar a senha e guardá-la no Secrets Manager. Não existe atributo de senha no código nem no state, só uma referência ao ARN.

A task definition consome esse secret sem que o valor passe pelo Terraform:

1
2
3
4
5
6
      secrets = [
        {
          name      = "DB_PASSWORD"
          valueFrom = "${aws_db_instance.budgetplanner-db.master_user_secret[0].secret_arn}:password::"
        }
      ]

A diferença entre environment e secrets é onde a resolução acontece. O que está em environment é literal e visível em qualquer describe-task-definition. O que está em secrets é um ponteiro: o agente do ECS busca o valor na hora de subir o container. O sufixo :password:: seleciona um campo do JSON, evitando injetar o documento inteiro.


O alarme que você não aprende a ignorar

Sem log group configurado, os logs do container existem só enquanto a task vive. A cada deploy some tudo, inclusive o rastro do problema que causou o restart.

O alarme mais útil é o de 5xx no ALB:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
resource "aws_cloudwatch_metric_alarm" "alb_5xx" {
  metric_name         = "HTTPCode_Target_5XX_Count"
  namespace           = "AWS/ApplicationELB"
  period              = 60
  statistic           = "Sum"
  threshold           = 5
  treat_missing_data  = "notBreaching"

  dimensions = {
    LoadBalancer = aws_lb.application_load_balancer.arn_suffix
    TargetGroup  = aws_lb_target_group.budgetplanner-tg.arn_suffix
  }

  alarm_actions = [aws_sns_topic.alarms.arn]
  ok_actions    = [aws_sns_topic.alarms.arn]
}

Três detalhes fazem a diferença entre um alarme útil e um que ninguém lê.

treat_missing_data = "notBreaching" trata ausência de dados como estado saudável. Num projeto sem tráfego constante, muitos minutos não têm requisição nenhuma, e o padrão do CloudWatch trataria isso como dado faltante, gerando ruído.

ok_actions avisa quando normaliza. Sem isso você é notificado de que quebrou e precisa ir ao console descobrir se voltou.

As dimensões usam arn_suffix, não arn, porque é o formato que as métricas do ALB esperam. Com o ARN completo o alarme é criado sem erro e simplesmente nunca encontra dado.


A conta que chega mesmo com zero tráfego

Essa foi a lição que menos esperava. A observabilidade sai barata: métricas nativas gratuitas, SNS gratuito até mil notificações, dashboard gratuito, o alarme a US$0,10, mais alguns dólares de logs. Abaixo de US$5 por mês.

Já a infraestrutura:

ItemCusto mensal aproximado
AWS Fargate (2 tasks, 0.5 vCPU / 1GB, 730h)US$36,04
Amazon RDS for PostgreSQL (db.t4g.micro, 10GB)US$13,98
Elastic Load Balancing (1 ALB)US$16,45
NAT Gateway (taxa fixa, subnets privadas)~US$33,00
Total estimado~US$99,50/mês

Cerca de cem dólares por mês para uma API que não recebe requisição nenhuma. Duas tasks, um load balancer e um banco gerenciado cobram por hora de existência, não por uso.

O item que mais me pegou foi o NAT Gateway. Ele não parece importante no desenho e é o segundo mais caro. Existe por um motivo específico: recursos em subnet privada não têm rota para a internet, mas o ECS precisa alcançar o ECR para baixar a imagem. O NAT dá essa saída sem permitir entrada.

Ou seja, colocar o ECS em subnet privada, que é a decisão de segurança correta, é o que traz os US$33. Segurança e custo são a mesma linha do Terraform aqui.

A estimativa não considera free tier. Fargate nunca teve cota gratuita, e RDS e ALB só têm doze meses grátis em conta nova. É por isso que o valor costuma surpreender quem calculou baseado em experiências antigas com EC2.

Existem saídas: VPC endpoints para o ECR no lugar do NAT, uma task só, ou App Runner em vez de ECS. Todas mudam o desenho. Escolhi manter o desenho e pagar, porque o objetivo era aprender o formato de produção, não otimizar a conta.


Conclusão

Nenhuma decisão isolada é a lição. O que mudou meu jeito de trabalhar foi escrever o porquê ao lado do como e registrar onde cada escolha quebra. O parágrafo mais útil da documentação do projeto não é o que explica como algo funciona, é o que admite o limite: o state do bootstrap commitado, o NAT que existe por segurança e custa caro.

E a ressalva: tratar projeto pessoal como produção não é recomendação universal. Se o objetivo é validar uma ideia rápido, cem dólares por mês em infra parada é dinheiro mal gasto, e um container numa VM barata resolve. Paguei essa conta porque queria entender o formato com as mãos, não porque todo projeto pessoal precisa disso.

Se for por esse caminho, defina antes o que quer aprender. É o que separa aprendizado de uma fatura recorrente que você esquece de cancelar.

No próximo post vejo o outro lado: por que rodar duas tasks atrás de um ALB determinou o desenho da autenticação da API.


Referências Técnicas

Esta postagem está licenciada sob CC BY 4.0 pelo autor.