◂ Escritos
#002· infra· AWS · S3 · CloudFront · Route 53· 7 min de leitura

Hospedando um portfólio estático na AWS: S3, CloudFront e Route 53

Apontar o domínio, subir os arquivos, pronto, esse era o plano. O que de fato colocou meu portfólio no ar foi uma CloudFront Function que eu nem sabia que precisava, e um AccessDenied que no fim significava outra coisa completamente diferente.

01 O problema

Eu tinha o portfólio pronto, duas versões imersivas (uma descida do espaço e uma viagem para dentro da máquina) mais uma seção /articles. Agora eu precisava dele no ar em luizpicoli.com: um domínio de verdade, HTTPS, e de preferência um deploy que acontecesse sozinho quando eu desse push na main. Barato e estático, sem servidor pra cuidar.

02 Minha primeira ideia

O S3 tem "static website hosting" ali no console. Meu plano era o óbvio: ligar isso, deixar o bucket público, apontar o domínio pra ele, pronto. Funciona pela metade, e a metade que não funciona é justamente a que importa.

03 O que descobri

Duas coisas que a versão "no piloto automático" ignora:

Então o formato virou: Route 53 → CloudFront (HTTPS via ACM) → S3 privado via OAC. Nada de "S3 website hosting".

04 O erro

A primeira vez que abri luizpicoli.com/articles/ apareceu isto:

xml response
<Error>
  <Code>AccessDenied</Code>
  <Message>Access Denied</Message>
</Error>

"Access Denied", então saí caçando um bug de permissão. Pista errada. Com um bucket privado + OAC, quando um objeto não existe o S3 retorna 403 AccessDenied em vez de 404 Not Found, porque a policy não concede s3:ListBucket. Então "AccessDenied" aqui quase sempre significa "essa key não está no bucket", não um problema de permissão.

E havia dois jeitos de acabar com uma key faltando:

05 A solução

A metade do roteamento se resolve com uma CloudFront Function no viewer request, um pedacinho de JS que roda na edge a cada requisição. A minha faz dois trabalhos: redireciona www → apex, e reescreve URLs de diretório pro seu index.html:

javascript viewer-request.js
function handler(event) {
    var request = event.request;
    var host = request.headers.host.value;
    var uri = request.uri;

    // Redireciona www -> domínio apex
    if (host === "www.luizpicoli.com") {
        return {
            statusCode: 301,
            statusDescription: "Moved Permanently",
            headers: { location: { value: "https://luizpicoli.com" + uri } }
        };
    }

    var lastSegment = uri.split("/").pop();
    var hasExtension = lastSegment.includes(".");

    // Força barra final pra caminhos relativos sempre resolverem
    if (!hasExtension && !uri.endsWith("/")) {
        return {
            statusCode: 301,
            statusDescription: "Moved Permanently",
            headers: { location: { value: uri + "/" } }
        };
    }

    // Reescreve URLs de diretório pro seu index.html
    if (uri.endsWith("/")) {
        request.uri = uri + "index.html";
    }

    return request;
}

O redirect de barra final importa mais do que parece. As páginas dos artigos carregam CSS e JS com caminhos relativos (../style.css), e isso só resolve corretamente quando a URL termina em barra. Forçar a barra com um 301 faz /articles/algum-post se comportar como /articles/algum-post/, então os assets sempre carregam, e canoniza a URL, o que é bom pra SEO também.

A metade do deploy foi só lembrar de incluir toda rota no build. O caminho completo da requisição acabou ficando assim:

flow request path
browser ─▶ Route 53 ─▶ CloudFront ─▶ CloudFront Function ─▶ S3 (privado, OAC)
                            │                  │
                       HTTPS via ACM    www→apex · dir → index.html

06 Implementação

A coisa toda é um punhado de peças conectadas: um bucket S3 privado com uma policy que só deixa a distribuição ler (OAC); um certificado ACM pro apex e pro www, emitido em us-east-1; uma distribuição CloudFront com o bucket como origin, o cert anexado e a função no viewer request; e um alias no Route 53 apontando o apex pra distribuição. Depois GitHub Actions pra buildar e fazer deploy a cada push na main:

yaml .github/workflows/deploy.yml
- name: Montar estrutura de rotas
  run: |
    mkdir -p dist
    cp -r space    dist/space
    cp -r computer dist/computer
    cp -r articles dist/articles
    cp index.html  dist/index.html

- name: Upload para S3
  run: |
    aws s3 sync dist/ s3://${{ vars.S3_BUCKET }} --delete

- name: Invalidar cache do CloudFront
  run: |
    aws cloudfront create-invalidation \
      --distribution-id ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }} \
      --paths "/*"
Dois detalhes que valem destacar

s3 sync --delete torna o bucket um espelho do build, qualquer coisa que não esteja no dist/ é removida, que é exatamente por que a pasta articles/ faltando ficou invisível até eu adicionar aquela linha de cp. E o create-invalidation "/*" limpa o cache do CloudFront pra o novo deploy aparecer na hora em vez de esperar os TTLs, incluindo qualquer 403 velho que ele tivesse cacheado antes.

07 Resultado

Agora o ciclo é: push na main, esperar uns dois minutos, e a mudança está no ar em luizpicoli.com com HTTPS. Sem servidor, sem upload manual, sem FTP, e nesse tráfego custa uns centavos por mês.

Execução do deploy no GitHub Actions espelhando o site pro S3 e invalidando o CloudFront
Um push na main, e a pipeline espelha o repositório pro S3 e limpa o cache.

08 O que aprendi

A parte pra qual eu tinha reservado cinco minutos, "apontar o domínio pro S3", foi a que mais me ensinou. O bug não estava no meu código; estava no meu modelo de como uma requisição chega até o arquivo.