Todos os artigos

Notas de engenharia

Migrando pra AWS: IAM, EC2, RDS e o cutover real

A migração de um SaaS do Railway para EC2 e RDS na AWS: decisões sobre IAM e VPC, a virada em produção e os 4 problemas da pipeline de CI/CD.

  • aws
  • ec2
  • rds
  • iam
  • ci-cd
  • infraestrutura

Migrei a produção do Inside My Mind (IMM), um SaaS de hábitos e diário pessoal com IA, pra AWS. Neste relato, explico as decisões, o que deu errado e como resolvi os problemas — incluindo um incidente durante a troca do ambiente de produção, o cutover.

0. Contexto

O IMM rodava em Vercel (frontend) + Railway (backend) + Supabase (banco). A AWS começou como trilha paralela de estudo, com a opção real de virar produção antes do prazo, o que aconteceu em 2026-07-25 (ver §7).

A restrição era uma conta AWS com $120 de crédito, com vencimento em 8/out/2026 ou quando o saldo zerasse. O custo estimado da stack completa (EC2+RDS+S3) era de ~$25-27/mês. Nesse ritmo, o crédito duraria além do prazo: a data era o limite real, não o saldo. Decidi não migrar pro plano pago e planejei voltar pro Railway/Render ao expirar o crédito. Isso explica escolhas como não manter ALB e NAT Gateway rodando: o objetivo era aprender os conceitos, sem manter toda a infraestrutura permanentemente.

1. IAM: quem pode fazer o quê

Decisão: identidades separadas por função, não 1 admin genérico.

  • imm-admins (AdministratorAccess), só bootstrap, uso raro.
  • imm-dba: RDS, DynamoDB, SSM leitura.
  • imm-dev: EC2, logs, CloudFront leitura.
  • imm-infra: VPC, Route 53, CloudFront, ELB, bucket S3. Adicionado depois, quando ficou claro que “rede” não cabia nem em dba nem em dev.

Por quê: least privilege: se a credencial do imm-dev vazar, o atacante não toca no RDS. Um único admin torna qualquer vazamento total.

A fronteira entre dba e infra acabou sendo frequência da operação: RDS é day-2 ops recorrente (dba); S3 é provisionamento único, mesma natureza de VPC/EC2 (infra). Por isso o bucket S3 migrou de dba pra infra, e dynamodb:* entrou em dba.

Duas roles, não usuários, pra coisas que não são pessoas: imm-ec2-role (instância chama S3/SES/CloudWatch/SSM sem credencial gravada em .env) e imm-github-oidc-role (pipeline assume via OIDC, escopado a repo+branch: substitui access key fixa e de longa duração no GitHub Secrets). Root nunca é usado no dia a dia; MFA obrigatório.

2. EC2: onde a aplicação roda

Decisão: EC2 (IaaS), não Lambda, pro imm-api inteiro. A API já funcionava em produção (Railway): não fazia sentido reescrever pra serverless só pra aprender AWS. Resultado: t3.micro, Amazon Linux 2023, Nginx como reverse proxy (443 → 3001), PM2 mantendo o processo Node vivo.

Sair de PaaS pra IaaS significa herdar tudo que antes era do provedor: patch de SO, firewall, backup. É o “Security IN the cloud” virando trabalho meu.

Security Group com 4 regras, nenhuma a mais: 22 (SSH, só o meu IP), 80 (redireciona pra HTTPS), 443 (tráfego real), 3001 (nem precisa de regra, já que Nginx e Fastify falam via localhost).

3. S3: armazenamento de avatar

Decisão: 1 bucket, não 2. O bucket de áudio planejado foi cortado do escopo: a feature ia ser refatorada de qualquer forma, criar e derrubar depois é custo sem propósito.

S3 não vive dentro de nenhuma VPC: proteção vem de Block Public Access (padrão), IAM/bucket policy, e presigned URL pra upload sem expor credencial. O bucket de avatares começou público com CORS e evoluiu pra fechado com OAC (Origin Access Control): só o CloudFront acessa, direto é negado.

