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:
- O endpoint de site do S3 não faz HTTPS. Num domínio próprio você não consegue servir
https://direto do endpoint de website do S3. Pra ter certificado você põe o CloudFront na frente, com o cert emitido no ACM, e pro CloudFront esse cert precisa estar emus-east-1, não importa onde esteja o bucket. - O setup seguro é um bucket privado. Em vez de deixar o bucket público, você o mantém trancado e deixa só o CloudFront ler, via OAC (Origin Access Control). A policy do bucket dá acesso à distribuição, não ao mundo.
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:
<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:
- O roteamento. O navegador pede
/articles/, mas o objeto é/articles/index.html. O CloudFront só adicionaindex.htmlna raiz, não em URLs de subpasta. Então toda URL de diretório dá 403. - O deploy. Meu CI montava uma pasta
dist/e sincronizava com--delete, mas eu tinha esquecido de copiar a pastaarticles/pra dentro dodist/. Os arquivos simplesmente não estavam lá.
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:
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:
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:
- 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 "/*"
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.
08 O que aprendi
- Com um bucket privado + OAC, um 403 AccessDenied geralmente significa 404. Não cace permissão quando o arquivo pode só não estar lá.
- "Hospedagem estática" não é só armazenamento, a camada de roteamento é onde um site estático de várias páginas de fato se resolve. Uma CloudFront Function é pequena, mas faz trabalho de verdade a cada requisição.
- Caminhos relativos de assets e barras finais andam juntos: decida suas URLs canônicas cedo e imponha isso na edge.
sync --deleteé um espelho, não um upload. Poderoso, mas o build tem que estar completo, porque o que não estiver nele desaparece.
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.