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 emdbanem emdev.
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.
AssumeRoleWithWebIdentitynegado. Um blocoenvironment: { name: production }muda o claimsubdo token OIDC: o trust policy só aceita o formato escopado por branch. Fix: tirar oenvironment:.PM2 Process not found, depoisEACCES: o maior problema real. SSM roda como root por padrão, não é SSH disfarçado. PM2 é por-usuário (imm-apirodava sobec2-user), root nunca achava o processo, e a tentativa como root sujounode_modulescom donoroot, quebrando a próxima comEACCES. Fix:sudo -u ec2-user bash -lc '...'.- 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. - Preso em
Pendingpor 8 minutos. Checagem logo apóssend-commandpode devolverInvocationDoesNotExist: 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ão | Motivo | Quando |
|---|---|---|
| 3 grupos IAM em vez de 1 admin | Least privilege: vazamento de credencial fica contido | Módulo 1 |
| OIDC em vez de access key no GitHub | Credencial temporária, escopada a repo+branch | Módulo 1 |
| EC2 (não Lambda) pra API inteira | API já funciona, sem motivo pra reescrever só pra aprender AWS | Módulo 2-3 |
| NAT Gateway não criado | Nenhum recurso privado precisa de saída pra internet | Módulo 4 |
| Migração de EC2 via AMI | Subnet de instância é imutável: não existe “mover” | Módulo 4 |
| S3 avatares: público+CORS → privado+OAC | Padrão mais seguro, é o que cai na prova | Módulo 4 |
| RDS sem IP público, SG só libera EC2 | Migrations rodam de dentro da EC2: banco nunca aceita conexão de fora da VPC | Módulo 5 |
| Cutover terminado, não revertido | Reverter escondia o sintoma; terminar resolvia de vez | 25/jul |
CD via SSM roda como ec2-user, não root | PM2 é por-usuário: root nunca acha processo de outro usuário | 25/jul |
Resize da EC2: swap 2GB + t3.small | CloudWatch provou não ser CPU; era build concorrente disputando RAM | Módulo 6-7 |
| GuardDuty não ativado | Bloqueado por “Free Account Plan” da conta (não IAM) | Módulo 6-7 |