4. Lambda: só o presigned URL, não a API inteira

Decisão que mudou do plano original: a imm-api inteira ia rodar em Lambda. Na prática, virou um serviço auxiliar isolado: só gera a presigned URL de upload de avatar, caso de uso perfeito pra serverless (rápido, sem estado, esporádico). Reescrever a API inteira não tinha ganho real nesse momento.

Frontend
  → Lambda (gera presigned URL)
  → Browser sobe o arquivo direto no S3
  → EC2 só lê o avatar via CloudFront

A EC2 nunca manuseia o arquivo: o upload não passa por ela.

5. Redes: a parte mais decidida por trade-off consciente

  • VPC com 4 subnets: uma pública (EC2), duas privadas (RDS exige subnet group cobrindo 2 AZs mesmo em Single-AZ) e uma pública só pro lab do ALB. A EC2 fica pública, ao contrário do desenho “padrão-ouro” (LB → app privado → banco privado), porque sem ALB permanente é ela quem recebe tráfego direto (escolha de custo, não erro de arquitetura). Público vs privado é a route table: só a pública tem rota pro Internet Gateway.
  • NAT Gateway: decisão de não criar. Nenhum recurso privado precisa sair pra internet: RDS só fala com a EC2 pela rota local, e a EC2 já tem saída própria via IGW. Pagar ~$32/mês por um caminho que nada usa não fazia sentido.
  • VPC Endpoint: não criado, documentado pra depois. Otimização (tráfego S3 sem sair da rede AWS), não necessidade: a EC2 já acessa o S3 via IGW sem problema.
  • Migração de EC2 pra VPC nova via AMI: subnet de instância é imutável, não existe “mover”. AMI → nova EC2 → Elastic IP → validar tudo no ar → só então terminar a antiga.
  • Route 53 implementado de verdade: hosted zone + A record pro Elastic IP, revisando um plano anterior que adiava isso pra Cloudflare.
  • CloudFront só na frente do S3, não da API: mantém o CDN focado em assets estáticos com cache.

6. Banco de dados: RDS e backup

RDS: PostgreSQL 16, db.t3.micro, sem IP público. Postgres porque já é o que o IMM usa (Supabase), migração sem reescrever query, reaproveitando o subnet group das subnets privadas já criadas. O Security Group libera só o SG da EC2, nunca IP local, nem o meu: migrations rodam de dentro da EC2 via SSH, o RDS nunca aceita conexão de fora da VPC. SSL obrigatório via parameter group (rds.force_ssl = 1): garantia no servidor, não no client.

Backup: automático (1 dia, teto do free tier) + snapshot manual antes de mudança arriscada. AWS Backup (orquestração cross-recurso) não entra: o IMM tem um recurso com estado, orquestração extra ali é complexidade sem função.

7. Cutover de produção (2026-07-25)

O módulo de redes configurou Route 53 delegando api.insidemymind.tech como exercício de aprendizado: isso colocou tráfego real na EC2 sem decisão formal de cutover, só percebido quando rotas de upload de avatar e análise de IA começaram a dar 500. Diagnóstico: usuários reais só existiam no Supabase, mas a API na EC2 já falava com o RDS (só com dado de teste); nenhum lado “estava errado” isoladamente, só desincronizados.

Decisão: terminar o cutover, não reverter o DNS. Reverter escondia o sintoma sem resolver a causa; terminar trazia os dados reais pro RDS.

Execução: pg_dump --data-only via conexão direta (não o pooler, incompatível com transaction mode pooling); restore de dentro da EC2 via SSH, porque o RDS não é público; usuários de teste removidos via TRUNCATE ... CASCADE antes do restore. Verificação pós-restore bateu exatamente com o dump de origem, migração sem perda.

Resultado naquele momento: Railway pausado e banco do Supabase desativado. O storage do Supabase ainda estava ativo, pois avatar e áudio dependiam desse provedor no código. As mudanças seguintes estão na seção 9.

8. CD real (GitHub Actions → EC2 via SSM): 4 problemas até fechar

A pipeline foi implementada num PR, mas só funcionou depois de 4 rodadas de depuração. Os problemas envolveram a integração entre GitHub Actions, IAM e SSM Run Command.

  1. AssumeRoleWithWebIdentity negado. Um bloco environment: { name: production } muda o claim sub do token OIDC: o trust policy só aceita o formato escopado por branch. Fix: tirar o environment:.
  2. PM2 Process not found, depois EACCES: o maior problema real. SSM roda como root por padrão, não é SSH disfarçado. PM2 é por-usuário (imm-api rodava sob ec2-user), root nunca achava o processo, e a tentativa como root sujou node_modules com dono root, quebrando a próxima com EACCES. Fix: sudo -u ec2-user bash -lc '...'.
  3. Waiter estourando com o deploy ainda rodando. Timeout fixo do waiter (~100s), menor que o build real numa t3.micro. Fix: polling manual a cada 10s, até 8 min.
  4. Preso em Pending por 8 minutos. Checagem logo após send-command pode devolver InvocationDoesNotExist: tratado como transitório em vez de erro.

Efeito colateral: depois de 6 tentativas competindo por RAM numa t3.micro de 1GB, a instância parou de aceitar SSH e SSM ao mesmo tempo: sintoma de sistema sem recurso, não um serviço isolado travando. Reboot resolveu; hipótese mais provável é OOM: build concorrendo com a API em produção numa máquina de 1GB é receita conhecida pra isso.

9. Storage integrado, SES e GuardDuty

Áudio: sem bucket. Vai direto no body da requisição pro Gemini e é descartado: sem fila, sem Lambda de transcrição, sem custo de storage pra um dado que não precisa sobreviver ao request.

Avatar: Lambda + S3 + CloudFront, integrado de verdade. Dois bugs de CORS só apareceram testando no browser, nenhum em checagem de IAM: a Function URL não liberava authorization, e o bucket não tinha CORS configurado.

SES substitui o Resend em produção. SPF estava faltando: corrigido via TXT na Cloudflare; DKIM validado, produção liberada. Gotcha: a associação SES↔SNS só aparece na API v1, não na v2.

GuardDuty não ativado. IAM permite, mas a conta está em “Free Account Plan” e exige pagamento no cadastro. Decisão: não completar agora, mesmo racional de não fazer upgrade.

10. Recurso da EC2: resize não ficou adiado

CloudWatch mostrou CPU em 35-53%, longe do teto: não era CPU o gargalo. Causa real: zero swap + builds concorrentes disputando RAM numa t3.micro de 1GB. Decisão: swap de 2GB (fix imediato) + resize pra t3.small, 2GB RAM (fix estrutural), via stop → modify-instance-attribute → start. Elastic IP persiste, pm2 startup systemd religa o serviço sozinho.

11. Linha do tempo resumida

DecisãoMotivoQuando
3 grupos IAM em vez de 1 adminLeast privilege: vazamento de credencial fica contidoMódulo 1
OIDC em vez de access key no GitHubCredencial temporária, escopada a repo+branchMódulo 1
EC2 (não Lambda) pra API inteiraAPI já funciona, sem motivo pra reescrever só pra aprender AWSMódulo 2-3
NAT Gateway não criadoNenhum recurso privado precisa de saída pra internetMódulo 4
Migração de EC2 via AMISubnet de instância é imutável: não existe “mover”Módulo 4
S3 avatares: público+CORS → privado+OACPadrão mais seguro, é o que cai na provaMódulo 4
RDS sem IP público, SG só libera EC2Migrations rodam de dentro da EC2: banco nunca aceita conexão de fora da VPCMódulo 5
Cutover terminado, não revertidoReverter escondia o sintoma; terminar resolvia de vez25/jul
CD via SSM roda como ec2-user, não rootPM2 é por-usuário: root nunca acha processo de outro usuário25/jul
Resize da EC2: swap 2GB + t3.smallCloudWatch provou não ser CPU; era build concorrente disputando RAMMódulo 6-7
GuardDuty não ativadoBloqueado por “Free Account Plan” da conta (não IAM)Módulo 6-7